Historiquement, Rust a introduit Rc (comptage des références) comme une alternative soucieuse des performances à Arc (comptage des références atomique) pour les scénarios à thread unique. Les premières versions du langage manquaient de cette distinction, forçant tous les propriétaires partagés à payer le coût des opérations atomiques. Les auto-traits Send et Sync ont été conçus pour faire respecter la sécurité des threads de manière compositionnelle, permettant au compilateur de déduire automatiquement ces propriétés en fonction des constituants d'un type.
Le problème fondamental réside dans l'implémentation interne de Rc, qui utilise un compteur non atomique (généralement encapsulé dans Cell<usize> ou UnsafeCell<usize>) pour suivre les références actives. Ce design suppose un accès à thread unique afin d'éviter le coût des barrières de mémoire. Si Rc<T> était autorisé à implémenter Send, un programme pourrait déplacer une copie du pointeur vers un autre thread. Lors de la destruction ou de la clonage dans le nouveau thread, les deux threads effectueraient des opérations de lecture-modification-écriture non synchronisées sur le compteur de références. Cela constitue une concurrence des données, ce qui pourrait corrompre le compteur, entraînant une désallocation prématurée (utilisation après libération) ou des fuites de mémoire (double libération).
La solution est architecturale : Rc choisit explicitement de ne pas être Send et Sync en contenant des types qui ne sont pas sûrs pour les threads (ou via des implémentations négatives dans le Rust moderne). Cela force les développeurs à utiliser Arc<T> pour le partage entre threads, qui utilise AtomicUsize pour ses compteurs, garantissant que les opérations d'incrément et de décrément sont atomiques et séquencées correctement à travers tous les cœurs de CPU. Le compilateur impose cette distinction au niveau du type, empêchant le partage accidentel sans vérifications à l'exécution.
Considérez un éditeur de texte haute performance analysant un grand document en un arbre de syntaxe abstraite (AST). Le parser utilise Rc<Node> pour représenter des sous-chaînes partagées (par exemple, des identifiants identiques) à travers l'arbre, optimisant la mémoire pendant la phase d'analyse à thread unique. Le besoin émerge de paralléliser la validation sémantique en distribuant des sous-arbres à un pool de threads.
Le problème immédiat est que la compilation échoue lors de la tentative d'envoi de Rc<Node> aux threads de travail. Plusieurs solutions ont été évaluées :
Remplacement global par Arc : Substituer toutes les instances de Rc par Arc. Avantages : Changements de code minimes et sécurité des threads immédiate. Inconvénients : Le profilage a révélé une dégradation de rendement de 12-15 % pendant l'analyse en raison d'opérations atomiques inutiles dans la voie chaude, violant les budgets de performance.
Clonage profond pour transmission : Sérialisation des sous-arbres en Vec<u8>, envoi des octets, et désérialisation sur les travailleurs. Avantages : Pas de code non sûr ou de modifications architecturales. Inconvénients : Latence élevée et coût GPU pour la gestion de structures de graphes complexes avec des cycles internes, rendant cela prohibitif pour l'édition en temps réel.
Extraction de pointeur non sûr : Transmuter Rc en un pointeur brut, envoyer le pointeur, et reconstruire Rc sur le récepteur. Avantages : Aucun coût de copie. Inconvénients : Fondamentalement non sûr ; viole l'invariant de propriété de Rc (le thread récepteur ne peut pas savoir si le thread émetteur abandonne ses clones), entraînant inévitablement une corruption de mémoire ou des pointeurs pendants.
Dispatch de tâches basé sur des canaux : Conserver l'AST dans le thread principal et envoyer des tâches de validation légères (plages d'octets ou indices de nœuds) via des canaux crossbeam. Les travailleurs retournent des résultats sans toucher à la mémoire gérée par Rc. Avantages : Préserve les performances de Rc pour l'analyse, élimine les courses de données sans unsafe, et découple les composants. Inconvénients : Nécessite une restructuration de l'algorithme de validation de data-parallèle à task-parallèle.
L'équipe a choisi l'approche basée sur des canaux. Le parser est resté à thread unique et rapide, tandis que la validation a augmenté linéairement avec le nombre de cœurs. Le résultat a été un système stable sans blocs unsafe et conservant les caractéristiques de performance.
Pourquoi Rc<T> reste-t-il !Sync même lorsque le type encapsulé T est Sync, et comment cela diffère-t-il de la restriction Send ?
Rc<T> ne peut pas être Sync car les références immuables (&Rc<T>) permettent d'appeler .clone(), ce qui modifie le compteur de références non atomique interne. Même si T lui-même est sûr à partager (Sync), le partage du wrapper Rc entre les threads permettrait des incréments simultanés du compteur à partir de plusieurs threads, causant une concurrence des données. La restriction Send empêche de déplacer la propriété vers un autre thread entièrement, tandis que la restriction Sync empêche même le partage de références entre les threads. Rc viole les deux principes parce que ses opérations "en lecture seule" (clonage) effectuent en réalité une mutation interne.
*Comment PhantomData<T> influence-t-il la dérivation automatique de Send et Sync pour une structure personnalisée englobant un pointeur brut (const T), et pourquoi son inclusion est-elle critique ?
Sans PhantomData, une structure contenant *const T ne porte aucune information de type reliant à T dans le but de la dérivation auto-trait. Le compilateur suppose de manière conservatrice que le pointeur pourrait pendre, aliasser arbitrairement ou pointer vers des données locales aux threads, et refuse donc d’inférer Send ou Sync. En incluant PhantomData<T>, le développeur signale au compilateur que la structure possède logiquement un T. En conséquence, la structure implémente automatiquement Send si T : Send et Sync si T : Sync, restaurant la sécurité des threads compositionnelle essentielle pour les wrappers FFI ou les pointeurs intelligents personnalisés.
Dans quelles conditions spécifiques un objet de trait Box<dyn Trait> perd-il l'auto-trait Send, même lorsque le type concret sous-jacent implémente Send ?
Un objet de trait dyn Trait n'implémente Send que si la définition du trait exige explicitement Send comme super-lien (par exemple, trait Trait : Send). Lors de l'effacement du type concret en un objet de trait, le compilateur rejette toutes les informations de type spécifiques, y compris les implémentations d'auto-trait. À moins que le trait garantisse lui-même la présence de Send, le compilateur ne peut pas vérifier que le vtable pointe vers des méthodes sûres pour les threads. Cela empêche l'envoi d'objets de trait encapsulés à travers les frontières des threads à moins que la liaison aux traits n'inclue explicitement Send (et Sync), limitant effectivement la sécurité des objets aux implémentations sûres pour les threads.