RustProgrammationDéveloppeur Rust

Articule les dangers de sécurité mémoire qui se présentent lorsqu'un futur asynchrone est abandonné en cours d'exécution lors d'une annulation de branche select! et détaillez les modèles architecturaux—comme l'idiome drop-guard—qui doivent être employés pour garantir la cohérence des ressources lorsque l'annulation se produit entre les points d'attente.

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Lorsqu'un futur async est abandonné alors qu'il est suspendu à un point await (comme lorsque une branche sœur se termine dans tokio::select!), son implémentation Drop s'exécute de manière synchrone pour détruire les ressources détenues. Le danger survient lorsque le futur possède des ressources nécessitant un cleanup asynchrone—comme le vidage d'un TcpStream, l'envoi d'une trame de fermeture de protocole, ou la validation d'une transaction de base de données—car le trait Drop ne fournit aucun contexte async. Si le futur est annulé après avoir modifié partiellement l'état (par exemple, en écrivant la moitié d'un tampon de fichier) mais avant de finaliser, le Drop synchrone ne peut pas .await l'achèvement des opérations de nettoyage, laissant potentiellement le système dans un état incohérent ou provoquant une fuite de ressources. La solution architecturale consiste en le modèle drop-guard : encapsuler la ressource dans une structure de garde dont l'implémentation Drop planifie soit un nettoyage de repli synchrone (acceptant des risques de blocage), soit transitionne la ressource dans une tâche de nettoyage détachée, garantissant que l'invariant critique (par exemple, la suppression de fichiers temporaires) est finalement appliqué sans dépendre du code async dans le destructeur.

Situation de la vie réelle

Nous avons développé un service d'ingestion de médias à fort débit où tokio::spawn gérait des téléchargements de fichiers simultanés. Chaque tâche de téléchargement écrivait des morceaux dans un fichier temporaire sur disque, effectuait un scan de virus via un processus externe, et enfin déplacait atomiquement le fichier validé vers un bucket de stockage permanent. L'exigence était stricte : si le client se déconnectait (déclenchant une annulation de tâche via select! entre le scan de virus et le déplacement atomique), le fichier temporaire devait être supprimé immédiatement pour éviter l'épuisement de l'espace disque.

Solution 1 : Nettoyage synchrone dans Drop. Nous avons implémenté une structure TempFileGuard encapsulant std::fs::File et la chaîne de chemin. Dans son implémentation Drop, nous avons invoqué std::fs::remove_file de manière synchrone pour supprimer le fichier temporaire. Avantages : Le code était simple et garantissait l'exécution pendant le déballage de la pile ou l'annulation. Inconvénients : std::fs::remove_file est un appel système bloquant. Lorsqu'il s'exécute sur les fils de travail du runtime Tokio, cela bloquait le fil pendant des millisecondes sous une forte charge disque, affamant d'autres tâches et violant le contrat non-bloquant async. De plus, si le fichier temporaire était sur un système de fichiers réseau (NFS), le blocage pouvait s'étendre à des secondes, provoquant des bulles de latence catastrophiques.

Solution 2 : Tâche de nettoyage lancée. Dans le Drop de la garde, nous avons capturé la chaîne de chemin et lancé une tokio::task détachée pour exécuter tokio::fs::remove_file de manière asynchrone. Avantages : Cela a immédiatement restitué le contrôle au runtime, préservant la latence. Inconvénients : Si le runtime était déjà en cours d'arrêt ou sous une charge extrême, la tâche de nettoyage pouvait ne jamais s'exécuter, entraînant des fuites de ressources. De plus, ce modèle exigeait que la garde détienne une poignée Clone au runtime, compliquant la durée de vie de la structure et introduisant des possibilités d'utilisation après libération si le runtime était abandonné avant la garde.

Solution 3 : Jeton d'annulation explicite avec une repli synchrone. Nous avons utilisé tokio_util::sync::CancellationToken et structuré la logique de téléchargement pour vérifier l'annulation avant le déplacement atomique. Si annulé, une suppression synchrone était tentée uniquement si le fichier était en dessous d'un certain seuil de taille (suppression rapide), sinon il était mis en file d'attente pour un fil de nettoyage de fond dédié (lancé via std::thread) avec un canal. Le Drop de la garde ne gérait que le rare cas limite d'une panique, utilisant la suppression synchrone comme dernier recours. Solution choisie : Nous avons sélectionné l'Option 3. Elle a équilibré la détermination (voies synchrones pour les petits fichiers) avec l'évolutivité (fil de fond pour les opérations lentes) tout en évitant de bloquer les travailleurs Tokio. Le résultat a été zéro fichier temporaire fuité lors des tests de charge avec 10 000 annulations simultanées, et la latence p99 est restée stable car le fil de fond a absorbé la pénalité de latence NFS.

Ce que les candidats manquent souvent


Pourquoi invoquer block_on à l'intérieur d'une implémentation Drop pour effectuer un nettoyage asynchrone est-il fondamentalement peu sûr dans la plupart des runtimes asynchrones ?

Tenter d'appeler block_on dans Drop crée un danger de réentrance. Drop est invoqué de manière synchrone lors du déballage de la pile ou lorsqu'un futur est annulé. Si le fil actuel est un fil de travail du runtime Tokio (ou async-std), block_on tentera de conduire le réacteur à l'achèvement pour le nouveau futur. Cependant, le runtime attend déjà que la tâche actuelle (celle en cours d'abandon) libère le fil. Cela conduit à un interblocage : block_on attend que le réacteur interroge le futur de nettoyage, mais le réacteur ne peut pas progresser car le fil est bloqué à l'intérieur de block_on. De plus, des runtimes comme Tokio paniquent explicitement lors de la détection d'appels imbriqués de block_on pour empêcher ce scénario. L'approche correcte consiste à effectuer le nettoyage de manière synchrone (si instantané) ou à déléguer à un fil dédié via un canal, sans jamais bloquer l'exécuteur asynchrone depuis un destructeur.


