Concurrence#
La programmation concurrente consiste à exécuter plusieurs tâches dont les périodes d’activité se chevauchent. Elle est essentielle pour exploiter les processeurs multi-coeurs, mais elle est aussi une source notoire de bogues subtils : data races, interblocages, conditions de course. La plupart des langages délèguent la détection de ces erreurs à l’exécution ou à la discipline du programmeur. Rust adopte une approche radicalement différente : le système de types, grâce aux mécanismes de possession (chapitre 5), aux traits Send et Sync (chapitre 13) et aux fermetures move (chapitre 17), détecte les accès concurrents invalides à la compilation. L’équipe Rust appelle cette approche fearless concurrency.
Fearless concurrency#
Définition 113 (Fearless concurrency)
La fearless concurrency est le principe selon lequel le compilateur Rust empêche les data races à la compilation. Une data race se produit lorsque deux threads accèdent simultanément à une même donnée, qu’au moins l’un d’eux écrit, et qu’aucun mécanisme de synchronisation n’ordonne ces accès. Rust garantit qu’un programme qui compile est exempt de data races.
Cette garantie ne repose pas sur un mécanisme unique mais sur la combinaison de plusieurs éléments du système de types :
les règles de possession et d’emprunt (chapitre 5) interdisent les alias mutables ;
le trait
Sendcontrôle quels types peuvent être transférés entre threads ;le trait
Synccontrôle quels types peuvent être partagés par référence entre threads ;le compilateur exige une fermeture
move(chapitre 17) pour les threads, forçant le transfert de possession.
Remarque 91
La fearless concurrency protège contre les data races mais pas contre tous les bogues de concurrence. Les interblocages (deadlocks), les situations de famine (starvation) et les erreurs logiques liées à l’ordre d’exécution restent possibles. Le compilateur élimine la catégorie la plus dangereuse ; la responsabilité du reste incombe au programmeur.
Threads#
Un thread est un fil d’exécution autonome au sein d’un processus. Rust utilise les threads du système d’exploitation (threads 1:1) via la fonction std::thread::spawn.
Définition 114 (std::thread::spawn)
La fonction std::thread::spawn prend une fermeture et lance un nouveau thread qui l’exécute. Elle retourne un JoinHandle<T>, où T est le type de retour de la fermeture. La méthode join() sur le handle bloque le thread appelant jusqu’à la terminaison du thread créé, et retourne un Result<T>.
Proposition 30 (Exigence de move pour les fermetures de threads)
Le compilateur exige en général que la fermeture passée à thread::spawn soit move. Un thread enfant peut survivre au scope dans lequel il a été créé ; les références vers des variables locales seraient alors pendantes. Le transfert de possession par move (chapitre 17) garantit que le thread possède toutes ses données.
Exemple 103 (Création de threads et join)
Le code suivant créé plusieurs threads, chacun recevant la possession de sa propre copie de l’indice de boucle. Le thread principal attend la fin de chaque thread via join().
use std::thread;
fn main() {
let mut handles = vec![];
for i in 0..5 {
let handle = thread::spawn(move || {
println!("Thread {} démarré", i);
thread::sleep(std::time::Duration::from_millis(50));
println!("Thread {} terminé", i);
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
println!("Tous les threads sont terminés");
}
Remarque 92
L’ordre d’exécution des threads n’est pas déterministe. A chaque lancement, les messages peuvent apparaitre dans un ordre différent. C’est une propriéte fondamentale de la concurrence : le programmeur ne doit jamais présupposer un ordre d’exécution entre threads indépendants.
Canaux (channels)#
Les canaux (channels) permettent aux threads de communiquer en s’échangeant des messages, suivant le principe : ne communiquez pas en partageant de la mémoire ; partagez de la mémoire en communiquant. Rust fournit des canaux multi-producteurs, mono-consommateur via le module std::sync::mpsc (multiple producer, single consumer).
Définition 115 (Canal mpsc)
La fonction mpsc::channel() retourne un couple (Sender<T>, Receiver<T>). L’émetteur (tx) envoie des valeurs de type T avec send(), et le récepteur (rx) les reçoit avec recv() (bloquant) ou try_recv() (non bloquant). La possession de la valeur est transférée de l’émetteur au récepteur : une fois envoyée, la valeur n’est plus accessible côté émetteur.
Proposition 31 (Transfert de possession via les canaux)
L’appel tx.send(val) transfère la possession de val au canal. Toute tentative d’utiliser val apres l’envoi provoque une erreur de compilation. Cette règle garantit l’absence d’accès concurrent à la valeur : seul le récepteur peut la lire après l’envoi.
Exemple 104 (Communication par canal entre threads)
Voir le code ci-dessous.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let message = String::from("bonjour depuis le thread");
tx.send(message).unwrap();
// message n'est plus accessible ici : la possession a été transférée
});
let recu = rx.recv().unwrap();
println!("Message reçu : {}", recu);
}
Pour créer plusieurs producteurs, on clone l’émetteur avec tx.clone(). Chaque clone peut être envoyé dans un thread différent.
Exemple 105 (Producteurs multiples)
Voir le code ci-dessous.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let mut handles = vec![];
for id in 0..3 {
let tx_clone = tx.clone();
let handle = thread::spawn(move || {
let msg = format!("message du producteur {}", id);
tx_clone.send(msg).unwrap();
});
handles.push(handle);
}
drop(tx); // fermer l'émetteur original pour que le récepteur puisse terminer
for msg in rx {
println!("Reçu : {}", msg);
}
for h in handles {
h.join().unwrap();
}
}
Remarque 93
Il est important de fermer (via drop) l’émetteur original après avoir cloné les copies nécessaires. Le récepteur itère jusqu’à ce que tous les émetteurs soient fermés. Si l’original reste ouvert, l’itération sur rx ne terminera jamais.
Mutex<T>#
Lorsque plusieurs threads doivent accéder à une même donnée mutable, un canal n’est pas toujours adapté. Le Mutex<T> (mutual exclusion) protège une donnée en n’autorisant qu’un seul thread à y accéder à la fois.
Définition 116 (Mutex<T>)
Un Mutex<T> encapsule une valeur de type T et fournit un accès exclusif via la méthode lock(). L’appel à lock() bloque le thread courant jusqu’à ce que le verrou soit disponible, puis retourne un MutexGuard<T> — une garde RAII qui déréférence vers &mut T et libère automatiquement le verrou lorsqu’elle sort de la portée.
Proposition 32 (Empoisonnement du Mutex)
Si un thread panique alors qu’il détient le verrou, le Mutex est empoisonné (poisoned). Les appels subséquents à lock() retournent un Err(PoisonError) pour signaler que les données protégées pourraient être dans un état incohérent. On peut récupérer l’accès via into_inner() sur l’erreur si l’on estime que l’état reste exploitable.
Exemple 106 (Compteur partagé avec Arc<Mutex<T>>)
Pour partager un Mutex entre threads, on l’encapsule dans un Arc (chapitre 18). Chaque thread reçoit un clone de l”Arc, ce qui maintient le compteur de références atomique.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let compteur = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&compteur);
let handle = thread::spawn(move || {
let mut val = c.lock().unwrap();
*val += 1;
}); // MutexGuard est libéré ici : le verrou est relaché
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
println!("Compteur final : {}", *compteur.lock().unwrap());
}
Remarque 94
La garde MutexGuard libère le verrou lorsqu’elle sort de la portée. Pour minimiser la durée de verrouillage, il est recommandé de limiter la portée de la garde à un bloc { ... } le plus étroit possible. Détenir un verrou pendant une opération longue (E/S, calcul coûteux) dégrade la concurrence effective du programme.
RwLock<T>#
Définition 117 (RwLock<T>)
Un RwLock<T> (read-write lock) est un verrou qui distingue les accès en lecture et en écriture :
read()acquiert un verrou en lecture ; plusieurs lecteurs peuvent détenir simultanément un verrou en lecture ;write()acquiert un verrou en écriture ; un seul écrivain est autorisé à la fois, et aucun lecteur ne peut coexister avec un écrivain.
Ce schéma est adapté aux situations ou les lectures sont beaucoup plus fréquentes que les écritures.
Proposition 33 (RwLock et empoisonnement)
Comme Mutex, un RwLock peut être empoisonné si un thread panique en détenant le verrou en écriture. Un panic pendant un verrou en lecture n’empoisonne pas le RwLock, car les données n’ont pas pu être modifiées.
Exemple 107 (Cache partage avec Arc<RwLock<T>>)
Voir le code ci-dessous.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(String::from("mode=debug")));
let mut handles = vec![];
// Plusieurs lecteurs simultanément
for i in 0..3 {
let cfg = Arc::clone(&config);
handles.push(thread::spawn(move || {
let val = cfg.read().unwrap();
println!("Lecteur {} lit : {}", i, *val);
}));
}
// Un seul écrivain
{
let cfg = Arc::clone(&config);
handles.push(thread::spawn(move || {
let mut val = cfg.write().unwrap();
*val = String::from("mode=release");
println!("Ecrivain a mis à jour la configuration");
}));
}
for h in handles {
h.join().unwrap();
}
println!("Config finale : {}", *config.read().unwrap());
}
Remarque 95
En pratique, RwLock n’est avantageux que lorsque les lectures sont nettement plus fréquentes que les écritures. Pour un accès majoritairement en écriture, Mutex est préférable car il a un surcoût moindre. De plus, le comportement exact de RwLock (priorité lecteurs ou écrivains) dépend de l’implémentation du système d’exploitation.
Arc<T> et les combinaisons thread-safe#
Le chapitre 18 a introduit Arc<T> comme la version thread-safe de Rc<T>. Dans le contexte de la concurrence, Arc est le mécanisme standard pour partager des données entre threads.
Proposition 34 (Arc est nécessaire pour le partage entre threads)
Rc<T> n’implémente ni Send ni Sync (chapitre 18) : le compilateur interdit son utilisation entre threads. Arc<T>, dont le compteur est atomique, implémente Send et Sync lorsque T: Send + Sync, ce qui permet le partage.
Le patron Arc<Mutex<T>> combine propriété partagée et mutabilité exclusive. Il est l’équivalent multi-thread de Rc<RefCell<T>> (chapitre 18).
Contexte |
Propriété partagée |
Mutabilité intérieure |
|---|---|---|
Mono-thread |
|
|
Multi-thread |
|
|
On peut vérifier que Arc<Mutex<T>> satisfait les bornes attendues :
use std::sync::{Arc, Mutex};
fn est_send<T: Send>() {}
fn est_sync<T: Sync>() {}
est_send::<Arc<Mutex<i32>>>();
est_sync::<Arc<Mutex<i32>>>();
println!("Arc<Mutex<i32>> est Send + Sync");
Arc<Mutex<i32>> est Send + Sync
Send et Sync#
Les traits Send et Sync (introduits au chapitre 13) sont les fondations de la sécurité concurrente en Rust. Ce chapitre les illustre dans leur contexte d’application.
Définition 118 (Rappel : Send et Sync)
Un type est
Sends’il peut être transféré en toute sécurité d’un thread à un autre (la possession traverse la frontière du thread).Un type est
Syncsi&Tpeut être partagée entre threads sans risque (plusieurs threads peuvent lire simultanément).La relation entre les deux :
TestSyncsi et seulement si&TestSend.
Proposition 35 (Application aux types concurrents)
Type |
|
|
Raison |
|---|---|---|---|
|
Oui |
Oui |
Pas d’état partagé interne |
|
Non |
Non |
Compteur non atomique |
|
Oui |
Oui |
Compteur atomique |
|
Oui |
Oui |
Accès exclusif garanti |
|
Oui |
Oui |
Accès lecteurs/écrivain garanti |
|
Oui |
Non |
Mutabilité intérieure sans synchronisation |
On peut vérifier ces propriétés en utilisant des fonctions génériques avec bornes de trait :
fn verifier_send<T: Send>() { println!("{} est Send", std::any::type_name::<T>()); }
fn verifier_sync<T: Sync>() { println!("{} est Sync", std::any::type_name::<T>()); }
verifier_send::<Vec<i32>>();
verifier_sync::<Vec<i32>>();
verifier_send::<std::sync::Mutex<String>>();
verifier_sync::<std::sync::Mutex<String>>();
alloc::vec::Vec<i32> est Send
alloc::vec::Vec<i32> est Sync
std::sync::poison::mutex::Mutex<alloc::string::String> est Send
std::sync::poison::mutex::Mutex<alloc::string::String> est Sync
Et confirmer que Rc n’est pas Send :
fn verifier_send<T: Send>() {}
verifier_send::<std::rc::Rc<i32>>(); // erreur : Rc<i32> n'est pas Send
[E0277] Error: `Rc<i32>` cannot be sent between threads safely
╭─[command_4:1:1]
│
1 │ fn verifier_send<T: Send>() {}
│ ──┬─
│ ╰─── required by this bound in `verifier_send`
2 │ verifier_send::<std::rc::Rc<i32>>(); // erreur : Rc<i32> n'est pas Send
│ ────────┬───────
│ ╰───────── `Rc<i32>` cannot be sent between threads safely
───╯
Remarque 96
Send et Sync sont implémentés automatiquement par le compilateur pour les types composés de champs qui sont eux-mêmes Send (respectivement Sync). On peut implémenter manuellement ces traits avec unsafe impl, mais cela place la responsabilité de la correction sur le programmeur. Cette opération est réservée aux cas où l’on crée des abstractions de bas niveau utilisant du code unsafe.
Résumé#
La concurrence en Rust repose sur la coopération entre le système de types et les primitives de synchronisation :
Threads :
std::thread::spawnlance un thread ;JoinHandle::join()attend sa terminaison. La fermeture doit êtremovepour prendre possession des données.Canaux :
mpsc::channel()permet la communication par passage de messages. La possession est transférée de l’émetteur au récepteur, éliminant les accès concurrents.Mutex<T>: accès mutuellement exclusif à une donnée partagée. La garde RAII libère le verrou automatiquement.RwLock<T>: variante distinguant lecteurs multiples et écrivain unique, adaptée aux scénarios de lecture intensive.Arc<T>: propriété partagée thread-safe par comptage de références atomique (chapitre 18). Combiné avecMutexouRwLockpour la mutabilité partagée entre threads.SendetSync: traits marqueurs (chapitre 13) qui encodent les règles de sécurité concurrente dans le système de types.
Le compilateur Rust agit comme un vérificateur de concurrence : il refuse les programmes qui violeraient les invariants de sécurité. Cette approche déplace la détection des bogues de concurrence les plus graves — les data races — de l’exécution vers la compilation, offrant une confiance élevée dans la correction des programmes concurrents.