Plongée dans la concurrence sécurisée avec Rust

La concurrence est un défi fondamental en programmation système. Rust adopte une approche radicalement différente des autres langages : plutôt que de laisser le développeur gérer manuellement les risques, le compilateur impose des règles strictes qui garantissent la sûreté du code concurrent à la compilation. Cet article explore les traits Send et Sync, piliers de cette approche, à travers un projet de test concret.

Le défi : deux fonctions en parallèle, zéro bug

Le point de départ de cette exploration était un défi simple en apparence : créer un système où deux fonctions peuvent être appelées en parallèle, sans risquer de bugs liés à la concurrence (data races, deadlocks, corruptions mémoire).

En Go, avec ses goroutines et ses channels, ce type de pattern est direct. En JavaScript, l'event loop single-threaded évite le problème par construction (au prix de la vraie parallélisation). En Python, le Global Interpreter Lock (GIL) empêche toute vraie concurrence sur les threads CPU, ce qui limite fortement la précision et les performances.

Rust rend ce défi plus complexe en imposant des règles strictes, mais c'est précisément ce qui garantit que le code est sûr et performant, même dans des scénarios complexes.

Send et Sync : les gardiens de la concurrence

Au cœur de l'approche idiomatique de Rust pour la programmation concurrente, on trouve deux traits fondamentaux :

Le trait Send

Un type qui implémente Send peut être transféré d'un thread à un autre en toute sécurité. Cela signifie que la propriété (ownership) de la valeur peut passer d'un thread à l'autre sans risque.

La plupart des types Rust sont Send. Les exceptions notables sont les types qui contiennent des pointeurs bruts ou des handles vers des ressources spécifiques à un thread (comme Rc<T>, le compteur de références non-atomique).

Le trait Sync

Un type qui implémente Sync peut être partagé entre plusieurs threads via des références immutables (&T). Autrement dit, si T est Sync, alors &T est Send.

Les types qui ne sont pas Sync incluent ceux dont l'état interne peut être modifié via une référence partagée sans synchronisation (comme Cell<T> ou RefCell<T>).

La règle fondamentale

Ces deux traits sont automatiquement dérivés par le compilateur pour les types composés, à condition que tous leurs champs les implémentent. Cela signifie que le compilateur refuse de compiler du code qui tenterait d'envoyer un type non-Send à un autre thread ou de partager un type non-Sync entre threads.

Mise en pratique avec crossbeam

Pour ce projet de test (disponible sur GitHub), j'ai utilisé le crate crossbeam qui offre des fonctionnalités rappelant la programmation en Go : scoped threads, channels typées, et primitives de synchronisation performantes.

crossbeam::scope est particulièrement intéressant car il garantit que tous les threads lancés dans un scope se terminent avant la sortie du scope, ce qui permet d'utiliser des références vers des données de la stack sans risque de dangling reference.

use crossbeam::scope;

let data = vec![1, 2, 3, 4];

scope(|s| {
    s.spawn(|_| {
        // On peut utiliser &data ici en toute sécurité
        println!("Thread 1: {:?}", &data);
    });
    s.spawn(|_| {
        println!("Thread 2: {:?}", &data);
    });
}).unwrap();

Pourquoi ne pas contourner avec unsafe ?

On pourrait être tenté d'utiliser des types non-Send ou non-Sync avec des blocs unsafe pour forcer le passage. Mais cela va à l'encontre de la philosophie de sécurité de Rust. Le système de types est là pour prévenir les bugs de concurrence, et le contourner revient à perdre la garantie principale du langage.

Si un type n'est pas Send ou Sync, c'est généralement pour une bonne raison : son implémentation interne n'est pas thread-safe. Forcer son utilisation concurrente avec unsafe réintroduit exactement les catégories de bugs que Rust cherche à éliminer.

Le résultat : fiabilité et complexité

Le résultat de cette exploration est du code fiable et performant. Rust force le développeur à penser à la concurrence de manière rigoureuse, et le compilateur rejette tout code qui ne respecte pas les invariants de sûreté.

Cela vient cependant avec une complexité accrue par rapport à des langages plus permissifs. La courbe d'apprentissage est réelle, et certaines structures de données concurrentes demandent une réflexion approfondie pour satisfaire les contraintes du système de types.

Comparaison avec d'autres approches

LangageApprocheGaranties à la compilationParallélisme réel
RustSend/Sync + ownershipOuiOui
GoGoroutines + channelsNon (data races possibles)Oui
JavaScriptEvent loop single-threadN/ANon (sauf Web Workers)
PythonGILNonNon (threads CPU)
C/C++Mutexes manuelsNonOui

Rust est le seul langage de cette liste qui détecte les erreurs de concurrence à la compilation, ce qui est un avantage considérable pour les systèmes critiques.

Conclusion

Les traits Send et Sync sont au cœur de ce qui rend Rust unique pour la programmation concurrente. Ils transforment des catégories entières de bugs runtime en erreurs de compilation, offrant une garantie que peu d'autres langages peuvent égaler. La complexité supplémentaire qu'ils imposent est le prix à payer pour une fiabilité sans compromis, et c'est un compromis qui vaut largement le coup pour des systèmes robustes et scalables.