Risposta alla domanda
Quando un futuro async viene abbandonato mentre è sospeso a un punto di attesa (ad esempio, quando un ramo fratello completa in tokio::select!), la sua implementazione di Drop viene eseguita in modo sincrono per distruggere le risorse detenute. Il pericolo sorge quando il futuro possiede risorse che richiedono una pulizia asincrona—come svuotare un TcpStream, inviare un frame di chiusura del protocollo o confermare una transazione nel database—poiché il tratto Drop non fornisce contesto async. Se il futuro viene cancellato dopo aver modificato parzialmente lo stato (ad esempio, scrivendo metà di un buffer di file) ma prima di completare, il Drop sincrono non può .await il completamento delle operazioni di pulizia, lasciando potenzialmente il sistema in uno stato incoerente o causando perdite di risorse. La soluzione architetturale implica il modello drop-guard: avvolgere la risorsa in una struttura di guardia la cui implementazione di Drop pianifica una pulizia di emergenza sincrona (accettando i rischi di blocco) oppure trasforma la risorsa in un'attività di pulizia separata, garantendo che l'invariante critica (ad esempio, la cancellazione di file temporanei) venga infine applicata senza fare affidamento su codice async all'interno del distruttore.
Situazione reale
Abbiamo sviluppato un servizio di acquisizione media ad alta capacità dove tokio::spawn gestiva caricamenti di file concorrenti. Ogni attività di caricamento scriveva chunk in un file temporaneo su disco, eseguiva la scansione dei virus tramite un processo esterno e infine spostava atomica il file convalidato in un bucket di archiviazione permanente. Il requisito era rigoroso: se il client si disconnetteva (attivando la cancellazione dell'attività tramite select! tra la scansione dei virus e lo spostamento atomico), il file temporaneo doveva essere eliminato immediatamente per prevenire l'esaurimento dello spazio su disco.
Soluzione 1: Pulizia sincrona in Drop. Abbiamo implementato una struttura TempFileGuard che avvolge std::fs::File e la stringa del percorso. Nella sua implementazione di Drop, abbiamo invocato std::fs::remove_file in modo sincrono per eliminare il file temporaneo. Pro: Il codice era semplice e garantiva l'esecuzione durante il disaccoppiamento dello stack o la cancellazione. Contro: std::fs::remove_file è una syscall di blocco. Quando si esegue sui thread di lavoro del runtime Tokio, questo bloccava il thread per millisecondi sotto carichi di disco elevati, privando altri compiti e violando il contratto async non bloccante. Inoltre, se il file temporaneo si trovava su un filesystem di rete (NFS), il blocco poteva estendersi a secondi, causando bolle di latenza catastrofiche.
Soluzione 2: Attività di pulizia chiamata. Nella Drop della guardia, abbiamo catturato la stringa del percorso e generato un tokio::task separato per eseguire tokio::fs::remove_file in modo asincrono. Pro: Questo restituiva immediatamente il controllo al runtime, preservando la latenza. Contro: Se il runtime stava già spegnendosi o era sotto carico estremo, l'attività di pulizia potrebbe non essere mai eseguita, portando a perdite di risorse. Inoltre, questo modello richiedeva che la guardia mantenesse un manico Clone del runtime, complicando la vita della struttura e introducendo un potenziale uso dopo la liberazione se il runtime veniva abbandonato prima della guardia.
Soluzione 3: Token di cancellazione esplicito con fallback sincrono. Abbiamo utilizzato tokio_util::sync::CancellationToken e strutturato la logica di caricamento per controllare la cancellazione prima dello spostamento atomico. Se cancellato, è stata effettuata un'eliminazione sincrona solo se il file era al di sotto di una certa soglia di dimensione (eliminazione veloce), altrimenti è stata in coda per un thread di pulizia dedicato (generato tramite std::thread) con un canale. La Drop della guardia gestiva solo il raro caso limite di un panico, utilizzando la cancellazione sincrona come ultima risorsa. Soluzione scelta: Abbiamo selezionato l'Opzione 3. Ha bilanciato il determinismo (percorso sincrono per file di piccole dimensioni) con la scalabilità (thread in background per operazioni lente) evitando di bloccare i lavoratori Tokio. Il risultato è stato zero file temporanei persi durante i test di carico con 10.000 cancellazioni concorrenti, e la latenza p99 è rimasta stabile poiché il thread in background ha assorbito la penalità di latenza del NFS.
Cosa spesso manca ai candidati
Perché invocare block_on all'interno di un'implementazione di Drop per eseguire la pulizia asincrona è fondamentalmente non valido nella maggior parte dei runtime asincroni?
Tentare di chiamare block_on all'interno di Drop crea un pericolo di rientranza. Drop viene invocato sincronicamente durante il disaccoppiamento dello stack o quando un futuro viene cancellato. Se il thread corrente è un thread di lavoro del runtime Tokio (o async-std), block_on tenterà di portare a termine il reattore per il nuovo futuro. Tuttavia, il runtime sta già aspettando che il compito corrente (quello che viene abbandonato) rilasci il thread. Ciò porta a un deadlock: block_on attende che il reattore inizi a pollare il futuro di pulizia, ma il reattore non può procedere perché il thread è bloccato all'interno di block_on. Inoltre, runtime come Tokio provocano esplicitamente un panico quando rilevano chiamate nidificate di block_on per prevenire questo scenario. L'approccio corretto è eseguire la pulizia in modo sincrono (se istantanea) o delegarla a un thread dedicato tramite un canale, senza mai bloccare l'esecutore asincrono all'interno di un distruttore.
Come il design del metodo Future::poll limita intrinsecamente la cancellazione a verificarsi solo nei punti di attesa, e perché è significativo per il design delle sezioni critiche?
Il metodo Future::poll è sincrono e deve restituire Poll::Ready o Poll::Pending prontamente; non può cedere a metà esecuzione. Un punto di attesa è una scorciatoia sintattica per la macchina a stati generata dal compilatore che passa tra stati quando poll restituisce Pending. L'esecutore (o la macro select!) può solo abbandonare il futuro quando non è attivamente in esecuzione—specificamente, quando ha restituito Pending e ha ceduto il controllo. Di conseguenza, la cancellazione è atomica rispetto alle invocazioni di poll. Questo è significativo perché garantisce che qualsiasi codice tra due punti di attesa (una "sezione critica") venga eseguito completamente o per nulla dalla prospettiva del runtime asincrono. Tuttavia, se un futuro detiene un MutexGuard attraverso un'attesa (che Rust vieta per il Mutex standard ma consente per tokio::sync::Mutex), la cancellazione potrebbe lasciare i dati condivisi in uno stato incoerente. I candidati spesso trascurano che devono garantire che gli invarianti della struttura dati siano ripristinati prima di ciascun punto di attesa, non solo alla fine della funzione, poiché la cancellazione esegue Drop su tutte le variabili vive esattamente in quel punto di sospensione.
Nel contesto di std::pin::Pin, perché i futuri utilizzati in select! devono essere o Unpin o esplicitamente pin, e come ciò previene l'insicurezza della memoria durante l'abbandono parziale?
select! esegue casualmente il polling di più futuri. Se un futuro è !Unpin (ad esempio, contiene puntatori autoconclusivi o collegamenti di lista intrusivi), spostarlo dopo il primo poll invaliderebbe quei puntatori. Pin garantisce che la posizione in memoria del futuro rimanga stabile. select! richiede che i futuri siano Unpin (consentendo gli spostamenti) o siano già Pin-ati a una specifica posizione di memoria (stack o heap). Quando un ramo completa, select! abbandona gli altri futuri. Se il futuro era Unpin, viene spostato nel collante di abbandono. Se era Pin-ato, viene abbandonato in loco. La garanzia di sicurezza della memoria deriva da Pin che garantisce che drop venga chiamato sul futuro al suo indirizzo di memoria originale, prevenendo problemi di uso dopo la liberazione o puntatori pendenti che sorgerebbero se un futuro autoconclusivo fosse spostato (anche per distruzione) dopo essere stato polled. I candidati trascurano frequentemente che Pin influisce non solo sul polling ma anche sulla semantica di distruzione dei futuri cancellati.