backend / rust
Concurrence : threads, Mutex, Arc
Explication
Ce que vous allez apprendre
- Lancer un thread OS avec
thread::spawnet attendre sa fin avec.join() - Comprendre pourquoi
moveest presque toujours nécessaire pour un thread - Utiliser
Arc<T>, la version thread-safe deRc<T> - Protéger une donnée partagée entre threads avec
Mutex<T> - Comprendre le concept de "fearless concurrency" grâce aux traits
Send/Sync
Dans quel contexte ?
Une équipe qui a déjà été confrontée à des bugs de concurrence insaisissables en C++ (une donnée modifiée simultanément par deux threads sans le moindre message d'erreur clair) découvre que Rust refuse tout simplement de compiler un programme qui présenterait ce même risque. Ce n'est pas une coïncidence : c'est la promesse centrale du langage sur la concurrence, souvent résumée par l'expression "fearless concurrency" (concurrence sans peur).
D'abord, thread::spawn lance un vrai thread du système d'exploitation
Contrairement aux goroutines de Go, qui sont des unités légères gérées par un runtime, thread::spawn en Rust démarre un authentique thread OS, avec le coût mémoire et de changement de contexte que cela implique. .join() sur le JoinHandle retourné bloque jusqu'à la fin de l'exécution du thread, exactement comme Wait() sur un sync.WaitGroup en Go.
Une fois un thread lancé, une contrainte de syntaxe apparaît presque systématiquement
move est presque toujours nécessaire dans une closure passée à thread::spawn, car le compilateur doit garantir que les données capturées survivront pendant toute la durée de vie du thread — potentiellement plus longtemps que la fonction qui l'a lancé. Sans move, la closure emprunterait ces données, ce que le compilateur refuse dès qu'il ne peut pas garantir leur validité pendant toute l'exécution du thread.
| Concept | Équivalent en Go | Différence clé |
|---|---|---|
thread::spawn | go func() {...}() | Thread OS réel, pas une unité légère |
Arc<T> | Pas d'équivalent direct (GC) | Comptage de références thread-safe |
Mutex<T> | sync.Mutex | Le verrou "possède" la donnée qu'il protège |
mpsc::channel | make(chan T) | Multi-producteur, un seul consommateur |
Prérequis
Il faut avoir compris Rc/RefCell (leçon précédente) : Arc/Mutex en sont directement les équivalents pensés pour un contexte multi-thread.
Il reste une différence de conception majeure avec la plupart des autres langages
En Rust, un Mutex<T> "possède" littéralement la donnée qu'il protège : compteur.lock().unwrap() retourne un garde qui donne accès à la donnée interne, et ce garde libère automatiquement le verrou à sa sortie de scope (RAII). Il devient donc structurellement impossible d'accéder à la donnée sans passer par le verrou — contrairement à d'autres langages où rien n'empêche techniquement d'oublier de verrouiller avant d'accéder à une variable partagée.
Piège fréquent
Rc<T> n'est pas thread-safe et le compilateur refuse de le partager entre threads (erreur de compilation, jamais un bug silencieux à l'exécution). Il faut systématiquement remplacer Rc par Arc (Atomic Reference Counted) dès qu'une donnée partagée doit traverser une frontière de thread — un remplacement mécanique mais obligatoire.
Enfin, une garantie structurelle unique à Rust parmi les langages grand public
Bonne pratique
Fais confiance aux traits Send et Sync, vérifiés automatiquement par le compilateur : si ton code compile avec des threads, les data races classiques (deux threads qui modifient la même donnée sans synchronisation) sont structurellement exclues. C'est une garantie qu'aucun langage avec threads natifs et sans ce système de types ne peut offrir au même niveau.
Maintenant que tu maîtrises les threads OS classiques, la prochaine leçon aborde un modèle de concurrence différent, pensé pour l'I/O massive plutôt que le calcul intensif : async/await avec Tokio.
Commandes & code
Concurrence : threads, Mutex, Arc
Le compilateur Rust empêche les data races à la compilation grâce à Send et Sync.
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
// spawn : lance un thread OS, retourne un JoinHandle
let handle = thread::spawn(|| {
for i in 1..5 {
println!("thread secondaire: {}", i);
thread::sleep(Duration::from_millis(10));
}
});
for i in 1..3 {
println!("thread principal: {}", i);
}
handle.join().unwrap(); // attend la fin du thread secondaire
// move : obligatoire pour transferer l'ownership d'une donnee au thread
let donnees = vec![1, 2, 3];
let handle2 = thread::spawn(move || {
println!("{:?}", donnees);
});
handle2.join().unwrap();
// Arc<T> : comme Rc mais thread-safe (Atomic Reference Counted)
// Mutex<T> : exclusion mutuelle, verifiee a l'execution
let compteur = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let compteur = Arc::clone(&compteur);
let handle = thread::spawn(move || {
let mut valeur = compteur.lock().unwrap(); // bloque jusqu'a obtenir le verrou
*valeur += 1;
// le verrou est libere automatiquement a la fin du scope (RAII, comme defer)
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("resultat final: {}", *compteur.lock().unwrap()); // toujours 10, garanti
// Le compilateur EMPECHE a la compilation les erreurs de concurrence classiques :
// - partager une donnee non-Sync entre threads : erreur de compilation
// - deplacer une donnee dans un thread puis l'utiliser ailleurs : erreur "moved value"
// C'est le principe du "fearless concurrency" de Rust.
// Channels (mpsc: multi-producer single-consumer), alternative au partage de memoire
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
for i in 0..3 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("message {}", i)).unwrap();
});
}
drop(tx); // fermer l'emetteur original pour que rx sache quand s'arreter
for message in rx { // recoit jusqu'a ce que tous les emetteurs soient droppes
println!("{}", message);
}
}Résumé
Arc<T>(thread-safe) remplaceRc<T>dès qu'une donnée est partagée entre threads.Mutex<T>protège une donnée ;.lock()retourne un garde qui libère automatiquement le verrou en fin de scope.- Les traits
Send/Sync, vérifiés à la compilation, éliminent structurellement les data races ("fearless concurrency"). mpsc::channel()offre une alternative par message-passing au partage de mémoire verrouillée.
Exercices pratiques
Mission : un compteur de ventes concurrent qui ne compile pas
Objectif : Diagnostiquer des erreurs de compilation liees a Rc et move dans un contexte multi-thread, et les corriger avec Arc et Mutex.
Contexte
Un dev veut lancer 20 threads qui incrementent chacun un compteur partage de ventes du jour, puis afficher le total. Il ecrit :
use std::rc::Rc;
use std::cell::RefCell;
use std::thread;
fn main() {
let compteur = Rc::new(RefCell::new(0));
let mut handles = vec![];
for _ in 0..20 {
let compteur = Rc::clone(&compteur);
let handle = thread::spawn(|| {
*compteur.borrow_mut() += 1;
});
handles.push(handle);
}
for h in handles { h.join().unwrap(); }
println!("{}", compteur.borrow());
}Le compilateur refuse de compiler avec plusieurs erreurs liees a Send/Sync et a la duree de vie de compteur. Corrige ce code pour qu'il compile et fonctionne correctement.