La storia di questa domanda risale alla stabilizzazione di std::task::Waker in Rust 1.36, che ha introdotto un meccanismo standardizzato per gli executor per notificare le future della prontezza. Prima di questo, i framework asincroni si basavano su chiusure boxed o trait di notifica personalizzati che imponevano un sovraccarico di allocazione e impedivano un'integrazione senza soluzione di continuità con le librerie C. L'API RawWaker è stata progettata per supportare astrazioni a costo zero consentendo agli sviluppatori di costruire istanze di Waker da puntatori raw e tabelle di puntatori di funzione (RawWakerVTable), rispecchiando le tabelle virtuali di C++ ma con i requisiti di sicurezza di Rust.
Il problema sorge perché la costruzione di RawWaker bypassa completamente il sistema di possesso e borrowing di Rust. Il programmatore deve garantire manualmente quattro invarianti critici: il puntatore dei dati deve rimanere valido per tutta la vita di tutti i clone di Waker (non solo quello originale), le quattro funzioni vtable (clone, wake, wake_by_ref, drop) devono essere thread-safe (Send e Sync) anche se l'executor è single-threaded, e la funzione clone deve restituire un nuovo RawWaker che fa riferimento allo stesso stato di attività sottostante. Inoltre, la vtable deve utilizzare l'ABI extern "C" per garantire la compatibilità FFI e convenzioni di chiamata stabili tra le versioni di Rust.
La soluzione richiede una rigorosa adesione agli invarianti unsafe. Il puntatore dei dati dovrebbe tipicamente riferirsi a dati 'static o essere avvolto in Arc per gestire la proprietà condivisa tra i clone. Le funzioni vtable devono implementare correttamente la semantica del conteggio dei riferimenti: clone dovrebbe incrementare il conteggio, drop dovrebbe decrementarlo, e wake dovrebbe decrementare dopo la notifica (consumando il Waker). Violare il contratto ABI—ad esempio utilizzando le convenzioni di chiamata di Rust anziché extern "C"—risulta in comportamento indefinito quando l'executor invoca questi puntatori, incluso corruzione dello stack, disallineamento degli argomenti o salti a indirizzi di memoria non validi.
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); // Fuga per evitare drop RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // Rilascia l'Arc, liberando il riferimento } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // Logica di risveglio qui, poi fuga di nuovo let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // Rimozione implicita rilascia la memoria } 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)) } }
Considera lo sviluppo di un sistema di trading ad alta frequenza in cui un runtime async di Rust deve interfacciarsi con una libreria di feed di dati di mercato legacy in C++. La libreria C++ fornisce una funzione di registrazione che accetta un contesto void* e un puntatore a funzione, invocando il callback quando arrivano aggiornamenti di prezzo. La sfida ingegneristica richiede la creazione di un Waker che colleghi le future di Rust con questo meccanismo di callback C++ senza introdurre un sovraccarico di allocazione per ogni messaggio, poiché i requisiti di latenza richiedono tempi di risveglio sub-microsecondo.
Una soluzione ha comportato il salvataggio di una chiusura Box<dyn Fn() + Send> come puntatore ai dati del Waker. Questo approccio ha offerto sicurezza della memoria attraverso il sistema di proprietà di Rust e un'integrazione semplice. Tuttavia, ha introdotto una latenza di allocazione inaccettabile per ogni iscrizione ai dati di mercato e un sovraccarico di dispatch virtuale che violava l'architettura a copia zero del sistema. Inoltre, gestire la vita della chiusura boxed al di là del confine FFI si è rivelato pericoloso, poiché la pulizia asincrona della libreria C++ poteva lasciare puntatori pendenti se il lato Rust abbandonava il Waker prima che la libreria C++ smettesse di invocare il callback.
Un approccio alternativo ha utilizzato una mappa hash statica globale che mappa ID interi a handle di attività, passando l'ID come contesto void*. Questo ha eliminato le allocazioni e fornito una ricerca O(1) durante le operazioni di risveglio. Tuttavia, ha creato un pericolo di perdita di memoria se le attività sono state completate senza disregistrarsi dal feed, e la mappa statica ha richiesto sincronizzazione Mutex che è diventata un collo di bottiglia di contesa sotto un elevato throughput di dati di mercato, seriamente serializzando le notifiche di risveglio tra tutti i core CPU.
La soluzione scelta ha implementato un RawWaker personalizzato in cui il puntatore dei dati conteneva un Arc<TaskState> contenente il contesto di callback C++ e un flag di completamento. Le funzioni di RawWakerVTable sono state implementate come thunks unsafe extern "C" che trasmutavano in modo sicuro il void* di nuovo in puntatori Arc, garantendo un corretto conteggio dei riferimenti attraverso il confine FFI. Questo design ha eliminato le allocazioni per messaggio riutilizzando la struttura Arc, mantenuto la sicurezza dei thread attraverso le operazioni atomiche di Arc e garantito la sicurezza della memoria decrementando il conteggio dei riferimenti solo quando l'ultimo clone di Waker è stato abbandonato. Il risultato ha raggiunto latenze di risveglio sub-microsecondo mantenendo al contempo garanzie di sicurezza della memoria attraverso il confine Rust/C++, superando con successo la rilevazione di comportamento indefinito di Miri e i test di stress che coinvolgono milioni di aggiornamenti di prezzo concorrenti.
Perché le funzioni di RawWakerVTable devono essere thread-safe (Send + Sync) anche se l'executor è single-threaded?
Il tipo Waker implementa Clone, Send e Sync, permettendogli di migrare tra i confini dei thread indipendentemente dal modello di threading dell'executor. Quando una futura contiene un Waker e lo passa a un'attività spawn_blocking o a un canale std::sync::mpsc, il Waker potrebbe essere invocato da un thread diverso da quello che lo ha creato. Se le funzioni della vtable assumono accesso single-threaded—ad esempio, utilizzando Rc o static mut non sincronizzati—creano condizioni di gara quando wake() viene chiamato in modo concorrente. Inoltre, runtime asincroni come Tokio o async-std possono migrare attività tra thread di lavoro per bilanciare il carico, il che significa che il Waker potrebbe essere clonata e abbandonata in thread diversi dal suo sito di creazione. Il requisito di sicurezza dei thread garantisce che il meccanismo di notifica rimanga valido, indipendentemente da come il Waker è condiviso in tutto il programma.
Quale fallimento catastrofico si verifica se la funzione clone restituisce un RawWaker con una vtable diversa da quella originale?
Il contratto Waker richiede che tutti i clone di un Waker rappresentino la stessa attività sottostante e si comportino in modo identico quando viene invocato. Se clone restituisce un RawWaker punteggiato a una vtable diversa—forse una associata a un'attività diversa o contenente puntatori a funzione null—l'executor potrebbe invocare la logica di risveglio sbagliata quando notifica l'attività. Questo si traduce nel risvegliare un'attività non correlata (corruzione logica) o nel saltare a memoria non valida (errore di segmentazione). Specificamente, l'executor memorizza tipicamente clone di Waker in code interne; quando si verifica un evento, chiama wake() su questi handle memorizzati. Una vtable non corrispondente significa che il puntatore dei dati (contesto di attività) viene interpretato attraverso le firme di funzione sbagliate, portando a comportamento immediato indefinito quando le funzioni vtable castano il puntatore a un tipo errato o accedono a campi a offset sbagliati.
Perché è obbligatorio l'ABI extern "C" per le funzioni vtable piuttosto che l'ABI predefinito di Rust?
La RawWakerVTable specifica puntatori a funzione extern "C" per garantire la compatibilità FFI e la stabilità ABI. L'ABI di Rust non è stabile tra le versioni del compilatore o i livelli di ottimizzazione; le firme di funzione potrebbero cambiare in base agli interni del compilatore, alle decisioni di inlining o alle architetture target. Utilizzando extern "C" si assicura che la convenzione di chiamata segua lo standard C della piattaforma, rendendo la vtable compatibile con il codice C e prevenendo comportamento indefinito quando il compilatore genera codice per i puntatori a funzione. Inoltre, l'ABI extern "C" impone regole specifiche sull'uso dei registri e sulla pulizia dello stack che consentono al Waker di essere passato in modo sicuro attraverso i confini linguistici. Senza questo vincolo, il collegamento a librerie dinamiche o l'upgrade del compilatore Rust potrebbero modificare la convenzione di chiamata delle funzioni, causando corruzione dello stack o disallineamento degli argomenti quando l'executor invoca wake() o clone().