Gestion de l’annulation en Rust asynchrone
(sunshowers.io)- Dans un environnement Rust asynchrone, la gestion de l’annulation est pratique, mais si elle est mal maîtrisée, elle peut provoquer des bugs inattendus et des difficultés de diagnostic
- En Rust synchrone, il faut généralement vérifier explicitement un flag ou terminer le processus, alors qu’en Rust asynchrone, il suffit de drop un future pour l’annuler très facilement
- La sûreté d’annulation (cancel safety) et la correction d’annulation (cancel correctness) sont deux concepts distincts, et l’annulation d’un future peut causer des problèmes à l’échelle de tout le système
- Parmi les principaux schémas problématiques liés à l’annulation figurent le mutex Tokio, la macro
select,try_join, ainsi que des erreurs d’utilisation des futures - Il n’existe pas de solution parfaite, mais l’usage d’API sûres vis-à-vis de l’annulation, le pinning des futures et la séparation en tasks permettent de réduire les problèmes dus à l’annulation
Introduction
- Cet article s’appuie sur une présentation de RustConf 2025 consacrée à la gestion de l’annulation (cancellation) en Rust asynchrone
- Dans des exemples classiques de code asynchrone en Rust, l’ajout d’un timeout à une boucle de réception ou d’envoi de messages révèle souvent des pertes de messages
- Il traite de problèmes d’annulation rencontrés dans de grands systèmes réels utilisant async Rust, notamment chez Oxide Computer Company, ainsi que de véritables cas de bugs
- Le texte est structuré en trois parties : 1) le concept d’annulation, 2) l’analyse de l’annulation, 3) des solutions pratiques
- L’auteur a expérimenté les avantages et les difficultés du Rust asynchrone à travers le signal handling en Rust et le développement de cargo-nextest
1. Qu’est-ce que l’annulation ?
Sens de l’annulation
- L’annulation (cancellation) désigne la situation où une tâche asynchrone est lancée puis interrompue en cours de route
- Exemples : téléchargement volumineux, requête réseau, lecture partielle d’un fichier, etc., que l’on peut interrompre avant la fin
Comment annuler en Rust synchrone
- En général, on vérifie périodiquement un flag atomique pour savoir s’il faut annuler, ou bien on utilise des exceptions spéciales (
panic), voire l’arrêt forcé du processus entier - Certains frameworks (comme Salsa) utilisent le payload de
panic, mais cela ne fonctionne pas sur toutes les plateformes Rust, notamment en environnement Wasm - Forcer l’arrêt d’un seul thread n’est pas autorisé en raison des garanties de sûreté de Rust et de la structure des mutex
- En résumé, il n’existe pas de protocole d’annulation générique et sûr en Rust synchrone
Rust asynchrone : qu’est-ce qu’un Future ?
- Un Future est une machine à états (state machine) générée par le compilateur Rust, qui n’est en mémoire qu’une simple donnée
- Sa création ne l’exécute pas ; il ne progresse que lorsqu’on appelle
awaitoupoll - Les futures Rust sont passifs (inert) : sans
pollouawaitexplicite, ils n’effectuent aucun travail - Cela contraste avec Go/JavaScript/C#, où la création d’un future déclenche généralement son exécution immédiate
Le protocole d’annulation en Rust asynchrone
- Annuler un Future consiste simplement à le drop, ou à cesser de lui appeler
poll/await - Comme il s’agit d’une machine à états, on peut abandonner un Future à n’importe quel moment
- En Rust asynchrone, l’annulation est donc à la fois très puissante et très facile à appliquer
- Mais elle est trop facile : un future peut être drop silencieusement, entraînant en cascade l’annulation de ses futures enfants selon le modèle de possession
- À cause de cette propriété, l’annulation devient un phénomène non local, susceptible d’affecter toute la chaîne d’appels
2. Analyse de l’annulation
Sûreté d’annulation et correction d’annulation
- La sûreté d’annulation (cancel safety) est la propriété d’un future individuel pouvant être annulé sans effet de bord
- Exemple : le future
sleepde Tokio est sûr vis-à-vis de l’annulation - À l’inverse, l’envoi MPSC de Tokio peut perdre un message si le future est drop avant son terme (pas de sûreté d’annulation)
- Exemple : le future
- La correction d’annulation (cancel correctness) est une propriété globale du système : il doit conserver ses propriétés essentielles en cas d’annulation
- S’il n’y a pas de future non sûr vis-à-vis de l’annulation dans le système, il n’y a pas de problème de correction
- Un problème ne survient que si un future non sûr vis-à-vis de l’annulation est effectivement annulé
- Si l’annulation provoque perte de données, violation d’invariants ou oubli de nettoyage, alors la correction d’annulation est rompue
Les difficultés du mutex Tokio
- Un mutex Tokio fonctionne en prenant un lock, en ajustant les données, puis en relâchant le lock
- Problème : si, à l’intérieur du lock, on viole temporairement l’état attendu (par exemple en remplaçant
Option<T>parNone) puis qu’unawaitintervient, l’annulation du future peut figer les données dans un état invalide - Dans des systèmes réels (par exemple la gestion d’état de sled chez Oxide), des points
awaitont effectivement conduit à des états instables à cause de l’annulation - Ainsi, dans la gestion d’état du code asynchrone, l’annulation peut devenir une source de défauts extrêmement dangereuse
Schémas d’apparition de l’annulation et exemples
- Appel d’un future sans
.await: Rust signale en général les futures inutilisés, mais pas toujours si une valeur de retourResultest capturée dans_(d’où l’intérêt des lints Clippy les plus récents) - Opérations de type
try_join: si un future échoue, les autres sont annulés, ce qui a déjà conduit à des bugs dans de la logique d’arrêt de services réels - Macro
select: plusieurs futures sont traités en parallèle, puis tous ceux qui n’ont pas terminé sont annulés, ce qui accroît fortement le risque de perte de données dans les bouclesselect - Ces schémas sont documentés, mais dans la pratique, l’annulation asynchrone peut survenir implicitement dans de nombreux endroits
3. Que peut-on faire ?
- Il n’existe pas encore de solution fondamentale et complète aux problèmes de correction d’annulation
- En pratique toutefois, on peut réduire le risque de défauts liés à l’annulation avec les approches suivantes
Recomposer avec des futures sûrs vis-à-vis de l’annulation
- Exemple avec MPSC send : séparer la réservation (
reserve) de l’envoi réel (send) permet d’obtenir une sûreté d’annulation partielle- L’annulation de l’étape de réservation ne fait pas perdre le message concerné
- Une fois le
permitobtenu, l’envoi peut être effectué sans crainte d’annulation
write_alld’AsyncWrite : écrire tout le buffer viawrite_allest fragile face à l’annulation, tandis quewrite_all_bufpermet de suivre la progression grâce au curseur du buffer- Dans une boucle,
write_all_bufpermet de reprendre en toute sécurité à partir d’un état partiellement avancé
- Dans une boucle,
Utiliser les futures de manière à éviter l’annulation
- Pinning des futures : dans une boucle
select, on peut fixer un future avec un pin pour le poll par référence sans l’annuler- Exemple : réutiliser un future
reservepermet de conserver sa position d’attente dans la file de réservation
- Exemple : réutiliser un future
- Usage des tasks : exécuter un future dans une task avec
tokio::spawnsignifie que drop le handle n’annule pas automatiquement la task elle-même, qui reste gérée séparément par le runtime- Dans le serveur HTTP Dropshot d’Oxide, chaque requête est exécutée dans une task distincte, ce qui garantit son traitement complet même si le client se déconnecte
Une solution systématique ?
- Au niveau du safe Rust, les possibilités restent limitées, mais plusieurs pistes sont discutées
- Async drop : permettre l’exécution de code de nettoyage asynchrone lors de l’annulation d’un future
- Types linéaires (linear types) : imposer l’exécution de certains traitements au moment du
drop, ou marquer certains futures comme non annulables
- Toutes ces approches restent difficiles à mettre en œuvre
Conclusion et recommandations
- Il faut comprendre en profondeur que les futures sont passifs (passive)
- Il est nécessaire de bien maîtriser les notions de sûreté d’annulation (cancel safety) et de correction d’annulation (cancel correctness)
- Il faut identifier à l’avance les principaux cas de bugs d’annulation et les schémas de code concernés afin de préparer une stratégie adaptée
- Quelques recommandations pratiques
- Éviter les mutex Tokio et envisager des alternatives
- Concevoir ou utiliser des API partiellement progressives ou sûres vis-à-vis de l’annulation
- Pour les futures non sûrs vis-à-vis de l’annulation, adopter une structure de code garantissant impérativement leur achèvement
- Il est également recommandé d’examiner des sujets avancés comme la cooperative cancellation, le modèle acteur, la structured concurrency, la panic safety ou le mutex poisoning
- Des ressources supplémentaires sont disponibles sur sunshowers/cancelling-async-rust
Merci de votre lecture. L’auteur remercie ses collègues d’Oxide pour leurs retours sur la présentation et les documents de référence.
1 commentaires
Commentaire Hacker News
send/recvtrès intéressant : j’ai réalisé que, dans un langage où un future peut s’exécuter sans polling préalable tant qu’il n’a pas encore démarré, on pourrait au contraire obtenir la situation inverse. Si on met un timeout sursend, le message peut encore être envoyé après le timeout, mais il n’est pas perdu, donc c’est sûr ; en revanche, si on met un timeout surrecv, il peut arriver qu’un message soit lu depuis le canal puis que le timeout soit sélectionné, ce qui jette simplement le message et peut donc être non sûr. La solution consiste à sélectionner soit le timeout, soit « quelque chose est disponible » sur le canal, puis, dans ce second cas, à consulter les données de manière sûre avec unpeek.try_joinà cause d’une erreurDans tous ces cas, il est normal que le travail ne soit pas terminé puisque le contexte a été annulé. Si ce travail doit absolument aller jusqu’au bout, il suffit de le séparer dans une task indépendante. Je me demande si je ne passe pas à côté d’une nuance importante. J’ai toujours compris que la disparition du travail à cause de la cancellation faisait partie de l’intention de conception des futures ; j’aimerais qu’on m’explique à nouveau où se situe exactement le problème.
await, il ne reste plus que des détails techniques.spawnest dit « cancellation safe », mais si, lors dudrop, la task continue de s’exécuter, cela peut accumuler du travail inutile et monopoliser un verrou ou un port, ce qui pose problème. À l’inverse, un handle despawnqui arrête la task lors dudropserait qualifié de « cancellation unsafe », alors que c’est un pattern très important pour le nettoyage des tasks dépendantes.selectet les autres primitives de concurrence.awaitest toujours un point de retour potentiel. Mieux vaut éviter de mettre unawaitentre deux actions qui doivent impérativement s’exécuter de manière atomique.dpeut-il ne jamais être appelé ? À cause d’une annulation dansc? Ou parce qu’il se passe quelque chose plus haut dansa?await, avec une suspension entre les deux, alors qu’il faut malgré tout que l’exécution reprenne ensuite. Par exemple, si on modifie la base de données puis qu’on écrit un audit log, et que les deux doivent absolument être exécutés, est-ce qu’il n’y a vraiment pas de meilleure réponse qu’un commentaire « do not cancel » ?Futurede Rust ressemblent un peu, en C++, aux move semantics : une fois leFutureterminé, il peut se retrouver dans un état invalide. Comme Rust repose sur des coroutines sans pile, il faut gérer explicitement l’état dans une struct quand on implémente à la main une async poll-based. Tout cela constitue des pièges fréquents. Et récemment, dans l’async Rust, la cancellation est devenue une nouvelle variable dans la gestion d’état. Quand je développais la bibliothèque mea (Make Easy Async), je documentais toujours la cancel safety dès qu’elle n’était pas triviale, et je me souviens aussi d’un cas où une cancellation async trop désinvolte avait posé problème dans la pile d’E/S mea cas sur redditFuture..awaitprend possession du future, donc impossible d’appelerdrop(), et comme les futures sont lazy, je ne voyais pas clairement comment l’annulation se produisait après.await. J’ai compris ensuite en me renseignant surselect!etAbortable(), mais si cette partie était signalée tout au début dans une future présentation, ce serait parfait.async droparrive vite.