Geschichte der Frage
Während der Entstehung von Rust standen die Designer vor einem kritischen Dilemma: Wesentliche Datenstrukturen wie zyklische Graphen und zur Laufzeit überprüfte Container erforderten Mutationen durch gemeinsame Referenzen, was jedoch direkt dem grundlegenden Axiom der Sprache über exklusive veränderbare Zugriffe widersprach. Um dies zu lösen, ohne das Prinzip der nullkosten-abstraktion zu gefährden, wurde UnsafeCell als das einzige primitive Element eingeführt, das sich aus der Unveränderlichkeitsgarantie von gemeinsamen Referenzen &T hinausnimmt und als Fundament für alle sicheren Innenmutabilitätsabstraktionen dient.
Das Problem
Der Rust-Compiler nutzt die Unveränderlichkeit von &T, um aggressive Optimierungen vorzunehmen, wie Wert-Caching und Anweisungsneuordnungen, und geht davon aus, dass der zugrunde liegende Speicher sich während der Lebensdauer der Referenz nicht ändern kann. UnsafeCell signalisiert dem Compiler, dass sein Inhalt auch beim Zugriff über eine gemeinsame Referenz mutieren kann, wodurch diese Optimierungen für die eingeschlossenen Daten effektiv deaktiviert werden. Diese Opt-out-Garantie erstreckt sich jedoch nicht auf die von dem rohen Zeiger abgeleiteten Referenzen, die über UnsafeCell::get() erhalten werden; sobald dieser Zeiger in &mut T umgewandelt wird, treten die standardmäßigen Aliasregeln mit absoluter Striktheit wieder in Kraft.
Die Lösung
Die Lösung erfordert, dass der Programmierer die Invarianz einhält, dass jede veränderbare Referenz &mut T, die aus dem rohen Zeiger von UnsafeCell erzeugt wird, der einzige aktive Zugangsweg zu diesem Speicher für seine gesamte Lebensdauer sein muss. Diese Exklusivität verbietet gleichzeitige Lese- oder Schreibvorgänge über jeden anderen Zeiger, jede andere Referenz oder nachfolgende Aufrufe von get() während der Existenz der veränderbaren Referenz. UnsafeCell deaktiviert den Borrow-Checker nicht; es überträgt lediglich die Verantwortung, die zeitliche Exklusivität zu garantieren und Datenrennen zu verhindern, vom Compiler auf den Entwickler.
Problembeschreibung
Wir entwarfen einen Hochdurchsatz-Metrikaggregator für ein latenzarmes Handelssystem, in dem mehrere Threads Zähler aktualisierten, die mit bestimmten Finanzinstrumenten verbunden waren. Die gemeinsame Karte war nach der Initialisierung unveränderlich, aber die Metrikwerte erforderten häufige Inkremente. Der Einsatz von Mutex<u64> führte zu unhaltbarer Konkurrenz, während AtomicU64 sich als unzureichend für komplexe zusammengesetzte Metriktypen erwies. Wir benötigten sperrfrei und ohne Allokationen Aktualisierungen an Strukturen hinter Arc-Zeigern ohne Laufzeitüberprüfungen der Ausleihung.
Verschiedene in Betracht gezogene Lösungen
Lösung 1: Sharded Mutexes
Wir evaluierten, jede Metrik in einem Mutex zu kapseln und sie über 256 Shards zu verteilen, um die Konkurrenz zu reduzieren. Dieser Ansatz bot einfache Sicherheit und einfachen, wartbaren Code. Die Analyse ergab jedoch, dass selbst unkontrollierte Mutex-Operationen Hunderte von Nanosekunden aufgrund von futex-Syscalls und Cache-Kohärenzprotokollen in Anspruch nahmen, was unser strenges Sub-Mikrosekunden-Latenzb budget verletzte.
Lösung 2: AtomicPtr mit boxed Werten
Ein weiterer Ansatz bestand darin, Werte als AtomicPtr<Metric> zu speichern und Compare-and-Swap-Schleifen für Updates zu nutzen. Dies beseitigte Blockierungen, erforderte jedoch die Allokation neuer Box-Instanzen für jedes Inkrement, was zu extremem Speicherverbrauch und Kontention beim Allokator führte. Darüber hinaus komplizierte es die Speicherfreigabe und erforderte Hazard-Pointer oder epoch-basierte Garbage Collection, was die Codekomplexität und die Prüfoberfläche erheblich erhöhte.
Lösung 3: UnsafeCell mit Cache-Line-Ausrichtung
Wir entschieden uns, Metriken in UnsafeCell<Metric> innerhalb cache-line-ausgerichteter Strukturen zu speichern, um sicherzustellen, dass Threads, die auf verschiedene Shards schreiben, nie Cache-Linien teilen. Jeder Thread erhielt einen rohen Zeiger über UnsafeCell::get(), wandelte ihn während des Updates in &mut Metric um – garantiert sicher durch unsere Sharding-Logik, die sicherstellte, dass kein anderer Thread auf diesen bestimmten Slot zugreifen konnte – und führte die Mutation aus. Dies erforderte unsafe-Blöcke und einen formalen Beweis, dass unser konsistentes Hashing sicherstellte, dass es während der gleichzeitigen Nutzung zu keinen Kollisionen kam.
Welche Lösung wurde gewählt und warum
Wir wählten Lösung 3, da sie eine nullkostenabstraktion über rohem Speicher bot und gleichzeitig die aggressiven Latenzanforderungen erfüllte. Die Sharding-Garantie handelte als manueller Beweis für den exklusiven Zugang, was es uns ermöglichte, UnsafeCell ohne Laufzeit-Synchronisationsüberhead zu nutzen. Wir validierten die Sicherheit mithilfe von MIRI und dem loom-Modellprüfer für Parallelität, um umfassend zu überprüfen, dass unter allen möglichen Thread-Interleavings keine Aliasverletzungen auftraten.
Ergebnis
Die Implementierung erreichte Aktualisierungen mit einer Latenz von unter 100 Nanosekunden bei null Allokationen im Hot Path. Allerdings trat während eines anschließenden Refactorings eine subtile Regression auf, bei der eine Wartungsaufgabe versehentlich über alle Shards iterierte, ohne den impliziten Shard-Lock zu erwerben, und zwei veränderbare Referenzen auf die gleiche Metrik erzeugte. MIRI kennzeichnete dies während der CI sofort als undefiniertes Verhalten und verstärkte, dass UnsafeCell rigorose Disziplin erfordert, selbst wenn das architektonische Design theoretisch Sicherheit garantiert.
Warum ist es undefiniertes Verhalten, zwei veränderbare Referenzen, die aus einem UnsafeCell abgeleitet sind, gleichzeitig zu halten, obwohl UnsafeCell ausdrücklich von den Standardausleihregeln optiert?
UnsafeCell nimmt sich auf der Typ-Ebene aus der Unveränderlichkeitsgarantie für gemeinsame Referenzen heraus, lockert jedoch nicht die grundlegende Invarianz des Typs &mut T selbst. Wenn Sie get() aufrufen, erhalten Sie einen rohen Zeiger *mut T, der keine Lebensdauer- oder Aliasbeschränkungen trägt. Allerdings, in dem Moment, in dem Sie diesen Zeiger in &mut T dereferenzieren, bestätigen Sie dem Compiler, dass diese Referenz exklusiv ist. Zwei solcher Referenzen auf sich überschneidenden Speicher zu erstellen, selbst von demselben UnsafeCell, verletzt die Regel Aliasierung XOR Mutation, die dem Gedächtnismodell von Rust zugrunde liegt, was zu sofortigem undefiniertem Verhalten führt, egal wie die Referenzen erstellt wurden.
Wie erkennt MIRI Verstöße gegen die Invarianzen von UnsafeCell, und warum könnte Code Produktionsprüfungen bestehen, aber unter MIRI fehlschlagen?
MIRI implementiert das Aliasierungsmodell Stacked Borrows (oder optional Tree Borrows), das die Berechtigungen für den Zugriff auf Speicher durch abstrakte "Tags" verfolgt. Wenn Sie eine Referenz von einem UnsafeCell erstellen, weist MIRI ein einzigartiges Tag zu. Jeder Versuch, während der Aktivität der ersten Referenz auf denselben Speicher mit einem anderen Tag zuzugreifen, stellt einen Verstoß dar. Code besteht oft die Standardtests, da Hardware-Gedächtnismodelle nachsichtig sind und harmlose Datenrennen in der Praxis möglicherweise nicht als Abstürze manifestieren. MIRI hingegen setzt das theoretische Modell rigoros durch und erfasst Verstöße wie die Ungültigmachung einer veränderbaren Referenz, indem eine gemeinsame Referenz aus demselben UnsafeCell ohne ordnungsgemäße Synchronisation erstellt wird, selbst wenn der Assembly-Zusammenbau zufällig auf der aktuellen CPU-Architektur funktioniert.
Erklären Sie, warum Cell<T> keine unsicheren Blöcke für Mutation benötigt, während UnsafeCell<T> dies tut, und identifizieren Sie die spezifische Sicherheitsgarantie, die diese Unterscheidung ermöglicht.
Cell<T> erreicht Innenmutabilität ohne unsafe, indem es niemals Referenzen auf seine innere Daten exponiert; es erlaubt nur das Kopieren von Werten hinein (set) oder hinaus (get) für Typen, die Copy implementieren, oder das Bewegen von ihnen (replace) für nicht-Copy-Typen. Da Cell niemals ein &T oder &mut T an den enthaltenen Wert zurückgibt, ist es unmöglich, Aliasregeln zu verletzen – es gibt keine Referenzen, die aliasieren. UnsafeCell hingegen bietet get(), das einen rohen Zeiger *mut T zurückgibt, und ermöglicht die Erstellung von Referenzen. Diese Flexibilität ist für komplexe In-Place-Mutationen erforderlich, verschiebt jedoch die Verantwortung für die Gewährleistung von Exklusivität und die Verhinderung von Datenrennen vollständig auf den Programmierer, was unsafe-Blöcke erforderlich macht.