Rust introduce auto trait—come Send e Sync—per risolvere il carico ergonomico di dover provare manualmente la sicurezza dei thread per ogni tipo composito. Storicamente, i programmatori di sistema dovevano annotare ogni struct con contratti di concorrenza complessi, che erano soggetti a errori e verbosi. Il compilatore risolve questo implementando automaticamente questi trait per i tipi aggregati (struct, enum, tuple) solo se tutti i campi costitutivi li implementano.
Il problema sorge con i puntatori grezzi (*const T e *mut T). A differenza dei riferimenti o dei puntatori smart, i puntatori grezzi non portano con sé semantiche di proprietà o aliasing che il compilatore può verificare. Possono puntare a memoria locale del thread, memoria non allocata o stato condiviso mutabile gestito tramite sincronizzazione esterna. Applicare ciecamente Send o Sync ai puntatori grezzi basandosi solo su T violerebbe la sicurezza della memoria, poiché il compilatore non può garantire che il puntatore venga usato correttamente attraverso i confini dei thread.
La soluzione biforca la logica di derivazione. Per gli aggregati, il compilatore esegue una ricorsione strutturale: controlla ogni campo. Per i puntatori grezzi, il compilatore trattiene esplicitamente queste implementazioni, considerandole gestori opachi e potenzialmente insicuri. Questo costringe gli sviluppatori a utilizzare unsafe impl Send o unsafe impl Sync, assumendo la responsabilità personale per il rispetto delle garanzie di sicurezza dei thread che il compilatore non può dedurre.
use std::ptr::NonNull; // Un tipo aggregato struct Container<T> { data: Vec<T>, // Vec<T> è Send se T è Send index: usize, } // Container<T> è automaticamente Send se T: Send // Un tipo con un puntatore grezzo struct Node<T> { value: T, next: *mut Node<T>, // Il puntatore grezzo interrompe la derivazione automatica } // Opt-in esplicito richiesto unsafe impl<T: Send> Send per Node<T> {} unsafe impl<T: Sync> Sync per Node<T> {}
Durante lo sviluppo di un buffer circolare MPMC (multi-produttore, multi-consumatore) privo di allocazioni e senza blocchi per un'applicazione di trading ad alta frequenza, avevo bisogno che i nodi risiedessero in un array pre-allocato per evitare la contesa su jemalloc. La struct Node conteneva il payload e un puntatore *mut Node<T> per il prossimo nodo formando una lista collegata intrusiva. Nel tentativo di inviare il gestore del buffer a un thread di lavoro, il compilatore ha rifiutato il codice perché Node non implementava Send, nonostante sapessi che i nodi venivano accessati solo tramite operazioni atomiche di confronto e scambio.
Ho valutato tre soluzioni. Prima, sostituire il puntatore grezzo con Box<Node<T>>. Questo è stato rifiutato perché Box implica la proprietà dell'heap e allocazioni individuali, il che frammentava il buffer circolare ottimizzato per la cache e introduceva latenze di allocazione inaccettabili in HFT. Secondo, utilizzare NonNull<Node<T>> avvolto in AtomicPtr. Mentre AtomicPtr stesso è Send se T è Send, la struct Node contenente falliva ancora nella derivazione automatica perché il puntatore grezzo all'interno di NonNull (che è un involucro attorno a un puntatore grezzo) bloccava il controllo strutturale. Terzo, implementare manualmente Send e Sync utilizzando blocchi unsafe impl.
Ho scelto il terzo approccio dopo aver verificato formalmente che tutti gli accessi al puntatore next erano protetti da operazioni atomiche SeqCst su un indice di stato separato, assicurando che le relazioni di avviene-prima prevenissero le condizioni di competizione. Questa soluzione ha preservato l'architettura senza blocchi e priva di allocazioni soddisfacendo il sistema di tipi di Rust. Il risultato è stato una coda di qualità di produzione capace di elaborare milioni di eventi al secondo senza il sovraccarico di mutex, anche se richiedeva ampi commenti di SAFETY per i futuri manutentori.
Perché un puntatore grezzo a un tipo Send non implementa automaticamente Send?
I candidati assumono spesso che Send sia "transitivo" attraverso tutti i campi, inclusi i puntatori grezzi. Non riconoscono che i puntatori grezzi sono tipi primitivi privi di semantiche di proprietà intrinseche. Il compilatore non può distinguere tra un puntatore a memoria locale del thread e un puntatore a memoria heap condivisa, né può verificare le regole di aliasing. Di conseguenza, *const T e *mut T non implementano mai Send o Sync automaticamente, indipendentemente da T, costringendo il programmatore a usare unsafe impl per assumere la responsabilità del contratto di sicurezza del puntatore.
Come posso implementare Send condizionatamente per una struct generica contenente interni non sicuri?
Molti sviluppatori presumono che unsafe impl debba essere incondizionale. In realtà, puoi scrivere unsafe impl<T> Send per MyType<T> dove T: Send + 'static {}. Questo è essenziale per contenitori generici (come un involucro personalizzato di UnsafeCell) che dovrebbero essere Send solo quando i loro contenuti lo sono. I candidati trascurano che la clausola where in un unsafe impl consente lo stesso potere espressivo dei trait sicuri, garantendo che i vincoli di sicurezza dei thread si propagano correttamente attraverso il codice generico senza sovraccaricare l'implementazione.
Qual è la differenza nei requisiti di sicurezza per implementare Sync rispetto a Send su un tipo con puntatori grezzi?
Send richiede solo che il trasferimento della proprietà del valore attraverso i confini dei thread sia sicuro. Per un puntatore grezzo, questo di solito significa che spostare il valore dell'indirizzo è sicuro se il puntato è Send. Sync, tuttavia, richiede che la condivisione di riferimenti immutabili (&Self) attraverso i thread sia sicura. Se &Node espone il valore del puntatore grezzo (che potrebbe essere dereferenziato), e un altro thread modifica il puntato attraverso un riferimento mutabile, questo costituisce una condizione di competizione. Pertanto, le implementazioni di Sync per i tipi contenenti puntatori grezzi richiedono quasi sempre prova di accesso sincronizzato (ad esempio, il puntatore è accessibile solo sotto un Mutex o tramite operazioni atomiche), mentre Send potrebbe richiedere solo prova di trasferimento di proprietà unica.