3 points par GN⁺ 2025-10-05 | 1 commentaires | Partager sur WhatsApp
  • 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 await ou poll
  • Les futures Rust sont passifs (inert) : sans poll ou await explicite, 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 sleep de 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)
  • 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> par None) puis qu’un await intervient, 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 await ont 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 retour Result est 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 boucles select
  • 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 permit obtenu, l’envoi peut être effectué sans crainte d’annulation
  • write_all d’AsyncWrite : écrire tout le buffer via write_all est fragile face à l’annulation, tandis que write_all_buf permet de suivre la progression grâce au curseur du buffer
    • Dans une boucle, write_all_buf permet de reprendre en toute sécurité à partir d’un état partiellement avancé

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 reserve permet de conserver sa position d’attente dans la file de réservation
  • Usage des tasks : exécuter un future dans une task avec tokio::spawn signifie 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

 
GN⁺ 2025-10-05
Commentaire Hacker News
  • Je trouve l’exemple qui met un timeout sur send/recv trè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 sur send, 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 sur recv, 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 un peek.
    • Je me demande si ce n’est pas justement là le cœur de la cancellation-safety.
    • Je pense que c’est une bonne remarque.
  • J’aimerais présenter quelques ressources que j’ai écrites sur ce sujet
    • J’avais rédigé en 2020 une proposition selon laquelle les fonctions async devraient forcément s’exécuter jusqu’au bout ; elle inclut une fonctionnalité de graceful cancellation, et je pense qu’aucune meilleure idée n’a encore émergé lien vers la proposition
    • Il existe aussi une proposition pour une cancellation unifiée à travers Rust sync et async (« A case for CancellationTokens ») lien vers le gist
    • Il existe également une implémentation concrète de ces idées min_cancel_token
  • Je ne vois pas très bien en quoi le fait que des futures soient annulés pose problème. Les futures ne sont pas des tasks, et ce point est d’ailleurs reconnu dans l’article. Dès lors, qu’un future n’aille pas jusqu’au bout n’est-il pas précisément le comportement normal ? Et je ne comprends pas pourquoi ce serait un problème. L’article parle d’un future « cancel unsafe », mais pour moi le point central est surtout une confusion entre les attentes et la réalité.
    • Exemple 1 : annulation de l’un des éléments d’un try_join à cause d’une erreur
    • Exemple 2 : données non écrites lors d’une annulation
      Dans 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.
    • C’est vrai ! Chez Oxide, cela a réellement causé beaucoup de bugs. Une fois qu’on comprend bien que les futures sont passifs et peuvent être annulés à n’importe quel point d’await, il ne reste plus que des détails techniques.
  • J’ai trouvé cette présentation à RustConf vraiment passionnante. La distinction entre cancel safety et cancel correctness est extrêmement utile, et je suis ravi que la présentation existe aussi sous forme de billet de blog. Les conférences sont bien, mais une version blog est bien plus facile à partager et à consulter ensuite.
    • J’aime bien l’expression « cancel correctness » parce qu’elle cadre bien le sujet dans le contexte de la cancellation. En revanche, je n’aime pas beaucoup le terme « cancel safety » : il ne correspond pas vraiment à la notion de safety en Rust et donne inutilement une impression de jugement. Les termes safe/unsafe suggèrent qu’une option est meilleure ou pire, alors que le caractère souhaitable d’un comportement à l’annulation dépend du contexte. Par exemple, le future qui attend une task lancée avec spawn est dit « cancellation safe », mais si, lors du drop, 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 de spawn qui arrête la task lors du drop serait qualifié de « cancellation unsafe », alors que c’est un pattern très important pour le nettoyage des tasks dépendantes.
    • Je trouve aussi le billet de blog plus facile à lire et meilleur, je suis d’accord.
  • J’ai trouvé particulièrement intéressant le passage de https://sunshowers.io/posts/cancelling-async-rust/#the-pain-of-tokio-mutexes ; je pense que je pourrais très facilement faire ce genre d’erreur moi-même.
    • Même en tant que développeur Go, ça m’aide. Rust fournit des outils plus stricts pour aider, mais on tombe facilement dans les mêmes pièges en Go aussi avec les goroutines, les canaux, select et les autres primitives de concurrence.
  • Dans le premier exemple, le comportement recherché n’est pas clair. Si la file est pleine, il faut choisir entre abandonner, attendre ou paniquer. Mettre un timeout sur un blocage sert surtout à détecter des deadlocks. Le code dit que « tous les messages n’arrivent pas dans le canal », mais c’est normal : si les ressources manquent, cela ne peut pas faire autrement. Quel est l’objectif ? Une fermeture propre du programme ? C’est déjà assez difficile dans un environnement à threads, et pas vraiment plus simple en async. Le cas d’usage réel, c’est plutôt l’échange de messages avec une partie distante et le nettoyage de son propre état quand l’autre extrémité se déconnecte.
    • Dans l’idéal, on voudrait conserver les messages dans un tampon jusqu’à ce qu’il y ait de la place dans le canal. C’est abordé plus loin dans la présentation, dans « What can be done ».
    • La réponse est dans l’exemple : le code qui journalise quand il n’y a pas de place pendant 5 secondes sert au diagnostic, mais il risque insidieusement d’entraîner des pertes de données. C’est un peu artificiel, mais en pratique on peut très facilement coller ce genre de code de dépannage un peu partout quand on cherche à comprendre « pourquoi ça ne marche pas ? ».
    • À noter que l’autrice de cet article utilise les pronoms they/she about.
  • Il faut toujours garder à l’esprit qu’un await est toujours un point de retour potentiel. Mieux vaut éviter de mettre un await entre deux actions qui doivent impérativement s’exécuter de manière atomique.
    • J’aimerais comprendre concrètement comment cela peut poser problème, par exemple avec :
      async fn a() {
        b().await
      }
      async fn b() {
        c().await
        d().await
      }
      async fn c() {}
      async fn d() {}
      
      Dans ce code, de quelle manière d peut-il ne jamais être appelé ? À cause d’une annulation dans c ? Ou parce qu’il se passe quelque chose plus haut dans a ?
    • Du coup, ce n’est pas un peu dangereux ? Bien sûr, c’est sans doute inévitable, mais il peut y avoir des cas où une « section critique » contient deux 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 » ?
  • Les Future de Rust ressemblent un peu, en C++, aux move semantics : une fois le Future terminé, 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 reddit
  • Excellente présentation, vraiment ! En tant que tout débutant, j’aurais aimé qu’on souligne dès le début, dans le SOP, qu’on ne peut pas annuler un Future. .await prend possession du future, donc impossible d’appeler drop(), 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 sur select! et Abortable(), mais si cette partie était signalée tout au début dans une future présentation, ce serait parfait.
    • Question : que signifie « SOP » ici ?
  • Le timing est parfait : aujourd’hui même, j’étais justement en train d’ajouter à la doc comment d’une nouvelle fonction la mention « this function is cancel safe », et ça m’a amené à réfléchir à tout ça. J’aimerais vraiment qu’async drop arrive vite.
    • Cette fonction m’intrigue, tu pourrais en dire un peu plus par curiosité ?