Annulation synchrone, asynchrone et graceful shutdown : clarifier les concepts en programmation concurrente
Confondre la fin d'une tâche et l'arrêt d'un service réseau entraîne des bugs complexes et des fuites de ressources. Un billet d'ingénierie clarifie les trois sémantiques fondamentales de l'annulation en programmation concurrente : synchrone, asynchrone et graceful shutdown.
Fil « Clarification des concepts et termes d'annulation en programmation concurrente »
En programmation concurrente (exécution simultanée de plusieurs tâches), l'interruption d'une opération en cours est un problème complexe. Pour concevoir un système fiable qui ne bloque pas ses threads (unités d'exécution légères du système), il est essentiel de ne pas confondre trois concepts distincts : l'annulation synchrone, l'annulation asynchrone et le graceful shutdown (arrêt propre d'une application).
L'annulation synchrone : une structure de contrôle
L'annulation synchrone dépile directement la pile d'exécution (stack). Elle est omniprésente dans la gestion d'erreurs : dès qu'une exception est levée ou qu'une erreur est retournée, le programme interrompt les boucles et les blocs en cours. Il exécute alors le nettoyage des ressources via RAII (Resource Acquisition Is Initialization, idiome de libération automatique de ressources), des blocs finally, defer ou try-with-resources. Lorsque la commande d'annulation rend le contrôle, la tâche annulée est déjà terminée.
L'annulation asynchrone : un protocole de communication
À l'inverse, l'annulation asynchrone est un échange entre deux parties : l'une demande l'arrêt, puis doit attendre que la seconde accuse réception et libère ses ressources. Ce protocole s'impose lorsqu'une tâche utilise de la mémoire partagée. Par exemple, si un pool de threads (groupe de threads réutilisables) chiffre un tampon mémoire (buffer) via une boucle SIMD (Single Instruction Multiple Data, jeu d'instructions vectorielles du processeur), annuler la tâche brutalement risque de libérer le tampon pendant qu'il est lu, créant une mauvaise gestion de mémoire (data race). Le même problème survient avec io_uring (interface asynchrone d'E/S du noyau Linux) : le système doit attendre la confirmation de l'annulation avant de libérer le tampon lié aux appels système.
// Exemple de flux d'annulation asynchrone
task.request_cancelation(); // Demande envoyée, la tâche continue de tourner
await task.join(); // Attente explicite de la fin effective de la tâche
Graceful shutdown et architecture crash-only
À un niveau d'abstraction supérieur, le graceful shutdown gère les connexions applicatives. Pour un service web, cela consiste à fermer la boucle d'acceptation (accept loop) pour rejeter les nouvelles connexions, tout en laissant les requêtes en cours se terminer. Ce modèle autorise les mises à jour sans interruption (rolling upgrades). Cependant, une application résiliente doit aussi adopter le principe du crash-only software (logiciel tolérant aux crashs) : le système doit supporter une coupure de courant ou un SIGKILL (signal d'arrêt immédiat) déclenché par l'OOM killer (mécanisme du noyau Linux purgeant les processus en cas de manque de mémoire) sans corrompre ses données.
L'exemple concret de la base TigerBeetle
Cette distinction se traduit très concrètement dans la base de données financière TigerBeetle. Son composant Grid.cancel utilise une annulation asynchrone avec rappel (callback) pour attendre la clôture des opérations d'E/S disque et réseau. À l'inverse, le composant StateMachine.reset s'exécute de façon synchrone : au lieu de diffuser l'asynchronisme dans toutes les couches supérieures, l'annulation asynchrone est isolée au niveau le plus bas avant de réinitialiser le reste du système de façon synchrone. Bien identifier ces trois mécanismes permet de réduire la complexité du code tout en garantissant la libération des ressources.