Accueil / Articles / Explication de la concurrence en Node.js : libuv, le cycle d’événements et le pool de threads

Explication de la concurrence en Node.js : libuv, le cycle d’événements et le pool de threads

Découvrez comment Node.js utilise les primitives du système d’exploitation de libuv ainsi que son pool de threads travailleurs pour gérer les opérations I/O asynchrones, ainsi que les pièges courants liés aux pools de threads et des conseils pour leur optimisation.

2284 mots

Presque tous les développeurs entendent l’expression « Node.js est à thread unique » dès leur première semaine d’apprentissage de cette plateforme. Pourtant, en pratique, un seul processus Node.js peut lire des centaines de fichiers simultanément, rechercher des milliers d’enregistrements DNS et gérer des dizaines de milliers de connexions de base de données ouvertes tout en continuant à exécuter du code sans s’arrêter.

Si le JavaScript lui-même ne s’exécute que sur un seul thread, comment un serveur Node.js parvient-il à répondre aux requêtes tout en téléchargeant un fichier de plusieurs gigaoctets depuis un disque en rotation ?

Le mécanisme à l’origine de cela est libuv, une bibliothèque en C conçue spécifiquement pour Node.js qui gère les entrées et sorties asynchrones et non bloquantes.

Comprendre réellement comment libuv décharge les tâches en dehors du thread JavaScript n’est pas seulement une question de connaissances théoriques. C’est ce qui explique pourquoi une requête de base de données se comporte différemment d’une opération de hachage en termes de performance, pourquoi modifier une seule variable d’environnement peut changer considérablement la vitesse (ou la lenteur) de réponse de votre API en production, et comment repérer et éviter les goulets d’étranglement cachés dans vos services.

Qu’est-ce que libuv ?

Node.js n’est pas un moteur monolithique unique — c’est une pile composée de plusieurs couches qui coopèrent :

