Beaucoup de développeurs partent du principe qu’en s’appuyant sur des abstractions de concurrence, sans jamais manipuler de threads directement, l’ensemble des problèmes de concurrence disparaît comme par magie. C’est une illusion confortable, mais une illusion tout de même. Ceux qui ont déjà écrit du code multithread savent à quel point la concurrence est difficile à maîtriser correctement. Et ceux qui ne l’ont jamais fait peuvent me croire sur parole.
Ce second volet prolonge le premier épisode, qui mettait en perspective la gestion des threads avec le modèle de l’Event Loop. On y avait vu comment une plateforme peut masquer la complexité du parallélisme derrière une boucle d’événements. Reste une question essentielle : une fois cette complexité dissimulée, est-elle pour autant éliminée ?
Des abstractions pour ne plus toucher aux threads
Plutôt que de gérer les threads à la main, la plupart des plateformes et des langages proposent des primitives de plus haut niveau. Le panel est large, et chaque écosystème a fait ses propres choix :
- Callbacks : JavaScript, Python, Java, C/C++, C#
- Futures / Promises : Java, Scala, Rust, Python, Dart, JavaScript, TypeScript, C#
- Fibers / Green Threads : Go, Erlang, Java, TypeScript
- Coroutines : JavaScript, Python, Kotlin, C#, Swift, Rust
Toutes ces abstractions partagent un même objectif : réduire la charge cognitive liée à la concurrence. Elles évitent au développeur d’orchestrer manuellement la création, la synchronisation et la destruction des threads. Le runtime s’en occupe, et c’est précisément ce qui les rend si attractives.
Réduire la complexité n’est pas l’éliminer
Le problème, c’est que réduire la complexité ne revient pas à la faire disparaître. Même en s’appuyant sur ces primitives, on reste exposé à une série de difficultés bien réelles :
- Concurrence concurrente : race conditions, thread starvation, deadlocks.
- Resource safety : gestion correcte des erreurs et des interruptions, libération des ressources engagées.
- Efficacité : utilisation maîtrisée du CPU et de la mémoire face à un grand nombre d’opérations parallèles.
Le constat est nuancé. Oui, le runtime écarte un large pan de problèmes, et la gestion de la concurrence s’en trouve nettement simplifiée. Mais l’usage de ces primitives représente encore un risque non négligeable, un risque d’autant plus traître qu’il est invisible derrière une API qui semble innocente.
Un exemple JavaScript qui paraît anodin
Prenons le cas le plus banal qui soit : exécuter plusieurs tâches en parallèle avec Promise.all.
const result = await Promise.all([
succeedingTask1,
failingTask2,
succeedingTask3,
]);
À première vue, tout va bien :
- On utilise des Promises, donc la gestion des ressources asynchrones est prise en charge.
- Le thread principal n’est pas bloqué : il poursuit son exécution en attendant la résolution de la Promise.
Et pourtant, un problème sérieux se cache ici. Lorsque failingTask2 échoue (rejection), result est immédiatement marquée comme rejected… mais succeedingTask1 et succeedingTask3 ne sont pas interrompues pour autant. Elles continuent de s’exécuter en arrière-plan, consommant des ressources, alors même que leur résultat n’intéresse plus personne. C’est un resource leak : le programme avance comme si tout était terminé, tandis que deux opérations enfants tournent encore.
Pourquoi ce comportement ? Parce qu’il n’existe aucune relation entre ces Promises. Rien ne relie l’exécution des unes à celle des autres : elles sont parfaitement indépendantes, quelle que soit la structure du programme qui les a lancées. Une Promise nous offre un accès facile à la concurrence via l’Event Loop, mais elle ne porte en elle aucune notion de hiérarchie, de propriété ou de cycle de vie partagé.
La Promise n’est ici qu’un exemple. Le même raisonnement vaut pour la plupart des autres primitives de concurrence : elles facilitent le lancement d’opérations parallèles, mais sans garantir leur terminaison coordonnée.
La solution : la Structured Concurrency
C’est précisément ce que vient combler la Structured Concurrency : une approche déclarative qui apporte des garanties fortes là où les primitives classiques restent silencieuses.
L’idée fondatrice est d’offrir un contexte hiérarchique à l’ensemble des opérations d’un programme. Chaque opération concurrente appartient à un parent, et le parent conserve le contrôle sur toutes ses opérations enfants. Si l’une échoue, les autres peuvent être interrompues proprement ; si le contexte est annulé, tout ce qui en découle l’est aussi. La durée de vie d’une opération est ainsi bornée par la portée qui l’a créée, d’où le terme structured : la concurrence suit la structure du code, exactement comme les accolades délimitent la portée d’une variable.
Appliquée à notre exemple, cette approche aurait un effet immédiat : l’échec de failingTask2 aurait entraîné l’annulation automatique de succeedingTask1 et succeedingTask3, supprimant le resource leak à la racine. Plus aucune opération orpheline ne survit à la défaillance de ses pairs.
▶ La démonstration en vidéo est disponible sur le post LinkedIn original.
Conclusion
Les abstractions de concurrence (callbacks, Promises, fibers, coroutines) sont d’excellents outils pour rendre le parallélisme accessible. Mais elles déplacent la complexité plutôt qu’elles ne la suppriment : sans relation explicite entre les opérations, on s’expose toujours aux resource leaks, aux race conditions et à une gestion approximative des interruptions.
La Structured Concurrency répond à ce manque en imposant une hiérarchie claire et des garanties de terminaison. Elle ne remplace pas les primitives existantes : elle leur donne la structure qui leur faisait défaut. La prochaine fois que vous écrirez un Promise.all, demandez-vous ce qu’il advient des tâches que vous n’attendez plus. La réponse est rarement celle que l’on croit.