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); }
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());
}
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());
}
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()); }
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.