← Retour au blog
Concurrence Comparaison de deux modèles pour gérer la concurrence I/O : à gauche, le modèle one-connection-one-thread où N connexions consomment N threads via une thread pool dynamique ; à droite, le modèle Event Loop où N connexions partagent un thread principal s'appuyant sur une thread pool de taille fixe.

Concurrence : gérer les I/O par threads, une leaky abstraction

Du constat de Ryan Dahl à la création de Node.js : pourquoi l'Event Loop est une alternative au modèle one-connection-one-thread pour gérer efficacement la concurrence.

📅 ✍️ Antoine Coulon
event-loopthreadsnodejsioconcurrency

Quand Ryan Dahl crée Node.js, il ne part pas d’une envie de réinventer JavaScript côté serveur pour le plaisir. Il part d’un constat technique précis : gérer les opérations d’entrée/sortie avec des threads est une leaky abstraction. Une abstraction qui fuit, qui laisse remonter sa complexité sous-jacente jusque dans le code applicatif, là où elle n’a rien à faire.

Cet esprit précurseur avait la conviction qu’on pouvait simplifier et fiabiliser l’écriture de programmes concurrents, notamment en évitant d’imposer au développeur la gestion explicite du multithreading côté user-land. C’est cette intuition qui a donné naissance à l’Event Loop telle qu’on la connaît dans Node.js. Ce premier volet de la série sur la concurrence revient sur ce choix d’architecture et sur ce qu’il résout réellement.

Le problème : déléguer les threads au user-land

Le modèle historique pour traiter plusieurs connexions concurrentes est limpide sur le papier : une connexion, un thread. Chaque requête entrante se voit allouer son propre fil d’exécution, qui la prend en charge du début à la fin. Simple à raisonner, mais coûteux à mettre en œuvre dès qu’on cherche l’efficacité, et surtout, cela revient à exposer une primitive système bas niveau directement dans le code applicatif.

Confier la gestion des threads au user-land fait rapidement émerger une cascade de difficultés.

Une complexité fondamentale

Manipuler des threads directement, c’est ouvrir la porte aux race conditions, aux deadlocks, et à la gestion manuelle de ressources partagées. Ce ne sont pas des détails d’implémentation : ce sont des classes entières de bugs, difficiles à reproduire et à diagnostiquer, qui surgissent dès que plusieurs fils d’exécution se disputent un même état. Cette complexité est inhérente au modèle, pas accidentelle.

Un coût disproportionné

Allouer et bloquer un thread complet pour attendre le résultat d’une opération I/O est un gaspillage de ressources. Un thread a un coût mémoire et un coût d’ordonnancement non négligeables. Le mobiliser entièrement pour, l’essentiel du temps, attendre qu’un disque réponde ou qu’une socket réseau renvoie des données revient à sur-dimensionner la solution par rapport au besoin. À grande échelle, ce sont des milliers de threads majoritairement inactifs qui pèsent sur le système.

Un niveau d’abstraction trop bas

Un thread est une primitive système relativement bas niveau. Ce n’est pas, dans la plupart des cas, l’objet que l’on souhaite manipuler au niveau applicatif. Devoir descendre à ce niveau pour servir des requêtes, c’est précisément le symptôme d’une abstraction qui fuit : la mécanique interne du système d’exploitation remonte jusque dans la logique métier.

L’alternative : l’Event Loop

La proposition de Node.js consiste à renverser la responsabilité. Plutôt que de demander au développeur d’orchestrer des threads, on délègue cette orchestration au runtime, qui s’appuie directement sur les capacités du système d’exploitation.

Le fonctionnement repose sur trois éléments :

L’illustration ci-dessus oppose les deux modèles. À gauche, le modèle « une connexion, un thread » : N connexions consomment N threads, via une thread pool de grande taille et dynamique. À droite, le modèle Event Loop : N connexions partagent un unique thread principal, lequel s’appuie sur une thread pool réduite et de taille fixe. La différence d’empreinte est immédiatement visible.

L’idée clé est que cette architecture déplace la complexité hors du code applicatif pour la confier au runtime. Celui-ci exploite les mécanismes du système d’exploitation (notifications d’événements, I/O non bloquantes) pour gérer efficacement la concurrence sans imposer cette charge au développeur.

NGINX, la preuve à l’échelle

Cette approche n’est pas une singularité de Node.js. NGINX en est sans doute l’exemple le plus emblématique. En adoptant un modèle événementiel plutôt que le traditionnel « une connexion, un thread », NGINX a démontré qu’on pouvait gérer un très grand nombre de connexions concurrentes avec :

C’est cette validation à l’échelle qui a ancré le modèle événementiel comme une alternative crédible et durable au modèle threadé pour les charges fortement orientées I/O.

Vers des abstractions plus haut niveau

La tendance de fond, depuis, est claire : de plus en plus de plateformes adoptent des abstractions de plus haut niveau pour exprimer la concurrence : Fibers, Coroutines, Promises, Futures, Callbacks. C’est une évolution saine : elle éloigne encore davantage le développeur des primitives système et lui offre des outils plus expressifs pour décrire des flux asynchrones.

Pour autant, il faut se garder d’une illusion. Même avec ces abstractions, gérer correctement la concurrence reste un défi majeur, bien plus que la plupart des gens ne le pensent. Une Promise mal composée, un await oublié, une opération bloquante glissée par erreur dans le thread principal : les abstractions masquent la mécanique, elles ne suppriment pas les pièges. C’est précisément le sujet que j’approfondis dans le second volet de cette série.

Conclusion

Le constat de Ryan Dahl reste d’une grande actualité : gérer les I/O par des threads exposés au user-land, c’est laisser fuir une complexité système dans du code qui ne devrait pas avoir à s’en soucier. L’Event Loop n’a pas fait disparaître les threads (ils sont toujours là, dans la thread pool du runtime) mais elle les a remis à leur place : sous le capot, gérés par le runtime, au plus près du système d’exploitation.

Reste que déléguer la complexité au runtime ne dispense pas de comprendre ce qui se joue. Les abstractions de plus haut niveau facilitent l’écriture de programmes concurrents, mais elles n’en garantissent pas la justesse. C’est tout l’objet de la suite : voir pourquoi, même au-dessus d’une Event Loop, la concurrence continue de nous résister.