La concurrence à état partagé
Le passage de messages est un bon moyen de gérer la concurrence, mais ce n’est pas le seul. Une autre méthode serait que plusieurs threads accèdent aux mêmes données partagées. Considérez à nouveau cette partie du slogan de la documentation du langage Go : “Ne communiquez pas en partageant de la mémoire.”
À quoi ressemblerait la communication par partage de mémoire ? Et pourquoi les partisans du passage de messages recommanderaient-ils de ne pas utiliser le partage de mémoire ?
D’une certaine manière, les canaux dans n’importe quel langage de programmation sont similaires à la possession unique car une fois que vous transférez une valeur dans un canal, vous ne devriez plus utiliser cette valeur. La concurrence par mémoire partagée est comme la possession multiple : plusieurs threads peuvent accéder au même emplacement mémoire en même temps. Comme vous l’avez vu au chapitre 15, où les pointeurs intelligents rendaient possible la possession multiple, la possession multiple peut ajouter de la complexité car ces différents propriétaires doivent être gérés. Le système de types de Rust et les règles de possession aident grandement à effectuer cette gestion correctement. Pour un exemple, examinons les mutex, l’une des primitives de concurrence les plus courantes pour la mémoire partagée.
Contrôler l’accès avec les mutex
Mutex est une abréviation de mutual exclusion (exclusion mutuelle), car un mutex ne permet qu’à un seul thread d’accéder à certaines données à un moment donné. Pour accéder aux données dans un mutex, un thread doit d’abord signaler qu’il veut y accéder en demandant à acquérir le verrou du mutex. Le verrou (lock) est une structure de données qui fait partie du mutex et qui suit qui a actuellement l’accès exclusif aux données. Par conséquent, le mutex est décrit comme gardant les données qu’il contient via le système de verrouillage.
Les mutex ont la réputation d’être difficiles à utiliser car vous devez vous souvenir de deux règles :
- Vous devez tenter d’acquérir le verrou avant d’utiliser les données.
- Quand vous avez fini avec les données que le mutex garde, vous devez déverrouiller les données pour que d’autres threads puissent acquérir le verrou.
Pour une métaphore du monde réel d’un mutex, imaginez une table ronde lors d’une conférence avec un seul microphone. Avant qu’un panéliste puisse parler, il doit demander ou signaler qu’il veut utiliser le microphone. Quand il obtient le microphone, il peut parler aussi longtemps qu’il le souhaite puis passer le microphone au prochain panéliste qui demande à parler. Si un panéliste oublie de passer le microphone quand il a fini, personne d’autre ne peut parler. Si la gestion du microphone partagé se passe mal, la table ronde ne fonctionnera pas comme prévu !
La gestion des mutex peut être incroyablement délicate à réaliser correctement, c’est pourquoi tant de gens sont enthousiastes à propos des canaux. Cependant, grâce au système de types de Rust et aux règles de possession, vous ne pouvez pas vous tromper dans le verrouillage et le déverrouillage.
L’API de Mutex<T>
Comme exemple d’utilisation d’un mutex, commençons par utiliser un mutex dans un contexte mono-thread, comme montré dans l’encart 16-12.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 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 aux données à l’intérieur du mutex, nous utilisons la méthode lock pour acquérir le verrou. Cet appel bloquera le thread courant pour qu’il ne puisse faire aucun travail jusqu’à ce que ce soit notre tour d’avoir le verrou.
L’appel à lock échouerait si un autre thread détenant le verrou a paniqué. Dans ce cas, personne ne pourrait jamais obtenir le verrou, donc nous avons choisi d’utiliser unwrap et de faire paniquer ce thread si nous sommes dans cette situation.
Après avoir acquis le verrou, nous pouvons traiter la valeur de retour, nommée num dans ce cas, comme une référence mutable vers les données à l’intérieur. Le système de types garantit que nous acquérons un verrou avant d’utiliser la valeur dans m. Le type de m est Mutex<i32>, pas i32, donc nous devons appeler lock pour pouvoir utiliser la valeur i32. Nous ne pouvons pas oublier ; le système de types ne nous laissera pas accéder au i32 intérieur autrement.
L’appel à lock retourné un type appelé MutexGuard, enveloppé dans un LockResult que nous avons géré avec l’appel à unwrap. Le type MutexGuard implémente Deref pour pointer vers nos données internes ; le type a aussi une implémentation de Drop qui libère le verrou automatiquement quand un MutexGuard sort de la portée, ce qui se produit à la fin de la portée intérieure. En conséquence, nous ne risquons pas d’oublier de libérer le verrou et de bloquer le mutex pour les autres threads car la libération du verrou se fait automatiquement.
Après avoir libéré le verrou, nous pouvons afficher la valeur du mutex et voir que nous avons pu changer le i32 intérieur à 6.
Accès partagé à Mutex<T>
Maintenant, essayons de partager une valeur entre plusieurs threads en utilisant Mutex<T>. Nous allons lancer 10 threads et faire en sorte que chacun incrémente un compteur de 1, de sorte que le compteur passe de 0 à 10. L’exemple de l’encart 16-13 aura une erreur de compilation, et nous utiliserons cette erreur pour en apprendre davantage sur l’utilisation de Mutex<T> et comment Rust nous aide à l’utiliser correctement.
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Mutex<T>Nous créons une variable counter pour contenir un i32 dans un Mutex<T>, comme nous l’avons fait dans l’encart 16-12. Ensuite, nous créons 10 threads en itérant sur une plage de nombres. Nous utilisons thread::spawn et donnons à tous les threads la même closure : une qui déplace le compteur dans le thread, acquiert un verrou sur le Mutex<T> en appelant la méthode lock, puis ajouté 1 à la valeur dans le mutex. Quand un thread finit d’exécuter sa closure, num sortira de la portée et libérera le verrou pour qu’un autre thread puisse l’acquérir.
Dans le thread principal, nous collectons tous les handles de jointure. Ensuite, comme nous l’avons fait dans l’encart 16-2, nous appelons join sur chaque handle pour nous assurer que tous les threads finissent. À ce stade, le thread principal acquerra le verrou et affichera le résultat de ce programme.
Nous avons suggéré que cet exemple ne compilerait pas. Maintenant, découvrons pourquoi ! console {{#include ../listings/ch16-fearless-concurrency/listing-16-13/output.txt}}
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
--> src/main.rs:21:29
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let handle = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Result: {}", *counter.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 = counter.lock();
9 ~ for _ in 0..10 {
10 | let handle = thread::spawn(move || {
11 ~ let mut num = 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 indique que la valeur counter a été déplacée dans l’itération précédente de la boucle. Rust nous dit que nous ne pouvons pas déplacer la possession du verrou counter dans plusieurs threads. Corrigeons l’erreur de compilation avec la méthode de possession multiple dont nous avons discuté au chapitre 15.
Possession multiple avec plusieurs threads
Au chapitre 15, nous avons donné une valeur à plusieurs propriétaires en utilisant le pointeur intelligent Rc<T> pour créer une valeur à comptage de références. Faisons la même chose ici et voyons ce qui se passe. Nous enveloppons le Mutex<T> dans un Rc<T> dans l’encart 16-14 et clonons le Rc<T> avant de transférer la possession au thread.
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Rc<T> to allow multiple threads to own the Mutex<T>Encore une fois, nous compilons et obtenons… des erreurs différentes ! Le compilateur nous en apprend beaucoup : console {{#include ../listings/ch16-fearless-concurrency/listing-16-14/output.txt}}
$ 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 handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 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 handle = 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
Le message d’erreur est très verbeux ! Voici la partie importante sur laquelle se concentrer : `Rc<Mutex<i32>>` cannot be sent between threads safely (ne peut pas être envoyé entre les threads de manière sûre). Le compilateur nous dit aussi pourquoi : the trait `Send` is not implemented for `Rc<Mutex<i32>>`. Nous parlerons de Send dans la prochaine section : c’est l’un des traits qui garantit que les types que nous utilisons avec les threads sont destinés à être utilisés dans des situations concurrentes.
Malheureusement, Rc<T> n’est pas sûr à partager entre les threads. Quand Rc<T> gère le compteur de références, il ajouté au compteur pour chaque appel à clone et soustrait du compteur quand chaque clone est libéré. Mais il n’utilise aucune primitive de concurrence pour s’assurer que les modifications du compteur ne peuvent pas être interrompues par un autre thread. Cela pourrait mener à des compteurs erronés – des bogues subtils qui pourraient à leur tour provoquer des fuites de mémoire ou une valeur libérée avant que nous en ayons fini avec elle. Ce dont nous avons besoin est un type exactement comme Rc<T>, mais qui modifié le compteur de références de manière sûre pour les threads.
Comptage de références atomique avec Arc<T>
Heureusement, Arc<T> est un type comme Rc<T> qui est sûr à utiliser dans des situations concurrentes. Le a signifie atomic (atomique), ce qui signifie que c’est un type à comptage de références atomique. Les atomiques sont un type supplémentaire de primitive de concurrence que nous ne couvrirons pas en détail ici : consultez la documentation de la bibliothèque standard pour [std::sync::atomic][atomic] pour plus de détails. À ce stade, vous avez juste besoin de savoir que les atomiques fonctionnent comme les types primitifs mais sont sûrs à partager entre les threads.
Vous pourriez alors vous demander pourquoi tous les types primitifs ne sont pas atomiques et pourquoi les types de la bibliothèque standard ne sont pas implémentés pour utiliser Arc<T> par défaut. La raison est que la sécurité des threads à un coût en performance que vous ne voulez payer que quand vous en avez vraiment besoin. Si vous effectuez simplement des opérations sur des valeurs dans un seul thread, votre code peut s’exécuter plus rapidement s’il n’a pas à appliquer les garanties que fournissent les atomiques.
Revenons à notre exemple : Arc<T> et Rc<T> ont la même API, donc nous corrigeons notre programme en changeant la ligne use, l’appel à new, et l’appel à clone. Le code de l’encart 16-15 compilera et s’exécutera enfin.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Arc<T> to wrap the Mutex<T> to be able to share ownership across multiple threadsCe code affichera ce qui suit :
Result: 10
Nous avons réussi ! Nous avons compté de 0 à 10, ce qui peut ne pas sembler très impressionnant, mais cela nous a beaucoup appris sur Mutex<T> et la sécurité des threads. Vous pourriez aussi utiliser la structure de ce programme pour effectuer des opérations plus complexes que simplement incrémenter un compteur. En utilisant cette stratégie, vous pouvez diviser un calcul en parties indépendantes, répartir ces parties entre les threads, puis utiliser un Mutex<T> pour que chaque thread mette à jour le résultat final avec sa partie.
Notez que si vous effectuez des opérations numériques simples, il existe des types plus simples que les types Mutex<T> fournis par le [module std::sync::atomic de la bibliothèque standard][atomic]. Ces types fournissent un accès sûr, concurrent et atomique 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>.
Comparer RefCell<T>/Rc<T> et Mutex<T>/Arc<T>
Vous avez peut-être remarqué que counter est immuable mais que nous pouvions obtenir une référence mutable vers la valeur à l’intérieur ; cela signifie que Mutex<T> fournit la mutabilité intérieure, comme la famille Cell. De la même manière que nous avons utilisé RefCell<T> au chapitre 15 pour nous permettre de modifier le contenu à l’intérieur d’un Rc<T>, nous utilisons Mutex<T> pour modifier le contenu à l’intérieur d’un Arc<T>.
Un autre détail à noter est que Rust ne peut pas vous protéger de tous les types d’erreurs logiques quand vous utilisez Mutex<T>. Rappelez-vous du chapitre 15 que l’utilisation de Rc<T> comportait le risque de créer des cycles de références, où deux valeurs Rc<T> se réfèrent mutuellement, provoquant des fuites de mémoire. De même, Mutex<T> comporte le risque de créer des interblocages (deadlocks). Ceux-ci se produisent quand une opération a besoin de verrouiller deux ressources et que deux threads ont chacun acquis l’un des verrous, les faisant s’attendre mutuellement pour toujours. Si vous êtes intéressé par les interblocages, essayez de créer un programme Rust qui à un interblocage ; puis, recherchez les stratégies d’atténuation des interblocages pour les mutex dans n’importe quel langage et essayez de les implémenter en Rust. La documentation de l’API de la bibliothèque standard pour Mutex<T> et MutexGuard offre des informations utiles.
Nous terminerons ce chapitre en parlant des traits Send et Sync et de la façon dont nous pouvons les utiliser avec des types personnalisés.