Aller au contenu

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.

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

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.

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

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

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

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

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

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

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.

  • 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 move pour prendre entièrement possession des données qu’elle utilise.
  • Pour partager des données entre threads, combinez Arc (possession partagée) et Mutex (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

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