Comment la conception de la méthode Future::poll restreint-elle intrinsèquement l'annulation pour ne se produire qu'aux points d'attente, et pourquoi est-ce significatif pour la conception de sections critiques ?

La méthode Future::poll est synchrone et doit retourner Poll::Ready ou Poll::Pending rapidement ; elle ne peut pas céder en cours d'exécution. Un point await est une sucre syntaxique pour la machine d'état générée par le compilateur passant entre des états lorsque poll retourne Pending. L'exécuteur (ou la macro select!) ne peut abandonner le futur que lorsqu'il n'est pas en cours d'exécution - spécifiquement, lorsqu'il a retourné Pending et cédé le contrôle. Par conséquent, l'annulation est atomique par rapport aux invocations poll. Cela est significatif car cela garantit que tout code entre deux points await (une "section critique") s'exécute complètement ou pas du tout du point de vue du runtime asynchrone. Cependant, si un futur détient un MutexGuard au-delà d'un await (ce que Rust interdit pour le Mutex standard mais permet pour tokio::sync::Mutex), l'annulation pourrait laisser des données partagées dans un état incohérent. Les candidats manquent souvent de garantir que les invariants de structure de données sont restaurés avant chaque point await, et non seulement à la fin de la fonction, car l'annulation exécute Drop sur toutes les variables vivantes exactement à ce point de suspension.


Dans le contexte de std::pin::Pin, pourquoi les futurs utilisés dans select! doivent-ils être soit Unpin soit explicitement épinglés, et comment cela empêche l'insécurité mémoire lors de l'abandon partiel ?

select! interroge aléatoirement plusieurs futurs. Si un futur est !Unpin (par exemple, il contient des pointeurs auto-référentiels ou des liens de liste intrusifs), le déplacer après le premier poll invaliderait ces pointeurs. Pin garantit que l'emplacement mémoire du futur reste stable. select! exige que les futurs soient Unpin (permettant des déplacements) ou déjà Pin-nés à un emplacement mémoire spécifique (pile ou tas). Lorsqu'une branche se termine, select! abandonne les autres futurs. Si le futur était Unpin, il est déplacé dans le glue d'abandon. S'il était Pin-né, il est abandonné sur place. La garantie de sécurité mémoire découle de Pin garantissant que drop est appelé sur le futur à son adresse mémoire d'origine, empêchant les problèmes d'utilisation après libération ou de pointeur pendu qui surviendraient si un futur auto-référentiel était déplacé (même pour destruction) après avoir été interrogé. Les candidats négligent fréquemment que Pin affecte non seulement le sondage, mais aussi la sémantique de destruction des futurs annulés.