Le partage d’état en concurrence
L’envoi de messages est un assez bon moyen de gestion de la concurrence, mais ce n’est pas le seul. Une autre méthode serait, pour plusieurs tâches, d’accéder aux mêmes données partagées. Repensons à cette partie du slogan de la documentation du langage Go : “ne communiquez pas en partageant la mémoire”.
À quoi ressemble la communication par partage de mémoire ? De plus, pourquoi les partisans de l’envoi de messages déconseillent-ils le partage de mémoire ?
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.
Contrôle d’accès avec les mutex
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.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut nombre = m.lock().unwrap();
*nombre = 6;
}
println!("m = {m:?}");
}
Mutex<T> in a single-threaded context for simplicityComme 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 type de m est Mutex<i32>, et pas i32, donc nous devons appeler lock 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.
L’appel à lock retourne un type appelé MutexGuard, intégré dans un LockResult que nous avons géré en faisant appel à unwrap. Le type MutexGuard implémente Deref pour pouvoir pointer sur la donnée interne ; ce type 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. 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.
Accès partagé au Mutex<T>
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. L’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.
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!("Résultat : {}", *compteur.lock().unwrap());
}
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:21:29
|
5 | let compteur = Mutex::new(0);
| -------- move occurs because `compteur` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let manipulateur = thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop
...
21 | println!("Résultat : {}", *compteur.lock().unwrap());
| ^^^^^^^^ value borrowed here after move
|
help: consider moving the expression out of the loop so it is only moved once
|
8 ~ let mut value = compteur.lock();
9 ~ for _ in 0..10 {
10 | let manipulateur = thread::spawn(move || {
11 ~ let mut nombre = value.unwrap();
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 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. 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 la méthode permettant d’avoir plusieurs propriétaires 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.
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());
}
Rc<T> to allow multiple threads to own the Mutex<T>À 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<std::sync::Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let manipulateur = thread::spawn(move || {
| ------------- ^------
| | |
| ____________________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
| | |
| | required by a bound introduced by this call
12 | | let mut nombre = compteur.lock().unwrap();
13 | |
14 | | *nombre += 1;
15 | | });
| |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let manipulateur = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1
For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 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 sûre 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 sûre 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étail 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.
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());
}
Arc<T> to wrap the Mutex<T> to be able to share ownership across multiple threadsCe code va afficher ceci :
Result: 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.
Notez que si vous faites de simples opérations numériques, il existe des types plus simples que les types Mutex<T> fournis par le module std::sync::atomic de la bibliothèque standard. Ces types offrent un accès atomique, concurrent et sécurisé aux types primitifs. Nous avons choisi d’utiliser Mutex<T> avec un type primitif pour cet exemple afin de pouvoir nous concentrer sur le fonctionnement de Mutex<T>.
Comparaison de 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 dans le chapitre 15, l’utilisation de Rc<T> comportait 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.