Le partage d'état en concurrence

L'envoi de messages est un assez bon moyen de gestion de la concurrence, mais il n'y en a pas qu'un seul. Repensons à cette partie du slogan de la documentation du langage Go : “ne communiquez pas en partageant la mémoire”.

A quoi ressemble la communication par partage de mémoire ? De plus, pourquoi les partisans de l'envoi de messages ne devraient-ils pas l'utiliser et faire plutôt le contraire ?

De manière générale, les canaux dans les langages de programmation ressemblent à la possession exclusive, car une fois que vous avez transféré une valeur dans un canal, vous ne pouvez plus utiliser cette valeur. Le partage de mémoire en concurrence est comme de la possession multiple : plusieurs tâches peuvent accéder au même endroit de la mémoire en même temps. Comme vous l'avez vu au chapitre 15, dans lequel les pointeurs intelligents la rendent possible, la possession multiple peut ajouter de la complexité car ses différents propriétaires ont besoin d'être gérés. Le système de type de Rust et les règles de possession aident beaucoup à les gérer correctement. Par exemple, découvrons les mutex, une des primitives les plus courantes pour partager la mémoire.

Utiliser les mutex pour permettre l'accès à la donnée à une seule tâche à la fois

Mutex est une abréviation pour mutual exclusion, ce qui veut dire qu'un mutex ne permet qu'à une seule tâche d'accéder à une donnée à un instant donné. Pour accéder à la donnée dans un mutex, une tâche doit d'abord signaler qu'elle souhaite y accéder en demandant l'obtention du verrou du mutex. Le verrou est une structure de donnée qui fait partie du mutex et qui assure le suivi de qui a actuellement accès à la donnée. Par conséquent, le mutex est qualifié de gardien de la donnée qu'il renferme via le système de verrou.

Les mutex ont la réputation d'être difficiles à utiliser car vous devez veiller à deux règles :

  • Vous devez obtenir le verrou avant d'utiliser la donnée.
  • Lorsque vous avez fini avec la donnée que le mutex garde, vous devez déverrouiller la donnée afin que d'autres tâches puissent obtenir le verrou.

Pour faire une métaphore de la vie courante d'un mutex, imaginez une table ronde lors d'une conférence avec un seul microphone. Avant qu'un participant ne puisse parler, il doit demander ou signaler qu'il veut utiliser le micro. Lorsqu'il obtient le micro, il peut parler aussi longtemps qu'il le souhaite et ensuite passer le micro au prochain participant qui a demandé à pouvoir parler. Si un participant oublie de rendre le micro après avoir fini de parler, personne d'autre ne peut parler. Si la gestion du micro partagé se passe mal, la table ronde ne fonctionnera pas comme prévu !

La gestion des mutex peut devenir incroyablement compliquée, c'est pourquoi tant de personnes sont partisanes des canaux. Cependant, grâce au système de type de Rust et aux règles de possession, vous ne pouvez pas vous tromper dans le verrouillage et déverrouillage.

L'API des Mutex<T>

Pour illustrer l'utilisation d'un mutex, commençons par utiliser un mutex dans le contexte d'une seule tâche, comme dans l'encart 16-12 :

Fichier : src/main.rs

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut nombre = m.lock().unwrap();
        *nombre = 6;
    }

    println!("m = {:?}", m);
}

Encart 16-12 : découverte de l'API de Mutex<T> dans le contexte d'une seule tâche pour raison de simplicité

Comme avec beaucoup de types, nous créons un Mutex<T> en utilisant la fonction associée new. Pour accéder à la donnée dans le mutex, nous utilisons la méthode lock pour obtenir le verrou. Cela va bloquer la tâche courante, donc elle ne s'exécutera plus tant que ce ne sera pas à notre tour d'avoir le verrou.

L'appel à lock échouera si une autre tâche qui avait le verrou a paniqué. Dans ce cas, personne ne pourra obtenir le verrou, donc nous avons choisi d'utiliser unwrap pour que notre tâche panique si nous nous retrouvons dans une telle situation.

