Die Ursprünge dieser Frage gehen auf die Stabilisierung von std::task::Waker in Rust 1.36 zurück, die einen standardisierten Mechanismus einführte, durch den Executor Futures über die Bereitschaft benachrichtigen können. Zuvor beruhten asynchrone Frameworks auf verpackten Closures oder benutzerdefinierten Benachrichtigungstraits, die Allocationsüberhead entstanden, und die nahtlose Integration mit C-Bibliotheken verhinderten. Die RawWaker-API wurde entwickelt, um null-Kostenabstraktionen zu unterstützen, indem sie Entwicklern erlaubte, Waker-Instanzen aus Rohzeigern und Funktionstablestuben (RawWakerVTable) zu konstruieren und damit die virtuellen Tabellen von C++ nachahmte, aber mit den Sicherheitsanforderungen von Rust.
Das Problem ergibt sich darin, dass die Konstruktion von RawWaker das Besitz- und Ausleihsystem von Rust vollständig umgeht. Der Programmierer muss manuell vier kritische Invarianten sicherstellen: Der Datenzeiger muss für die Lebensdauer aller Waker-Kopien (nicht nur des Originals) gültig bleiben, die vier VTable-Funktionen (clone, wake, wake_by_ref, drop) müssen threadsicher (Send und Sync) sein, selbst wenn der Executor einseitig ist, und die clone-Funktion muss einen neuen RawWaker zurückgeben, der denselben zugrundeliegenden Aufgabenstatus referenziert. Darüber hinaus muss die VTable den extern "C" ABI verwenden, um FFI-Kompatibilität und stabile Aufrufkonventionen über Rust-Versionen hinweg sicherzustellen.
Die Lösung erfordert eine strikte Einhaltung der unsafe-Invarianten. Der Datenzeiger sollte typischerweise auf 'static Daten verweisen oder in Arc eingeschlossen sein, um einen gemeinsamen Besitz über Kopien hinweg zu verwalten. Die VTable-Funktionen müssen korrekt die Semantik der Referenzzählung implementieren: clone sollte die Zählung erhöhen, drop sollte sie verringern, und wake sollte nach der Benachrichtigung abnehmen (den Waker verbrauchend). Die Verletzung des ABI-Vertrags – beispielsweise die Verwendung von Rust-Aufrufkonventionen anstelle von extern "C" – führt zu undefiniertem Verhalten, wenn der Executor diese Zeiger aufruft, einschließlich Stack-Korruption, Argumentausrichtung oder dem Sprung zu ungültigen Speicheradressen.
use std::sync::Arc; use std::task::{RawWaker, RawWakerVTable, Waker}; struct TaskState { id: u64, } unsafe fn clone_waker(data: *const ()) -> RawWaker { let arc = Arc::from_raw(data as *const TaskState); let _ = Arc::clone(&arc); let _ = Arc::into_raw(arc); // Leck zurück, um **drop** zu vermeiden RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // **Drop** das Arc, um die Referenz freizugeben } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // Wachlogik hier, dann zurücklecken let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // Impliziter Drop gibt den Speicher frei } static VTABLE: RawWakerVTable = RawWakerVTable::new( clone_waker, wake_waker, wake_by_ref, drop_waker, ); fn create_waker(state: Arc<TaskState>) -> Waker { let ptr = Arc::into_raw(state) as *const (); unsafe { Waker::from_raw(RawWaker::new(ptr, &VTABLE)) } }
Betrachten Sie die Entwicklung eines Hochfrequenzhandelssystems, in dem eine Rust-Async-Laufzeit mit einer veralteten C++-Marktdaten-Feed-Bibliothek verbunden werden muss. Die C++-Bibliothek bietet eine Registrierungsfunktion an, die einen void*-Kontext und einen Funktionszeiger akzeptiert, der den Callback aufruft, wenn Preisupdates eintreffen. Die ingenieurtechnische Herausforderung erfordert die Erstellung eines Wakers, der Rust-Futures mit diesem C++-Callback-Mechanismus verbindet, ohne pro-Nachrichten-Allocationsüberhead einzuführen, da die Latenzanforderungen Wachzeiten unter einer Mikrosekunde vorschreiben.
Eine Lösung bestand darin, eine Box<dyn Fn() + Send>-Closure als den Datenzeiger des Wakers zu speichern. Dieser Ansatz bot Speichersicherheit durch das Besitzsystem von Rust und eine unkomplizierte Integration. Allerdings führte es zu unakzeptablen Heap-Allocationslatenzen für jedes Marktdatenabonnement und virtuellen Dispatch-Overhead, der die null-Kopien-Architektur des Systems verletzte. Darüber hinaus stellte das Management der Lebensdauer des verpackten Closures über die FFI-Grenze hinweg eine Gefährdung dar, da die asynchrone Bereinigung der C++-Bibliothek hängende Zeiger hinterlassen konnte, wenn die Rust-Seite den Waker vor dem Stoppen des Aufrufs durch die C++-Bibliothek fallen ließ.
Ein alternativer Ansatz nutzte eine globale statische Hash-Map, die ganzzahlige IDs auf Aufgabenhandles abbildete, und übergab die ID als den void*-Kontext. Dies beseitigte die Allocations und bot O(1)-Suchvorgänge während der Wachoperationen. Doch dies schuf ein Risiko für Speicherlecks, wenn Aufgaben beendet wurden, ohne sich vom Feed abzumelden, und die statische Map erforderte die Synchronisation durch Mutex, was zu einem Engpass unter hoher Marktdaten-Durchsatz wurde und effektiv die Wachbenachrichtigungen über alle CPU-Kerne serialisierte.
Die gewählte Lösung implementierte ein benutzerdefiniertes RawWaker, bei dem der Datenzeiger einen Arc<TaskState> enthielt, der den C++-Callback-Kontext und ein Abschlussflag enthalten hatte. Die Funktionen der RawWakerVTable wurden als unsafe extern "C" Thunks implementiert, die den void* sicher zurück zu Arc-Zeigern umwandelten und somit die ordnungsgemäße Referenzzählung über die FFI-Grenze hinweg sicherstellten. Dieses Design beseitigte pro-Nachricht-Allocations, indem die Struktur Arc wiederverwendet wurde, gewährleistete Threadsicherheit durch die atomaren Operationen von Arc und sicherte die Speichersicherheit, indem die Referenzzählung nur abnahm, wenn die letzte Waker-Kopie verworfen wurde. Das Ergebnis waren Wachlatenzen unter einer Mikrosekunde, während gleichzeitig die Speichersicherheitsgarantien über die Rust/C++-Grenze hinweg aufrechterhalten wurden und erfolgreich die undefinierten Verhaltensüberprüfungen von Miri sowie Stresstests mit Millionen von gleichzeitigen Preisupdates bestanden wurden.
Warum müssen die Funktionen der RawWakerVTable threadsicher (Send + Sync) sein, auch wenn der Executor einseitig ist?
Der Waker-Typ implementiert Clone, Send und Sync, was es ihm ermöglicht, über Thread-Grenzen hinweg zu wandern, unabhängig vom Threading-Modell des Executors. Wenn eine Future einen Waker hat und diesen an eine spawn_blocking-Aufgabe oder einen std::sync::mpsc-Kanal übergibt, kann der Waker von einem anderen Thread als dem, der ihn erstellt hat, aufgerufen werden. Wenn die VTable-Funktionen von einem einseitigen Zugriff ausgehen — beispielsweise durch die Verwendung von Rc oder unsynchronisiertem statischen Mut — verursachen sie Datenrennen, wenn wake() gleichzeitig aufgerufen wird. Darüber hinaus können asynchrone Laufzeiten wie Tokio oder async-std Aufgaben zwischen Arbeiter-Threads zum Lastenausgleich verschieben, sodass der Waker in Threads kopiert und fallen gelassen werden kann, die sich von seinem Erstellungsort unterscheiden. Die Anforderung an die Threadsicherheit gewährleistet, dass der Benachrichtungsmechanismus unabhängig davon, wie der Waker im Programm geteilt wird, gültig bleibt.
Welches katastrophale Versagen tritt auf, wenn die clone-Funktion einen RawWaker mit einer anderen VTable als die ursprüngliche zurückgibt?
Der Waker-Vertrag verlangt, dass alle Kopien eines Waker denselben zugrunde liegenden Task darstellen und identisch funktionieren, wenn sie aufgerufen werden. Wenn clone einen RawWaker zurückgibt, der auf eine andere VTable zeigt - vielleicht eine von einer anderen Aufgabe oder mit null Funktionszeigern - könnte der Executor die falsche Wachlogik auslösen, wenn er die Aufgabe benachrichtigt. Dies führt entweder dazu, dass eine nicht verwandte Aufgabe geweckt wird (logische Korruption) oder in ungültigen Speicher gesprungen wird (Segmentierungsfehler). Genauer gesagt speichert der Executor typischerweise Waker-Kopien in internen Warteschlangen; wenn ein Ereignis eintritt, wird wake() auf diesen gespeicherten Handgriffen aufgerufen. Eine nicht übereinstimmende VTable bedeutet, dass der Datenzeiger (Aufgaben-Kontext) durch die falschen Funktionssignaturen interpretiert wird, was sofort zu undefiniertem Verhalten führt, wenn die VTable-Funktionen den Zeiger auf einen falschen Typ casten oder Felder an falschen Offsets ansprechen.
Warum ist die extern "C" ABI für die VTable-Funktionen zwingend erforderlich und nicht die Standard Rust ABI?
Die RawWakerVTable gibt extern "C" Funktionszeiger an, um die FFI-Kompatibilität und die Stabilität des ABI zu gewährleisten. Die Rust ABI ist nicht stabil über Compiler-Versionen oder Optimierungsstufen hinweg; Funktionssignaturen könnten je nach Compiler-Interna, Inlining-Entscheidungen oder Zielarchitekturen variieren. Die Verwendung von extern "C" stellt sicher, dass die Aufrufkonvention dem C-Standard der Plattform folgt, wodurch die VTable mit C-Code kompatibel ist und undefiniertes Verhalten verhindert wird, wenn der Compiler Code für die Funktionszeiger generiert. Darüber hinaus erfordert die extern "C" ABI spezifische Registerverwendung und Stackbereinigungsregeln, die es ermöglichen, den Waker sicher über Sprachgrenzen hinweg zu übergeben. Ohne diesen Zustand könnte die Verknüpfung mit dynamischen Bibliotheken oder die Aktualisierung des Rust-Compilers die Funktionsaufrufkonvention ändern und zu Stack-Korruption oder Argumentausrichtung führen, wenn der Executor wake() oder clone() aufruft.