La concurrence en Rust
La concurrence, c’est-à-dire faire avancer plusieurs tâches en même temps, est le terrain où beaucoup de langages deviennent vraiment dangereux. Deux threads qui touchent les mêmes données au même moment peuvent les corrompre, et ce genre de bug est terriblement difficile à traquer. C’est exactement ce que les règles du borrowing (emprunt) ont été conçues pour empêcher, et cette protection s’applique directement aux threads.
Lancer un thread
Section intitulée « Lancer un thread »use std::thread;
fn main() { let handle = thread::spawn(|| { for i in 1..=3 { println!("thread: {i}"); } });
for i in 1..=3 { println!("main: {i}"); }
handle.join().unwrap(); // attendre que le thread ait fini}thread::spawn prend une closure (fonction anonyme) et l’exécute sur un nouveau thread, en parallèle du reste du programme. .join() bloque jusqu’à ce que ce thread ait terminé. Sans lui, main pourrait se terminer avant même que le thread lancé ait eu le temps de s’exécuter.
Déplacer des données dans un thread
Section intitulée « Déplacer des données dans un thread »Une closure passée à thread::spawn doit posséder entièrement les données qu’elle utilise, car elle peut s’exécuter après la fin de la portée d’origine. Ajoutez le mot-clé move pour l’y obliger :
use std::thread;
fn main() { let message = String::from("bonjour depuis un thread");
let handle = thread::spawn(move || { println!("{message}"); });
handle.join().unwrap();}Sortie :
bonjour depuis un threadSans move, Rust refuse de compiler ce code : il ne peut pas garantir que message sera encore valide au moment où le nouveau thread l’utilisera. move transfère l’ownership (possession) de message à la closure. C’est le même « déplacement » que sur la page Ownership, sauf qu’ici la valeur est confiée à un thread plutôt qu’à une autre variable.
Partager des données entre threads en toute sécurité
Section intitulée « Partager des données entre threads en toute sécurité »Les références ordinaires ne fonctionnent pas entre threads (elles ne peuvent pas survivre à la portée qui les a créées). Pour partager des données entre threads, on combine donc deux outils que vous connaissez déjà : Arc (vu sur la page Pointeurs intelligents) pour la possession partagée, et un Mutex pour garantir qu’un seul thread à la fois touche aux données :
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![];
for _ in 0..5 { 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!("Résultat : {}", *counter.lock().unwrap());}Sortie :
Résultat : 5counter.lock() attend de pouvoir obtenir un accès exclusif à la valeur en toute sécurité, fait la mise à jour, puis libère le verrou automatiquement. Cinq threads incrémentent le même compteur et, grâce au Mutex, ils ne se marchent jamais dessus : vous obtenez toujours 5, et pas un nombre plus petit à cause d’une mise à jour perdue.
Verrouiller des données partagées est une façon de faire coopérer des threads. L’autre façon courante consiste à ne rien partager du tout, et à laisser les threads s’envoyer des messages. C’est le sujet de la page Les canaux (channels).
Pourquoi Rust parle de « concurrence sans peur »
Section intitulée « Pourquoi Rust parle de « concurrence sans peur » »Dans la plupart des langages, rien ne vous empêche d’oublier de verrouiller un Mutex, ou de partager par accident entre threads une référence qui ne devrait pas l’être. En Rust, c’est le compilateur qui fait respecter ces règles : un code qui provoquerait une data race (accès concurrent non synchronisé) entre threads ne compile tout simplement pas. C’est le même système d’ownership et de borrowing que vous avez appris plus tôt dans ce cours, appliqué aux threads plutôt qu’à une seule portée.
Exercices
Section intitulée « Exercices »1. Deux threads, une somme
Section intitulée « 1. Deux threads, une somme »Additionnez les nombres de 1 à 100 avec deux threads : l’un calcule la somme de 1 à 50, l’autre celle de 51 à 100. Ensuite, main additionne les deux résultats. Indice : la closure d’un thread peut renvoyer une valeur, et handle.join().unwrap() vous la rend.
Voir la solution
use std::thread;
fn main() { let first = thread::spawn(|| { let total: u32 = (1..=50).sum(); total }); let second = thread::spawn(|| { let total: u32 = (51..=100).sum(); total });
let a = first.join().unwrap(); let b = second.join().unwrap(); println!("{a} + {b} = {}", a + b);}Sortie :
1275 + 3775 = 5050Ces closures n’utilisent aucune variable extérieure : elles n’ont donc pas besoin de move. Les deux threads tournent en même temps, et join() attend chacun d’eux puis vous rend son résultat.
2. Tout le monde signe le livre d’or
Section intitulée « 2. Tout le monde signe le livre d’or »Lancez trois threads. Chacun ajoute une ligne comme "le thread 2 est passé par là" dans un même Vec<String> partagé. Quand tous les threads ont terminé, affichez les lignes dans l’ordre trié. Indice : utilisez Arc<Mutex<Vec<String>>> comme dans l’exemple du compteur, et triez une copie à la fin, car les threads peuvent terminer dans n’importe quel ordre.
Voir la solution
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let book = Arc::new(Mutex::new(Vec::new())); let mut handles = vec![];
for id in 1..=3 { let book = Arc::clone(&book); handles.push(thread::spawn(move || { book.lock().unwrap().push(format!("le thread {id} est passé par là")); })); }
for handle in handles { handle.join().unwrap(); }
let mut lines = book.lock().unwrap().clone(); lines.sort(); for line in lines { println!("{line}"); }}Sortie :
le thread 1 est passé par làle thread 2 est passé par làle thread 3 est passé par làSupprimez la ligne lines.sort() et lancez le programme plusieurs fois : l’ordre peut changer. Le tri vous garantit la même sortie à chaque fois.
Récapitulatif
Section intitulée « Récapitulatif »thread::spawn(closure)exécute une closure sur un nouveau thread ;.join()attend qu’il ait terminé.- Une closure passée à un thread a généralement besoin de
movepour prendre entièrement possession des données qu’elle utilise. - Pour partager des données entre threads, combinez
Arc(possession partagée) etMutex(accès exclusif) :counter.lock()donne un accès sûr à la valeur, un thread à la fois. - Le compilateur rejette dès la compilation le code qui provoquerait une data race, pas à l’exécution.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Pourquoi une closure passée à thread::spawn a-t-elle généralement besoin du mot-clé move ?
2. À quoi sert Arc<Mutex<T>> ?
3. Que peut-il se passer si vous oubliez d'appeler .join() sur le handle d'un thread ?
4. Pourquoi l'ordre des lignes "main:" et "thread:" peut-il changer d'une exécution à l'autre du même programme ?
Score : 0 / 4
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Using Threads to Run Code Simultaneously — The Rust Book (en anglais) :
spawn,joinet les closuresmove, avec des exemples qui montrent comment les sorties s’entremêlent. - Shared-State Concurrency — The Rust Book (en anglais) :
MutexetArcen détail, y compris pourquoiRcne peut pas être envoyé à un autre thread. std::thread— documentation de la bibliothèque standard (en anglais) : tout sur les threads, y compris comment les nommer et les threads à portée limitée (scoped threads).Mutex— documentation de la bibliothèque standard (en anglais) : le fonctionnement du verrouillage, et ce que signifie l’« empoisonnement » (poisoning) quand un thread panique en tenant le verrou.- Threads — Rust by Example (en anglais) : un court exemple exécutable qui lance plusieurs threads et attend leur fin.