+-------------------------------------------------------------+
|                      Your Application                       |
+-------------------------------------------------------------+
|                    Node.js Core (JS / C++)                  |
+------------------------------+------------------------------+
|     V8 Engine (Google)       |            libuv             |
|   (Executes JavaScript)      |   (Event Loop & Async I/O)   |
+------------------------------+------------------------------+
|                Operating System Kernel                      |
+-------------------------------------------------------------+
  • V8 (Google) : Ce moteur compile et exécute votre JavaScript. Il dispose d’une seule pile d’appels et exécute le code de manière séquentielle, sur un seul thread uniquement.
  • libuv : Une bibliothèque multiplateforme écrite en C qui gère le cycle d’événements, un pool de threads travailleurs, l’accès au système de fichiers, les compteurs temporels, la création de processus enfants et le suivi des sockets réseau.
  • Lorsque les gens décrivent Node.js comme étant à thread unique, ce qu’ils veulent vraiment dire, c’est que le contexte d’exécution JavaScript s’exécute sur un seul thread principal. Cependant, libuv elle-même est écrite en C et est intrinsèquement multi-threadée. Elle s’appuie sur les mécanismes de bas niveau fournis par le système d’exploitation pour exécuter des tâches en même temps, sans bloquer l’exécution du JavaScript.

    Deux manières dont libuv gère le travail asynchrone

    On suppose souvent que libuv dirige chaque opération asynchrone vers un thread en arrière-plan. Ce n’est pas tout à fait exact — libuv divise en réalité le travail entre deux stratégies distinctes, en fonction du type de tâche :

    • Fonctionnalités natives du système d’exploitation sans blocage (pour les entrées et sorties réseau)
    • Pool de threads interne de libuv (pour l’accès au système de fichiers, les recherches DNS et le chiffrement)

    Comprendre cette séparation constitue sans doute le modèle mental le plus utile pour analyser les performances du backend Node.js.

    Incoming Async Task
            │
            ├── Is it Network I/O? (TCP/UDP, HTTP sockets)
            │     └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
            │          (Zero worker threads used)
            │
            └── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
                  └──> Handled by libuv Thread Pool (4 threads by default)
    

    1. Entrées et sorties réseau : primitives du système d’exploitation

    Les systèmes d’exploitation modernes disposent d’API dédiées et sans blocage pour gérer les sockets réseau :

    • epoll sous Linux
    • kqueue sous macOS et la famille BSD
    • IOCP (ports de finition pour les entrées et sorties) sous Windows

    Lorsqu’une application Node.js ouvre un écouteur TCP ou envoie une requête HTTPS sortante, libuv ne la transfère pas à un thread d’arbeiteur. Au lieu de cela, elle enregistre directement le descripteur de fichier du socket auprès du noyau du système d’exploitation, demandant ainsi à être notifiée dès qu’il y a des données entrantes sur ce socket ou lorsqu’il est prêt à accepter davantage d’écritures.

    Dès lors, libuv se contente d’attendre. C’est le noyau du système d’exploitation qui surveille le matériel réseau.

    Lorsque les paquets arrivent réellement à l’interface réseau, le noyau déclenche un événement. libuv le détecte lors de l’étape de sondage de son boucle d’événements, puis met en file d’attente la fonction de rappel JavaScript correspondante pour son exécution. V8 finit par la retirer de la file et la lance sur le thread principal.

    Puisqu’aucun thread d’exécution ne reste inactif en attendant que des données apparaissent sur le réseau, un seul processus Node.js peut gérer confortablement des dizaines de milliers de connexions lentes ou inactives tout en consommant très peu de mémoire.

    2. Entrée/sortie de fichiers et opérations système : le pool de threads d’exécution

    Puisque les sockets réseau peuvent être gérés sans blocage au niveau du noyau, on pourrait se demander pourquoi les lectures et écritures de fichiers ne fonctionnent pas de la même manière.

    La raison est que la plupart des systèmes d’exploitation n’ont pas de véritable API non bloquante pour l’accès au système de fichiers. Sur les systèmes basés sur POSIX comme Linux et macOS, les opérations de fichiers ordinaires bloquent le thread qui les lance jusqu’à ce que le dispositif de stockage renvoie réellement les données demandées.

    Si Node.js tentait d’effectuer une lecture de fichier directement sur le thread JavaScript principal, tout l’environnement d’exécution s’arrêterait jusqu’à ce que le disque se mette en marche, récupère les blocs pertinents et restitue les octets. Pendant toute cette pause, aucune autre requête HTTP n’aurait pu être traitée.

    Afin de contourner ce problème, libuv gère un pool de threads d’exploitation interne.

    Voici ce qui se passe lorsque votre code appelle fs.readFile() :

    • L’appel JavaScript est transmis via les liaisons internes de Node jusqu’à libuv.
    • libuv emballle la demande de lecture de fichier en une unité de travail et la place dans une file d’attente interne.
    • L’un des threads en arrière-plan du pool retire cette demande de la file d’attente.
    • Ce thread effectue ensuite l’appel système bloquant réel — read() ou write() — en toute sécurité, indépendamment du thread principal.
  • Lorsque la lecture est terminée, le thread d’exécution envoie un signal à la boucle d’événements via un mécanisme de notification entre threads.
  • La boucle d’événements planifie alors l’appel de callback JavaScript correspondant pour qu’il s’exécute sur le thread principal, en transmettant le buffer résultant.
  • Qu’est-ce qui s’exécute réellement dans le pool de threads ?

    Quatre grandes catégories de tâches reposent sur le pool de threads libuv :

    • Appels au système de fichiers : toutes les méthodes asynchrones de fs, telles que fs.readFile, fs.stat et fs.writeFile.
    • Recherches DNS : plus précisément dns.lookup(), qui fait appel à la fonction bloquante en C getaddrinfo(). En revanche, dns.resolve() évite complètement le pool de threads et communique directement avec le réseau via des appels non bloquants.
    • Opérations cryptographiques coûteuses : des fonctions telles que crypto.pbkdf2(), crypto.scrypt(), ainsi que des routines de génération de clés.
    • Routines de compression : les méthodes asynchrones de zlib, comme zlib.gzip().

    Observer le fonctionnement du pool de threads

    Vous pouvez confirmer que libuv s’appuie sur un pool en arrière-plan et voir sa taille par défaut grâce à une petite expérience :

    // thread-pool-test.js
    const crypto = require('crypto');
    
    const start = Date.now();
    const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
      crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
        const elapsed = Date.now() - start;
        console.log(`Task ${i} completed in ${elapsed}ms`);
      });
    }
    

    crypto.pbkdf2() est un bon cas d’épreuve car il effectue intentionnellement des calculs intensifs sur le CPU pour dériver un hash de mot de passe, et ces calculs sont acheminés vers le pool de threads.

    Exécutez le script depuis votre terminal :

    node thread-pool-test.js
    

    La sortie ressemblera à ceci :

    Task 2 completed in 218ms
    Task 1 completed in 220ms
    Task 4 completed in 224ms
    Task 3 completed in 226ms
    Task 5 completed in 435ms
    Task 6 completed in 437ms
    

    Pourquoi les deux dernières tâches prennent-elles deux fois plus de temps ?

    Examinez attentivement les temps d’exécution. Les quatre premières tâches se terminent toutes à peu près au même moment, vers 220 ms. Mais les cinquième et sixième tâches prennent près de 435 ms — presque le double.

    La raison en est que le pool de threads de libuv est livré avec une taille par défaut de 4 threads.

    Dès que la boucle commence, les quatre premières tâches s’emparent des quatre threads travailleurs disponibles. Les cinquième et sixième tâches restent alors dans la file d’attente interne de libuv, en attente. Ce n’est qu’une fois que l’une des quatre tâches initiales est terminée et libère un thread que les tâches en file d’attente peuvent commencer à s’exécuter.

    Ajustement de la taille du pool avec UV_THREADPOOL_SIZE

    UV_THREADPOOL_SIZE avant de lancer le processus Node.js. Elle accepte des valeurs allant de 1 à 128.

    Essayez à nouveau le même script, cette fois en demandant un pool de 8 threads :

    # On Linux / macOS:
    UV_THREADPOOL_SIZE=8 node thread-pool-test.js
    
    # On Windows (PowerShell):
    $env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
    

    Les résultats sont différents maintenant :

    Task 1 completed in 240ms
    Task 3 completed in 242ms
    Task 2 completed in 245ms
    Task 6 completed in 249ms
    Task 4 completed in 250ms
    Task 5 completed in 252ms
    

    Lorsqu’il y a suffisamment de threads d’ouvrier disponibles, les six tâches s’exécutent simultanément au lieu d’être mises en file d’attente.

    Limitation importante : vous ne pouvez pas modifier la taille du pool depuis votre script en écrivant process.env.UV_THREADPOOL_SIZE = 8. libuv lit et verrouille cette valeur avant même que votre code JavaScript ne s’exécute, donc la variable d’environnement doit être définie au niveau du shell ou par le gestionnaire de processus qui lance Node, et non depuis l’application elle-même.

    Un problème courant en production : des threads partagés pour des tâches non liées

    Puisque les opérations sur fichiers, les recherches DNS et les traitements cryptographiques utilisent par défaut le même pool de quatre threads, une utilisation intensive dans l’une de ces catégories peut ralentir silencieusement des activités complètement distinctes.

    Imaginons cette séquence d’événements :

    • Une vague d’authentifications déclenche plusieurs appels simultanés à crypto.pbkdf2 pour vérifier les mots de passe.
    • Tous les quatre threads libuv sont désormais entièrement occupés à calculer les hachages des mots de passe.
    • À ce même moment, une autre partie de l’application appelle fs.readFile() pour charger un modèle d’e-mail, ou appelle dns.lookup() pour résoudre le nom de hôte de votre base de données.
    • Ces deux opérations doivent attendre leur tour dans la file d’attente.

    Lire un fichier ne consomme presque aucune puissance CPU, mais il subit néanmoins des retards car chaque thread de travail est occupé à effectuer des hachages. De l’extérieur, on a l’impression que l’accès aux fichiers ou la connexion à la base de données ralentit, alors que la véritable cause est la concurrence pour les threads libuv.

    Comment soulager la concurrence dans le pool de threads :

    • Attribuez plus de threads au pool : si votre service effectue beaucoup d’opérations d’entrée/sortie de fichiers ou des tâches cryptographiques, augmenter la valeur de UV_THREADPOOL_SIZE à 16 ou 32 peut réduire les conflits, à condition que la machine sous-jacente dispose de suffisamment de ressources CPU pour cela.
    • Éloignez les tâches gourmandes en CPU du pool : pour les tâches que vous contrôlez, comme la génération de rapports ou le traitement d’images, évitez de les faire passer par libuv. Préférez plutôt le module worker_threads, qui crée des instances V8 distinctes sur leurs propres threads d’OS.
    • Évitez autant que possible l’utilisation de dns.lookup() : privilégiez dns.resolve4(), ou mettez en place des pools de connexions utilisant des adresses IP explicites, afin que la résolution des noms de réseau ne consomme pas les slots limités alloués aux travailleurs par libuv.

    Où les développeurs commettent souvent des erreurs

    Erreur n°1 : Supposer que async/await charge automatiquement le travail en arrière-plan

    Préfixer une fonction de async ne crée pas de thread en arrière-plan pour elle. async/await n’est qu’une syntaxe plus lisible ajoutée par-dessus les Promises. Si le corps de la fonction contient une boucle synchrone ou un calcul lourd, ce code s’exécute toujours directement sur le thread JavaScript principal et bloquera votre serveur pendant son exécution.

    Erreur n°2 : Confondre le pool de threads de libuv avec worker_threads

    • Le pool de threads de libuv est géré en interne par du code C natif. Il s’occupe des opérations intégrées telles que fs, crypto et zlib. Vous n’avez aucun moyen d’y insérer vos propres fonctions JavaScript arbitraires.
  • Le module worker_threads, disponible depuis Node.js 10.5, est une API au niveau JavaScript. Il vous permet d’exécuter votre propre code en parallèle, chaque thread disposant de son propre moteur V8 et de sa propre boucle d’événements indépendants.
  • Troisième erreur : rendre le pool de threads trop grand

    Il est tentant de définir UV_THREADPOOL_SIZE=128 partout et de penser que plus c’est mieux. Cependant, les threads ont un coût réel : chacun nécessite de la mémoire pour sa propre pile d’exécution, et lorsque des centaines de threads se disputent seulement deux ou quatre cœurs CPU, le système d’exploitation passe beaucoup de temps à alterner entre eux.

    Un point de départ raisonnable consiste à dimensionner le pool en fonction du nombre de cœurs CPU logiques lorsque la charge de travail est gourmande en ressources CPU, comme pour les opérations cryptographiques ou de compression, ou à utiliser deux à quatre fois ce nombre lorsque le travail attend principalement les opérations d’entrée/sortie disque.

    Résumé

    Le véritable atout de Node.js réside non pas dans le fait qu’il évite complètement la concurrence, mais plutôt dans le fait qu’il cache les détails complexes liés aux threads de bas niveau derrière un modèle de programmation simple basé sur les événements.

    • L’exécution du JavaScript reste single-threadée : la logique de l’application s’exécute étape par étape, ce qui permet d’éviter les conditions de course et le besoin de verrous.
    • Les opérations réseau passent par des mécanismes au niveau du système d’exploitation via libuv : les sockets sont gérés par les systèmes de sondage du noyau, tels que epoll, kqueue ou IOCP, sans consommer aucun thread de travail.
    • L’accès aux fichiers, les recherches DNS et les opérations cryptographiques reposent sur le pool de threads : quatre threads C en arrière-plan gèrent ces appels bloquants afin que le thread principal reste disponible pour recevoir de nouvelles requêtes.

    Lorsque vous savez quel de ces deux chemins une opération donnée emprunte, vous êtes en bien meilleure position pour identifier les problèmes de performance, dimensionner correctement vos serveurs et développer des services backend capables de résister à une charge élevée.

    Lectures complémentaires

  • Node.js Streams Expliqués : Comment corriger les crashs de fichier dus à un manque de mémoire — Découvrez pourquoi le chargement de fichiers entiers en mémoire provoque des crashs des serveurs Node.js et comment les flux lisible, écrivable, duplex et de transformation résolvent ce problème grâce à la contrainte de débit.
  • Gérer la concurrentisation en Node.js : Éviter les crashs de l’API avec p-map et Bottleneck — Apprenez comment combiner p-map et Bottleneck en Node.js pour prévenir les erreurs de limitation de débit et la surcharge du système en contrôlant la concurrentisation et le timing des requêtes.
  • Node.js 26 : Explication de l’API Temporal, des méthodes Map Upserts et d’Undici 8 — Décrit les principales modifications de Node.js 26 destinées au backend, notamment l’API Temporal stable, les méthodes natives Map upsert, les améliorations de performance d’Undici 8, ainsi que les changements pouvant causer des problèmes à vérifier avant mise à niveau.
  • Comprendre les closures en JavaScript et le cycle d’événements — Apprenez comment les closures conservent les variables externes et comment le cycle d’événements organise la pile d’appels, les microtâches et les macrotâches à l’aide d’exemples de code concrets.