Aller au contenu

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 117
toujours à moi : [12, 7, 30, 5, 18, 22, 9, 14]
▶ Essayez par vous-même

Voici ce qui se passe :

  • thread::scope(|s| { ... }) ouvre la portée. Vous lancez les threads avec s.spawn(...) au lieu de thread::spawn(...).
  • Les deux closures empruntent morning et afternoon, qui ne sont que des slices de sales. 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, sales appartient 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.

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 Mo
requête 2 : thème=clair, limite=10 Mo
requête 3 : thème=clair, limite=10 Mo
requête 4 : thème=clair, limite=10 Mo
après la mise à jour : thème=sombre, limite=25 Mo
▶ Essayez par vous-même

Les 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.

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 : 8000
solde des votes : -4000
▶ Essayez par vous-même

Remarquez 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.

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::Relaxed convient 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 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é proprement
▶ Essayez par vous-même

Le 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 USD
Rusty Shop (JP) : paiement en JPY
Rusty Shop (DE) : paiement en EUR
Rusty Shop (BR) : paiement en BRL
▶ Essayez par vous-même

Les 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 ?

  • LazyLock quand vous savez comment construire la valeur à l’endroit où vous la déclarez. Elle se comporte comme une variable globale normale.
  • OnceLock quand la valeur est décidée plus tard, par exemple d’après une option de la ligne de commande. Vous la remplissez avec get_or_init(...) ou set(...) (et set é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.

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 B
let 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 :

  1. Prenez toujours les verrous dans le même ordre. Si chaque thread verrouille alice avant bob (par exemple en triant par identifiant de compte), le cycle ne peut pas se produire.
  2. 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.
  3. 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 du RwLock plus haut) ou appeler drop(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-mail
terminé : générer le rapport
terminé : redimensionner la photo
▶ Essayez par vous-même

Sans 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.

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.

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` keyword

Le 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 loop

Le 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).

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 : 500500
▶ Essayez par vous-même

Ici, 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.

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]
▶ Essayez par vous-même

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.

  • thread::scope garantit que ses threads se terminent avant la fin de la portée : ils peuvent donc emprunter des données locales sans Arc ni move.
  • RwLock autorise plusieurs lecteurs ou un seul écrivain. Il est utile pour les données surtout lues ; sinon, un Mutex est plus simple.
  • Les atomiques (AtomicUsize, AtomicI64, AtomicBool) gèrent compteurs et drapeaux sans verrou. Utilisez SeqCst dans le doute, Relaxed pour les simples compteurs.
  • LazyLock et OnceLock cré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

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