L'état partagé en Rust : RwLock, atomiques et scoped threads
Sur la page La concurrence en Rust, vous avez partagé un compteur entre plusieurs threads avec Arc<Mutex<T>> : Arc permet à plusieurs threads de posséder la valeur, et Mutex ne laisse qu’un seul d’entre eux y toucher à la fois. Sur la page Les canaux (channels), vous avez évité tout partage en envoyant des messages. Ce sont deux excellents outils, et vous pouvez construire presque n’importe quoi avec.
Mais voyez un Mutex comme l’unique clé des toilettes d’un bureau. Ça marche, mais c’est excessif si vous voulez juste lire le tableau d’affichage du couloir. La bibliothèque standard de Rust propose quelques outils de plus, chacun mieux adapté à un travail précis. Sur cette page, vous en découvrirez quatre : les scoped threads (threads à portée limitée), RwLock, les types atomiques, et OnceLock/LazyLock. Vous apprendrez aussi à éviter le seul bug que le compilateur ne peut pas attraper pour vous : l’interblocage (deadlock).
Les scoped threads : emprunter au lieu de déplacer
Section intitulée « Les scoped threads : emprunter au lieu de déplacer »Avec thread::spawn, vous avez généralement besoin de move, et souvent d’Arc. C’est parce qu’un thread lancé pourrait continuer à tourner après que main est passé à autre chose : Rust ne le laisse donc pas emprunter vos variables locales.
std::thread::scope règle ce problème grâce à une promesse : chaque thread lancé dans la portée se termine avant la fin de cette portée. Comme les threads ne peuvent pas survivre à vos données, ils ont le droit de simplement les emprunter. Pas d’Arc, pas de move, pas de clonage.
use std::thread;
fn main() { let shop = "L'épicerie du coin"; let sales = vec![12, 7, 30, 5, 18, 22, 9, 14]; let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| { let am = s.spawn(|| morning.iter().sum::<i32>()); let pm = s.spawn(|| afternoon.iter().sum::<i32>()); (am.join().unwrap(), pm.join().unwrap()) });
println!("{shop} : matin {am}, après-midi {pm}, journée entière {}", am + pm); println!("toujours à moi : {:?}", sales);}Sortie :
L'épicerie du coin : matin 54, après-midi 63, journée entière 117toujours à moi : [12, 7, 30, 5, 18, 22, 9, 14]Voici ce qui se passe :
thread::scope(|s| { ... })ouvre la portée. Vous lancez les threads avecs.spawn(...)au lieu dethread::spawn(...).- Les deux closures empruntent
morningetafternoon, qui ne sont que des slices desales. Rien n’est copié. - Ce que renvoie la closure de la portée (ici un tuple de deux sommes) devient la valeur de
thread::scope. - Après la portée,
salesappartient toujours àmain, vous pouvez donc continuer à l’utiliser.
Même si vous oubliez d’appeler join(), la portée attend tous ses threads à l’accolade fermante }. C’est exactement cette promesse qui rend l’emprunt sûr.
RwLock : plusieurs lecteurs ou un seul écrivain
Section intitulée « RwLock : plusieurs lecteurs ou un seul écrivain »Imaginez l’ardoise du menu dans un café. Beaucoup de clients peuvent la lire en même temps, et c’est très bien. Mais quand le patron change un prix, tout le monde doit attendre un instant que la nouvelle ardoise soit en place.
C’est ça, un RwLock (un « verrou lecture-écriture », read-write lock). Il a deux portes d’entrée :
read()vous donne un garde de lecture partagé. Plusieurs threads peuvent en tenir un en même temps.write()vous donne un garde d’écriture exclusif. Tant qu’un écrivain le tient, personne d’autre ne peut lire ni écrire.
Un Mutex ne fait pas cette différence : même deux lecteurs doivent attendre leur tour. Voici un cache de paramètres que plusieurs gestionnaires de requêtes lisent, et qu’un administrateur met à jour une fois :
use std::sync::{Arc, RwLock};use std::thread;
struct Settings { theme: String, max_upload_mb: u32,}
fn main() { let settings = Arc::new(RwLock::new(Settings { theme: String::from("clair"), max_upload_mb: 10, }));
// Quatre gestionnaires de requêtes lisent les paramètres en même temps. let mut handles = Vec::new(); for id in 1..=4 { let settings = Arc::clone(&settings); handles.push(thread::spawn(move || { let s = settings.read().unwrap(); // partagé : les autres peuvent lire aussi format!("requête {id} : thème={}, limite={} Mo", s.theme, s.max_upload_mb) })); } for handle in handles { println!("{}", handle.join().unwrap()); }
// Un administrateur modifie les paramètres une fois. { let mut s = settings.write().unwrap(); // exclusif : tous les autres attendent s.theme = String::from("sombre"); s.max_upload_mb = 25; } // le garde d'écriture est libéré ici, le verrou est donc de nouveau libre
let s = settings.read().unwrap(); println!("après la mise à jour : thème={}, limite={} Mo", s.theme, s.max_upload_mb);}Sortie :
requête 1 : thème=clair, limite=10 Morequête 2 : thème=clair, limite=10 Morequête 3 : thème=clair, limite=10 Morequête 4 : thème=clair, limite=10 Moaprès la mise à jour : thème=sombre, limite=25 MoLes threads peuvent se terminer dans n’importe quel ordre, mais la sortie est toujours la même. Chaque thread renvoie sa ligne au lieu de l’afficher, et main les affiche dans l’ordre où il les attend avec join.
Quand RwLock est-il meilleur que Mutex ? Quand les lectures sont fréquentes, les écritures rares, et que chaque lecture prend un peu de temps (par exemple, parcourir un gros cache). Les lecteurs tournent alors vraiment côte à côte.
Quand ne l’est-il pas ? Si les lectures sont minuscules, ou si les écritures arrivent tout le temps, un RwLock n’est qu’un Mutex plus compliqué. Il fait une comptabilité supplémentaire, et lecteurs et écrivains passent de toute façon leur temps à s’attendre. Dans le doute, commencez par un Mutex. C’est plus simple, et vous pourrez changer plus tard si des mesures montrent que le verrou est un goulot d’étranglement.
Les atomiques : des compteurs sans verrou
Section intitulée « Les atomiques : des compteurs sans verrou »Pour un seul nombre ou un seul drapeau vrai/faux, même un verrou est de trop. Les types atomiques comme AtomicUsize, AtomicI64 et AtomicBool peuvent être modifiés sans risque par de nombreux threads à la fois, sans aucun verrou. Le processeur effectue chaque opération en une seule étape indivisible, donc aucune mise à jour n’est perdue.
Ici, 8 threads enregistrent chacun 1000 pages vues. Ils comptent aussi des votes : un vote pour, puis trois votes contre, encore et encore. Le total des votes devient négatif, c’est pourquoi on utilise un AtomicI64 et pas un type non signé :
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};use std::thread;
fn main() { let page_views = AtomicUsize::new(0); let net_votes = AtomicI64::new(0);
thread::scope(|s| { for _ in 0..8 { s.spawn(|| { for n in 0..1000 { page_views.fetch_add(1, Ordering::Relaxed); if n % 4 == 0 { net_votes.fetch_add(1, Ordering::Relaxed); // vote pour } else { net_votes.fetch_sub(1, Ordering::Relaxed); // vote contre } } }); } }); // les 8 threads sont tous terminés ici
println!("pages vues : {}", page_views.load(Ordering::Relaxed)); println!("solde des votes : {}", net_votes.load(Ordering::Relaxed));}Sortie :
pages vues : 8000solde des votes : -4000Remarquez que page_views n’est pas mut. Les atomiques se modifient à travers une référence partagée, et c’est justement pour ça que plusieurs threads peuvent les utiliser en même temps. Les méthodes que vous utiliserez le plus :
fetch_add(n, ordering)/fetch_sub(n, ordering)ajoutent ou soustraient, et renvoient l’ancienne valeur.load(ordering)lit la valeur actuelle.store(value, ordering)la remplace.
Qu’est-ce que Ordering ?
Section intitulée « Qu’est-ce que Ordering ? »Chaque opération atomique demande un Ordering (un ordonnancement mémoire). Il indique au processeur et au compilateur à quel point cette opération doit rester strictement à sa place par rapport aux opérations mémoire qui l’entourent. Le sujet est profond, mais en tant que débutant, deux règles vous suffisent :
- Dans le doute, utilisez
Ordering::SeqCst. C’est le choix le plus strict et le plus sûr. Ordering::Relaxedconvient pour un simple compteur que vous ne lisez qu’à la fin, comme celui ci-dessus. Le total reste parfaitement exact.
Si vous voulez aller plus loin un jour, le livre gratuit Rust Atomics and Locks (lien en bas de page) explique clairement les ordonnancements.
Un drapeau d’arrêt
Section intitulée « Un drapeau d’arrêt »Un AtomicBool fait un excellent signal « arrête-toi, s’il te plaît ». Ici, un worker en arrière-plan continue de sauvegarder automatiquement jusqu’à ce que main lève le drapeau :
use std::sync::atomic::{AtomicBool, Ordering};use std::thread;use std::time::Duration;
fn main() { let stop = AtomicBool::new(false);
let report = thread::scope(|s| { let worker = s.spawn(|| { while !stop.load(Ordering::SeqCst) { // faire semblant de sauvegarder le document ici thread::sleep(Duration::from_millis(5)); } "le worker de sauvegarde automatique s'est arrêté proprement" });
thread::sleep(Duration::from_millis(30)); // main fait autre chose stop.store(true, Ordering::SeqCst); // demander au worker de s'arrêter worker.join().unwrap() });
println!("{report}");}Sortie :
le worker de sauvegarde automatique s'est arrêté proprementLe worker vérifie le drapeau à chaque tour de boucle. Il termine son étape en cours puis s’arrête de lui-même, ce qui est bien plus délicat que de tuer un thread au beau milieu de l’enregistrement d’un fichier.
OnceLock et LazyLock : tout préparer une seule fois
Section intitulée « OnceLock et LazyLock : tout préparer une seule fois »Parfois, vous voulez une valeur globale unique que tous les threads peuvent lire : une table de correspondance, le nom de l’application, une configuration chargée au démarrage. La partie délicate, c’est de la construire. Que se passe-t-il si deux threads essaient de la construire au même moment ?
std::sync::LazyLock et std::sync::OnceLock résolvent ce problème. La valeur est créée exactement une fois, la première fois que quelqu’un en a besoin. Si d’autres threads la demandent pendant sa construction, ils attendent simplement, puis tout le monde partage la même valeur.
use std::collections::HashMap;use std::sync::{LazyLock, OnceLock};use std::thread;
// LazyLock : la recette est écrite ici même, et s'exécute à la première utilisation.static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| { println!("construction de la table des devises..."); HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])});
// OnceLock : démarre vide, et vous décidez comment la remplir là où vous l'utilisez.static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str { STORE_NAME.get_or_init(|| { std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Rusty Shop")) })}
fn main() { let mut handles = Vec::new(); for country in ["US", "JP", "DE", "BR"] { handles.push(thread::spawn(move || { format!("{} ({country}) : paiement en {}", store_name(), CURRENCIES[country]) })); } for handle in handles { println!("{}", handle.join().unwrap()); }}Sortie :
construction de la table des devises...Rusty Shop (US) : paiement en USDRusty Shop (JP) : paiement en JPYRusty Shop (DE) : paiement en EURRusty Shop (BR) : paiement en BRLLes quatre threads veulent la table à peu près au même moment, et pourtant « construction de la table des devises… » ne s’affiche qu’une fois. Et comme ce sont des valeurs static qui vivent pendant tout le programme, les threads n’ont pas du tout besoin d’Arc.
Laquelle choisir ?
LazyLockquand vous savez comment construire la valeur à l’endroit où vous la déclarez. Elle se comporte comme une variable globale normale.OnceLockquand la valeur est décidée plus tard, par exemple d’après une option de la ligne de commande. Vous la remplissez avecget_or_init(...)ouset(...)(etsetéchoue poliment si elle est déjà remplie).
Les deux font partie de la bibliothèque standard. LazyLock est arrivé avec Rust 1.80 : du code plus ancien utilise donc souvent les crates once_cell ou lazy_static pour le même travail.
Éviter les interblocages
Section intitulée « Éviter les interblocages »Le compilateur empêche les accès concurrents aux données (data races), mais il existe un bug de concurrence qu’il ne peut pas attraper : l’interblocage (deadlock). C’est quand des threads s’attendent mutuellement pour toujours, et que votre programme se fige. Pas d’erreur, pas de plantage, rien.
La recette classique : deux verrous pris dans des ordres différents. Imaginez deux personnes qui ont chacune besoin du stylo et du carnet. L’une attrape le stylo, l’autre attrape le carnet, et chacune attend poliment que l’autre lâche le sien. Pour toujours.
// Thread A // Thread Blet from = alice.lock().unwrap(); let from = bob.lock().unwrap();let to = bob.lock().unwrap(); let to = alice.lock().unwrap();// A tient alice, attend bob. // B tient bob, attend alice. Bloqués !Trois habitudes vous protègent :
- Prenez toujours les verrous dans le même ordre. Si chaque thread verrouille
aliceavantbob(par exemple en triant par identifiant de compte), le cycle ne peut pas se produire. - Gardez les verrous le moins longtemps possible. Tenez un verrou juste le temps de lire ou de modifier les données. Ne le gardez jamais pendant un travail lent comme un appel réseau, l’écriture d’un fichier ou un long calcul : tous les autres threads restent bloqués à attendre pendant tout ce temps.
- Libérez les gardes tôt. Un garde déverrouille quand il est libéré (dropped). Vous pouvez y mettre fin avec un bloc interne
{ ... }(comme pour l’écrivain duRwLockplus haut) ou appelerdrop(guard)vous-même.
Voici l’habitude n° 3 en action. Chaque worker prend une tâche dans une file partagée, déverrouille la file, et seulement ensuite fait la partie lente :
use std::sync::Mutex;use std::thread;
fn main() { let queue = Mutex::new(vec!["redimensionner la photo", "envoyer l'e-mail", "générer le rapport"]); let finished = Mutex::new(Vec::new());
thread::scope(|s| { for _ in 0..3 { s.spawn(|| { let mut jobs = queue.lock().unwrap(); let job = jobs.pop(); drop(jobs); // déverrouiller la file AVANT la partie lente
if let Some(job) = job { let result = format!("terminé : {job}"); // imaginez que ça prend des secondes finished.lock().unwrap().push(result); } }); } });
// La portée est finie, donc plus aucun thread n'utilise `finished`. let mut done = finished.into_inner().unwrap(); done.sort(); // les threads se terminent dans n'importe quel ordre for line in done { println!("{line}"); }}Sortie :
terminé : envoyer l'e-mailterminé : générer le rapportterminé : redimensionner la photoSans drop(jobs), le garde vivrait jusqu’à la fin de la closure. Le travail « lent » se ferait alors pendant que la file est encore verrouillée, et les trois workers tourneraient l’un après l’autre au lieu de côte à côte.
Un cas sournois à connaître : dans while let Some(job) = queue.lock().unwrap().pop() { ... }, le garde vit pendant tout le corps de la boucle. La file reste donc verrouillée pendant que vous traitez la tâche. Prenez d’abord la tâche dans une instruction let séparée, comme le fait l’exemple.
Quel outil choisir ?
Section intitulée « Quel outil choisir ? »| Vous voulez… | Utilisez |
|---|---|
| Envoyer du travail ou des résultats d’un thread à un autre | Un canal (mpsc) |
| Permettre à plusieurs threads de lire et de modifier la même valeur | Arc<Mutex<T>> |
| Partager des données souvent lues et rarement modifiées | Arc<RwLock<T>> |
| Compter des choses, ou partager un simple drapeau marche/arrêt | Les atomiques (AtomicUsize, AtomicBool, …) |
| Construire une valeur globale une fois, puis seulement la lire | LazyLock ou OnceLock |
Laisser des threads emprunter des données locales, sans Arc |
thread::scope |
Ces outils se combinent librement. Les exemples de cette page ont déjà associé thread::scope à des atomiques et à un Mutex. Choisissez l’outil le plus simple qui convient, et ne vous inquiétez pas de faire le « mauvais » choix : changer plus tard est en général une petite modification.
Erreurs courantes
Section intitulée « Erreurs courantes »Emprunter une variable locale dans thread::spawn. Ce code a l’air inoffensif :
use std::thread;
fn main() { let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| { let total: i32 = sales.iter().sum(); total });
println!("total : {}", handle.join().unwrap());}error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function --> src/main.rs:6:32 |6 | let handle = thread::spawn(|| { | ^^ may outlive borrowed value `sales`7 | let total: i32 = sales.iter().sum(); | ----- `sales` is borrowed here |help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keywordLe compilateur explique que la closure pourrait vivre plus longtemps que la fonction actuelle, alors qu’elle emprunte sales, qui appartient à cette fonction. Vous savez que vous appelez join() tout de suite, mais thread::spawn ne peut pas le savoir. De son point de vue, le thread pourrait tourner pour toujours. Vous avez deux solutions : ajouter move (mais alors main ne peut plus utiliser sales ensuite), ou utiliser thread::scope, qui sait emprunter :
let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());Oublier Arc. Sans Arc, la closure move du premier thread prend le Mutex, et il ne reste plus rien pour le suivant :
use std::sync::Mutex;use std::thread;
fn main() { let counter = Mutex::new(0); let mut handles = vec![];
for _ in 0..3 { handles.push(thread::spawn(move || { *counter.lock().unwrap() += 1; })); }
for handle in handles { handle.join().unwrap(); }}error[E0382]: use of moved value: `counter` --> src/main.rs:9:36 | 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..3 { | ------------- inside of this loop 9 | handles.push(thread::spawn(move || { | ^^^^^^^ value moved into closure here, in previous iteration of loopLe message dit que counter a déjà été déplacé dans la closure lors d’un tour de boucle précédent. Enveloppez-le dans Arc::new(...) et clonez l’Arc au début de la boucle, comme sur la page La concurrence en Rust. Ou bien utilisez thread::scope et retirez le move. Si vous prenez Rc au lieu d’Arc, vous obtiendrez error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely (ce Rc ne peut pas être envoyé à un autre thread en toute sécurité). Rc est réservé à un seul thread (voir Les pointeurs intelligents).
Exercices
Section intitulée « Exercices »1. Une somme en quatre morceaux
Section intitulée « 1. Une somme en quatre morceaux »Créez un Vec contenant les nombres de 1 à 1000. Utilisez thread::scope pour le découper en 4 morceaux de 250, et additionnez chaque morceau dans son propre thread. Affichez la somme de chaque morceau et le total général. Indice : numbers.chunks(250) vous donne les slices. Rassemblez les handles dans un Vec, puis attendez-les dans l’ordre avec join.
Voir la solution
use std::thread;
fn main() { let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| { let mut handles = Vec::new(); for chunk in numbers.chunks(250) { handles.push(s.spawn(move || chunk.iter().sum::<u64>())); } handles.into_iter().map(|h| h.join().unwrap()).collect() });
println!("sommes des morceaux : {:?}", sums); println!("total : {}", sums.iter().sum::<u64>());}Sortie :
sommes des morceaux : [31375, 93875, 156375, 218875]total : 500500Ici, move ne déplace que chunk dans chaque thread, et ce n’est qu’une petite slice empruntée. Le vecteur lui-même est toujours emprunté, pas déplacé : vous pourriez donc réutiliser numbers après la portée.
2. Des numéros de commande uniques
Section intitulée « 2. Des numéros de commande uniques »Une boutique en ligne donne un numéro à chaque commande, en commençant à 1000. Lancez 4 threads qui prennent chacun 3 numéros de commande dans un même AtomicU64 partagé. Rassemblez les 12 numéros, triez-les et affichez-les. Indice : fetch_add(1, Ordering::SeqCst) renvoie la valeur avant l’ajout, il distribue donc chaque numéro exactement une fois.
Voir la solution
use std::sync::atomic::{AtomicU64, Ordering};use std::thread;
fn main() { let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| { let mut handles = Vec::new(); for _ in 0..4 { handles.push(s.spawn(|| { (0..3) .map(|_| next_order.fetch_add(1, Ordering::SeqCst)) .collect::<Vec<u64>>() })); } handles.into_iter().flat_map(|h| h.join().unwrap()).collect() });
numbers.sort(); println!("{} commandes : {:?}", numbers.len(), numbers);}Sortie :
12 commandes : [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]Quel thread reçoit quel numéro change d’une exécution à l’autre, mais aucun numéro n’est jamais distribué deux fois ni sauté. Le tri rend la liste affichée identique à chaque fois.
Récapitulatif
Section intitulée « Récapitulatif »thread::scopegarantit que ses threads se terminent avant la fin de la portée : ils peuvent donc emprunter des données locales sansArcnimove.RwLockautorise plusieurs lecteurs ou un seul écrivain. Il est utile pour les données surtout lues ; sinon, unMutexest plus simple.- Les atomiques (
AtomicUsize,AtomicI64,AtomicBool) gèrent compteurs et drapeaux sans verrou. UtilisezSeqCstdans le doute,Relaxedpour les simples compteurs. LazyLocketOnceLockcréent une valeur globale exactement une fois, en toute sécurité, même quand de nombreux threads la demandent en même temps.- Évitez les interblocages : prenez les verrous toujours dans le même ordre, gardez-les le moins longtemps possible, et libérez les gardes tôt.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Pourquoi les threads lancés avec s.spawn dans thread::scope peuvent-ils emprunter des variables locales ?
2. Quelle affirmation sur RwLock est vraie ?
3. Il vous faut un compteur de pages vues partagé par de nombreux threads. Quel est le bon choix le plus simple ?
4. Le thread A verrouille alice puis bob. Le thread B verrouille bob puis alice. Que peut-il se passer ?
Score : 0 / 4
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Shared-State Concurrency — The Rust Book (en anglais) : le chapitre officiel sur
MutexetArc, la base sur laquelle s’appuie cette page. std::thread::scope— documentation de la bibliothèque standard (en anglais) : le fonctionnement des scoped threads, avec d’autres exemples.std::sync::RwLock— documentation de la bibliothèque standard (en anglais) : les gardes de lecture et d’écriture, l’empoisonnement (poisoning), et une note sur l’équité, qui dépend du système d’exploitation.std::sync::atomic— documentation de la bibliothèque standard (en anglais) : tous les types atomiques et un court aperçu des ordonnancements mémoire.- Rust Atomics and Locks, de Mara Bos (en anglais) : un livre gratuit en ligne qui explique pas à pas les atomiques, l’ordonnancement mémoire et la façon dont les verrous sont construits.