Après avoir obtenu le verrou, nous pouvons utiliser la valeur de retour comme une référence mutable vers la donnée, qui s'appellera nombre dans ce cas. Le système de type s'assure que nous obtenons le verrou avant d'utiliser la valeur présente dans m : le Mutex<i32> n'est pas un i32, donc nous devons obtenir le verrou pour pouvoir utiliser la valeur i32. Nous ne pouvons pas l'oublier ; le système de type ne nous laissera pas accéder au i32 à l'intérieur de toute façon.

Comme vous pouvez vous en douter, Mutex<T> est un pointeur intelligent. Plus précisément, l'appel à lock retourne un pointeur intelligent MutexGuard, intégré dans un LockResult que nous avons géré en faisant appel à unwrap. Le pointeur intelligent MutexGuard implémente Deref pour pouvoir pointer sur la donnée interne ; ce pointeur intelligent implémente aussi Drop qui libère le verrou automatiquement lorsqu'un MutexGuard sort de la portée, ce qui arrive à la fin de la portée interne dans l'encart 16-12. Au final, nous ne risquons pas d'oublier de rendre le verrou et ainsi bloquer l'utilisation du mutex pour les autres tâches car la libération du verrou se produit automatiquement.

Après avoir libéré le verrou, nous pouvons afficher la valeur dans le mutex et constater que nous avons pu changer la valeur interne du i32 à 6.

Partager un Mutex<T> entre plusieurs tâches

Essayons maintenant de partager une valeur entre plusieurs tâches en utilisant Mutex<T>. Nous allons faire fonctionner 10 tâches et faire en sorte que chacune augmente la valeur du compteur de 1, donc le compteur va passer de 0 à 10. Le prochain exemple dans l'encart 16-13 débouchera sur une erreur de compilation, et nous allons utiliser cette erreur pour en apprendre plus sur l'utilisation de Mutex<T> et sur la façon dont Rust nous aide à l'utiliser correctement.

Fichier : src/main.rs

use std::sync::Mutex;
use std::thread;

fn main() {
    let compteur = Mutex::new(0);
    let mut manipulateurs = vec![];

    for _ in 0..10 {
        let manipulateur = thread::spawn(move || {
            let mut nombre = compteur.lock().unwrap();

            *nombre += 1;
        });
        manipulateurs.push(manipulateur);
    }

    for manipulateur in manipulateurs {
        manipulateur.join().unwrap();
    }

    println!("Resultat : {}", *compteur.lock().unwrap());
}

Encart 16-13 : dix tâches qui augmentent chacune un compteur gardé par un Mutex<T>

Nous avons créé une variable compteur pour stocker un i32 dans un Mutex<T>, comme nous l'avons fait dans l'encart 16-12. Ensuite, nous créons 10 tâches en itérant sur un intervalle de nombres. Nous utilisons thread::spawn et nous donnons à toutes les tâches la même fermeture, qui déplace le compteur dans la tâche, obtient le verrou sur le Mutex<T> en faisant appel à la méthode lock et ajoute ensuite 1 à la valeur présente dans le mutex. Lorsqu'une tâche finit d'exécuter sa fermeture, nombre va sortir de la portée et va libérer le verrou afin qu'une autre tâche puisse l'obtenir.

Dans la tâche principale, nous collectons tous les manipulateurs. Ensuite, comme nous l'avions fait dans l'encart 16-2, nous faisons appel à join sur chaque manipulateur pour s'assurer que toutes les tâches ont fini. Une fois que c'est le cas, la tâche principale va obtenir le verrou et afficher le résultat de ce programme.

Nous avions annoncé que cet exemple ne se compilerait pas. Découvrons maintenant pourquoi !

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: use of moved value: `compteur`
  --> src/main.rs:9:36
   |
