Historique de la question
Lors de la genèse de Rust, les concepteurs ont été confrontés à un impasse critique : des structures de données essentielles comme les graphes cycliques et les conteneurs vérifiés à l'emprunt en temps d'exécution nécessitaient une mutation via des références partagées, ce qui contredisait directement l'axiome fondamental de la langue sur l'accès mutable exclusif. Pour résoudre ce problème sans compromettre le principe d'abstraction à coût zéro, UnsafeCell a été introduit comme le seul primitif qui renonce à la garantie d'immuabilité associée aux références partagées &T, servant de pierre angulaire à toutes les abstractions de mutabilité intérieure sécurisées.
Le problème
Le compilateur Rust exploite l'immuabilité de &T pour effectuer des optimisations agressives, telles que la mise en cache de valeurs et le réarrangement d'instructions, supposant que la mémoire sous-jacente ne peut pas changer pendant la durée de vie de la référence. UnsafeCell signale au compilateur que son contenu peut muter même lorsqu'il est accédé par une référence partagée, désactivant effectivement ces optimisations pour les données englobées. Cependant, cette option ne s'étend pas aux références dérivées du pointeur brut obtenu via UnsafeCell::get() ; dès que ce pointeur est converti en &mut T, les règles d'aliasing standard se réaffirment avec une rigidité absolue.
La solution
La solution exige que le programmeur respecte l'invariant selon lequel toute référence mutable &mut T produite à partir du pointeur brut d'UnsafeCell doit être le seul chemin d'accès actif à cette mémoire pour toute sa durée de vie. Cette exclusivité interdit les lectures ou écritures simultanées via tout autre pointeur, référence ou appels subséquents à get() pendant l'existence de la référence mutable. UnsafeCell ne désactive pas le vérificateur d'emprunt ; il transfère simplement la responsabilité de garantir l'exclusivité temporelle et de prévenir les races de données du compilateur au développeur.
Description du problème
Nous concevions un agrégateur de métriques à haut débit pour un système de trading à faible latence où plusieurs threads mettaient à jour des compteurs associés à des instruments financiers spécifiques. La carte partagée était immuable après l'initialisation, mais les valeurs de métriques nécessitaient des incréments fréquents. L'utilisation de Mutex<u64> a introduit une contention inacceptable, tandis que AtomicU64 s'est avéré insuffisant pour des types de métriques composites complexes. Nous avions besoin de mises à jour sans verrou et sans allocation pour les structures derrière des pointeurs Arc sans vérifications d'emprunt à l'exécution.
Différentes solutions envisagées
Solution 1 : Mutex shardés
Nous avons évalué l'idée d'encapsuler chaque métrique dans un Mutex et de les répartir sur 256 shards pour réduire la contention. Cette approche offrait une sécurité simple et un code simple et maintenable. Cependant, le profilage a révélé que même les opérations Mutex non contestées consommaient des centaines de nanosecondes en raison des appels système futex et des protocoles de cohérence de cache, violant notre budget de latence strict de sous-microseconde.
Solution 2 : AtomicPtr avec valeurs encapsulées
Une autre approche consistait à stocker les valeurs sous forme de AtomicPtr<Metric> et à utiliser des boucles de comparaison et d'échange pour les mises à jour. Cela supprimait le blocage mais nécessitait l'allocation de nouvelles instances Box pour chaque incrément, entraînant une pression mémoire sévère et une contention de l'allocateur. De plus, cela compliquait la récupération de mémoire, nécessitant des pointeurs de risque ou une collecte des déchets basée sur des époques qui augmentaient considérablement la complexité du code et la surface d'audit.
Solution 3 : UnsafeCell avec alignement de ligne de cache
Nous avons choisi de stocker les métriques dans UnsafeCell<Metric> au sein de structures alignées sur une ligne de cache, garantissant que les threads écrivant à différents shards ne partageaient jamais les lignes de cache. Chaque thread obtenait un pointeur brut via UnsafeCell::get(), le castait en &mut Metric pendant la mise à jour—garanti sûr par notre logique de partage garantissant qu'aucun autre thread ne pouvait accéder à ce slot spécifique—et effectuait la mutation. Cela nécessitait des blocs unsafe et une preuve formelle que notre hachage cohérent garantissait aucune collision lors d'accès simultanés.
Quelle solution a été choisie et pourquoi
Nous avons choisi la Solution 3 car elle offrait une abstraction à coût zéro sur la mémoire brute tout en satisfaisant les exigences de latence agressives. La garantie de partage agissait comme une preuve manuelle d'accès exclusif, nous permettant d'exploiter UnsafeCell sans surcharge de synchronisation à l'exécution. Nous avons validé la sécurité en utilisant MIRI et le vérificateur de modèle de concurrence loom pour vérifier de manière exhaustive qu'aucune violation d'aliasing ne s'était produite sous toutes les interleavings possibles de threads.
Résultat
L'implémentation a atteint des latences de mise à jour sous 100 nanosecondes avec zéro allocations dans le chemin chaud. Cependant, une régression subtile est apparue lors d'une refonte ultérieure où une tâche de maintenance a accidentellement itéré sur tous les shards sans acquérir le verrou implicite du shard, créant deux références mutables vers la même métrique. MIRI a immédiatement signalé cela comme un comportement indéfini lors de CI, renforçant que UnsafeCell exige une discipline rigoureuse même lorsque la conception architecturale garantit théoriquement la sécurité.
Pourquoi est-ce un comportement indéfini de détenir deux références mutables dérivées d'un UnsafeCell simultanément, même si UnsafeCell renonce explicitement aux règles d'emprunt standard ?
UnsafeCell renonce à la garantie d'immuabilité pour les références partagées au niveau type, mais ne relâche pas l'invariant fondamental du type &mut T lui-même. Lorsque vous appelez get(), vous recevez un pointeur brut *mut T qui n'a aucune contrainte de durée de vie ou d'aliasing. Cependant, dès que vous déréférez ce pointeur en un &mut T, vous affirmez au compilateur que cette référence est exclusive. Créer deux références telles à une mémoire chevauchante, même à partir du même UnsafeCell, viole la règle aliasing XOR mutation qui sous-tend le modèle mémoire de Rust, conduisant à un comportement indéfini immédiat, peu importe comment les références ont été construites.
Comment MIRI détecte-t-il les violations des invariants d'UnsafeCell, et pourquoi le code peut-il passer des tests de production mais échouer sous MIRI ?
MIRI implémente le modèle d'aliasing Stacked Borrows (ou Tree Borrows en option), qui suit les permissions d'accès mémoire à travers des "tags" abstraits. Lorsque vous créez une référence à partir d'un UnsafeCell, MIRI assignera un tag unique. Toute tentative d'utiliser un tag différent pour accéder à la même mémoire alors que la première référence est active constitue une violation. Le code passe souvent les tests standard car les modèles de mémoire du matériel sont indulgents, et les courses de données bénignes peuvent ne pas se manifester sous forme de plantages dans la pratique. MIRI, cependant, applique rigoureusement le modèle théorique, capturant les transgressions comme l'invalidation d'une référence mutable en créant une référence partagée à partir du même UnsafeCell sans synchronisation appropriée, même si l'assemblage fonctionne par hasard sur l'architecture CPU actuelle.
Expliquez pourquoi Cell<T> n'exige pas de blocs unsafe pour la mutation tandis qu'UnsafeCell<T> le fait, et identifiez la garantie de sécurité spécifique qui permet cette distinction.
Cell<T> réalise la mutabilité intérieure sans unsafe en ne révélant jamais de références à ses données intérieures ; il ne permet que de copier des valeurs à l'intérieur (set) ou à l'extérieur (get) pour des types implémentant Copy, ou de les déplacer (replace) pour des types non-Copy. Parce que Cell ne produit jamais un &T ou &mut T vers la valeur contenue, il est impossible de violer les règles d'aliasing—il n'y a pas de références à faire correspondre. UnsafeCell, en revanche, fournit get() qui retourne un pointeur brut *mut T, permettant la création de références. Cette flexibilité est nécessaire pour des mutations complexes en place, mais elle déplace le fardeau de garantir l'exclusivité et de prévenir les courses de données entièrement sur le programmeur, nécessitant des blocs unsafe.