5  |     let compteur = Mutex::new(0);
   |         -------- move occurs because `compteur` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9  |         let manipulateur = thread::spawn(move || {
   |                                          ^^^^^^^ value moved into closure here, in previous iteration of loop
10 |             let mut nombre = compteur.lock().unwrap();
   |                              -------- use occurs due to use in closure

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` due to previous error

Le message d'erreur signale que la valeur compteur a été déplacée dans l'itération précédente de la boucle. Donc Rust nous explique qu'il ne peut pas déplacer la possession du verrou de compteur dans plusieurs tâches. Corrigeons cette erreur de compilation avec une méthode permettant d'avoir plusieurs propriétaires et que nous avons vue au chapitre 15.

Plusieurs propriétaires avec plusieurs tâches

Dans le chapitre 15, nous avons assigné plusieurs propriétaires à une valeur en utilisant le pointeur intelligent Rc<T> pour créer un compteur de référence. Faisons la même chose ici et voyons ce qui se passe. Nous allons intégrer le Mutex<T> dans un Rc<T> dans l'encart 16-14 et cloner le Rc<T> avant de déplacer sa possession à la tâche.

Fichier : src/main.rs

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let compteur = Rc::new(Mutex::new(0));
    let mut manipulateurs = vec![];

    for _ in 0..10 {
        let compteur = Rc::clone(&compteur);
        let manipulateur = thread::spawn(move || {
            let mut nombre = compteur.lock().unwrap();

            *nombre += 1;
        });
        manipulateurs.push(manipulateur);
    }

    for manipulateur in manipulateurs {
        manipulateur.join().unwrap();
    }

    println!("Résultat : {}", *compteur.lock().unwrap());
}

Encart 16-14 : tentative d'utilisation d'un Rc<T> pour nous permettre d'utiliser plusieurs tâches qui posséderont le Mutex<T>

A nouveau, nous compilons et nous obtenons ... une erreur différente ! Le compilateur nous en apprend beaucoup.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
   --> src/main.rs:11:22
    |
11  |           let manipulateur = thread::spawn(move || {
    |  ____________________________^^^^^^^^^^^^^_-
    | |                            |
    | |                            `Rc<Mutex<i32>>` cannot be sent between threads safely
12  | |             let mut nombre = compteur.lock().unwrap();
13  | |
14  | |             *nombre += 1;
15  | |         });
    | |_________- within this `[closure@src/main.rs:11:36: 15:10]`
    |
    = help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
    = note: required because it appears within the type `[closure@src/main.rs:11:36: 15:10]`
note: required by a bound in `spawn`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` due to previous error

Ouah, ce message d'erreur est très verbeux ! Voici la partie la plus importante sur laquelle se concentrer : `Rc<Mutex<i32>>` cannot be sent between threads safely. Le compilateur nous indique aussi pour quelle raison : the trait `Send` is not implemented for `Rc<Mutex<i32>>` . Nous allons voir Send dans la prochaine section : c'est l'un des traits qui garantissent que le type que nous utilisons avec les tâches est prévu pour être utilisé dans des situations de concurrence.

Malheureusement l'utilisation de Rc<T> n'est pas sure lorsqu'il est partagé entre plusieurs tâches. Lorsque Rc<T> gère le compteur de références, il incrémente le compteur autant de fois que nous avons fait appel à clone et décrémente le compteur à chaque fois qu'un clone est libéré. Mais il n'utilise pas de primitives de concurrence pour s'assurer que les changements faits au compteur ne peuvent pas être interrompus par une autre tâche. Cela pourrait provoquer des bogues subtils induisant une mauvaise gestion du compteur, ce qui pourrait provoquer des fuites de mémoire ou faire qu'une valeur soit libérée avant que nous ayions fini de l'utiliser. Nous avons besoin d'un type exactement comme Rc<T> mais qui procède aux changements du compteur de références de manière sure en situation de concurrence.

Compteur de référence atomique avec Arc<T>

Heureusement, Arc<T> est un type comme Rc<T> qui est sûr en situation de concurrence. Le A signifie atomique, ce qui signifie que c'est un type compteur de références atomique. L'atome est une sorte de primitive concurrente que nous n'allons pas aborder en détails ici : rendez-vous dans la documentation de la bibliothèque standard sur std::sync::atomic pour en savoir plus. Pour le moment, vous avez juste besoin de retenir que les atomes fonctionnent comme les types primitifs mais qui sont sûrs à partager entre plusieurs tâches.

Vous vous demandez pourquoi tous les types primitifs ne sont pas atomiques et pourquoi les types de la bibliothèque standard ne sont pas implémentés en utilisant Arc<T> par défaut. La raison à cela est que la sécurité entre les tâches a un coût sur les performances que vous n'êtes prêt à payer que lorsque vous en avez besoin. Si vous procédez à des opérations sur des valeurs uniquement dans une seule tâche, votre code va s'exécuter plus vite car il n'a pas besoin d'appliquer les garanties fournies par les types atomiques.

Retournons à notre exemple : Arc<T> et Rc<T> ont la même API, donc corrigeons notre programme en changeant la ligne use, l'appel à new et l'appel à clone. Le code dans l'encart 16-15 va finalement se compiler et s'exécuter :

Fichier : src/main.rs

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let compteur = Arc::new(Mutex::new(0));
    let mut manipulateurs = vec![];

    for _ in 0..10 {
        let compteur = Arc::clone(&compteur);
        let manipulateur = thread::spawn(move || {
            let mut nombre = compteur.lock().unwrap();

            *nombre += 1;
        });
        manipulateurs.push(manipulateur);
    }

    for manipulateur in manipulateurs {
        manipulateur.join().unwrap();
    }

    println!("Résultat : {}", *compteur.lock().unwrap());
}

Encart 16-15 : utilisation d'un Arc<T> pour englober le Mutex<T> afin de partager la possession entre plusieurs tâches

Ce code va finalement afficher ceci :

Resultat : 10

Nous y sommes arrivés ! Nous avons compté de 0 à 10, ce qui ne semble pas très impressionnant, mais cela nous a appris beaucoup sur Mutex<T> et la sûreté des tâches. Vous pouvez aussi utiliser cette structure de programme pour procéder à des opérations plus complexes que simplement incrémenter un compteur. En utilisant cette stratégie, vous pouvez diviser un calcul en différentes parties, répartir ces parties sur des tâches, et ensuite utiliser un Mutex<T> pour faire en sorte que chaque tâche mette à jour le résultat final avec sa propre partie.

Similarités entre RefCell<T>/Rc<T> et Mutex<T>/Arc<T>

Vous avez peut-être constaté que compteur est immuable mais que nous pouvons obtenir une référence mutable vers la valeur qu'il renferme ; cela signifie que Mutex<T> a une mutabilité interne, comme le fait la famille des Cell. De la même manière que nous avons utilisé RefCell<T> au chapitre 15 pour nous permettre de changer le contenu dans un Rc<T>, nous utilisons Mutex<T> pour modifier le contenu d'un Arc<T>.

Un autre détail à souligner est que Rust ne peut pas vous protéger de tous les genres d'erreurs de logique lorsque vous utilisez Mutex<T>. Souvenez-vous que le chapitre 15 utilisait Rc<T> avec le risque de créer des boucles de références, dans lesquelles deux valeurs Rc<T> se référeraient l'une à l'autre, ce qui provoquait des fuites de mémoire. De la même manière, l'utilisation de Mutex<T> risque de créer des interblocages. Cela se produit lorsqu'une opération nécessite de verrouiller deux ressources et que deux tâches ont chacune un des deux verrous, ce qui fait qu'elles s'attendent mutuellement pour toujours. Si vous êtes intéressés par les interblocages, essayez de créer un programme Rust qui a un interblocage ; recherchez ensuite des stratégies pour remédier aux interblocages dans n'importe quel langage et implémentez-les en Rust. La documentation de l'API de la bibliothèque standard pour Mutex<T> et MutexGuard offre des informations précieuses à ce sujet.

Nous allons terminer ce chapitre en parlant des traits Send et Sync et voir comment nous pouvons les utiliser sur des types personnalisés.