12k
All articles

La boucle d'événements, les threads de travail et la concurrence dans Node.js

Boucle dévénement Node.js, pool de threads libuv, worker threads et cluster expliqués avec un guide clair sur la concurrence I/O vs CPU.

OpenReplay Team
OpenReplay Team
La boucle d'événements, les threads de travail et la concurrence dans Node.js

Node.js exécute votre JavaScript sur un seul thread piloté par la boucle d’événements ; la concurrence repose sur l’externalisation de l’attente — libuv délègue les I/O bloquantes à un pool de threads en arrière-plan — et non sur l’exécution parallèle de votre code JavaScript. Lorsque vous avez besoin d’exécuter du JavaScript en parallèle pour des tâches intensives en calcul, il existe un mécanisme distinct : les worker threads, chacun étant un isolat V8 complet disposant de sa propre boucle d’événements. Ces trois éléments — le thread JS unique, le pool de threads libuv et les worker threads — sont constamment confondus, et c’est précisément de cette confusion que naissent les endpoints lents et les serveurs bloqués.

Cet article distingue ces couches avec précision. Il explique les phases de la boucle d’événements et la séparation microtâches/macrotâches, ce que le pool de threads libuv prend réellement en charge (et ce qu’il ne prend pas en charge), quand les worker threads surpassent un simple async/await, en quoi le clustering diffère, et propose une règle de décision pour choisir entre ces approches. Le code est écrit pour Node.js 24 (Active LTS), avec Node.js 26 comme version Current ; les worker threads sont stables, et non expérimentaux.

Points clés à retenir

  • Node exécute votre JavaScript sur un seul thread ; la boucle d’événements atteint la concurrence en déléguant les I/O bloquantes au pool de threads en arrière-plan de libuv, et non en exécutant votre code en parallèle.
  • Le pool de threads libuv est configuré par défaut à 4 threads et peut être porté à un maximum de 1 024 via UV_THREADPOOL_SIZE ; il prend en charge les opérations sur le système de fichiers, dns.lookup, les opérations crypto et zlib — mais pas les sockets réseau, qui utilisent directement epoll/kqueue/IOCP du système d’exploitation.
  • Un worker thread n’est pas « simplement un thread OS » — chacun est un isolat V8 distinct avec sa propre boucle d’événements et sa propre boucle libuv, ce qui explique pourquoi les workers ne peuvent pas partager d’objets ordinaires et doivent communiquer par passage de messages.
  • Les microtâches ne constituent pas une phase de la boucle d’événements : les callbacks de process.nextTick sont traités en premier, puis la file d’attente des microtâches de promesses, et seulement ensuite la boucle avance vers la macrotâche suivante.
  • Choisissez en fonction du goulot d’étranglement : async/await pour les tâches liées aux I/O, les worker threads pour le JavaScript intensif en calcul, et le cluster pour répartir la charge I/O sur plusieurs cœurs.

Le modèle fondamental : un thread JS, une boucle d’événements et libuv

Node.js exécute votre JavaScript sur un seul thread, et ce thread fait tourner la boucle d’événements. Le runtime repose sur le moteur V8 de Google pour l’exécution du JavaScript et sur libuv, une bibliothèque C qui fournit la boucle d’événements et les I/O asynchrones. Le mécanisme qui permet à un seul thread de gérer des milliers de connexions simultanées est la délégation : lorsque votre code appelle une opération bloquante comme la lecture d’un fichier, Node n’attend pas. Il enregistre l’opération auprès de libuv, retourne immédiatement, et votre callback s’exécute plus tard lorsque le résultat est disponible.

La concurrence dans Node, c’est l’externalisation de l’attente. Pendant qu’une lecture de fichier ou une résolution DNS est en cours, le thread JS unique est libre d’exécuter d’autres callbacks. Rien dans votre JavaScript ne s’exécute en parallèle — il n’existe qu’une seule pile d’appels — mais de nombreuses opérations peuvent être en cours simultanément, car les parties lentes se déroulent ailleurs.

C’est pourquoi affirmer que « Node est mono-thread » est une demi-vérité qu’il convient de nuancer. L’exécution du JavaScript est mono-thread. Le runtime, lui, ne l’est pas : libuv maintient un pool de threads en arrière-plan, et le système d’exploitation gère les sockets réseau pour le compte de Node. Considérez « mono-thread » comme une affirmation sur l’endroit où votre code s’exécute, et non sur le processus dans son ensemble.

Idée reçue fréquente : « Node est mono-thread. » Votre JavaScript s’exécute sur un seul thread ; le processus Node en utilise plusieurs. Cette distinction est précisément l’objet de cet article.

Concurrence et parallélisme, définis avec précision

La concurrence signifie que plusieurs tâches progressent sur la même période en s’entrelacant sur une ressource partagée ; le parallélisme signifie que plusieurs tâches s’exécutent au même instant sur des cœurs distincts. Une machine mono-cœur faisant tourner Node est concurrente mais pas parallèle : la boucle d’événements alterne rapidement entre les opérations en cours, mais un seul morceau de JavaScript s’exécute à tout moment. Les worker threads et le clustering ajoutent un véritable parallélisme en introduisant des contextes d’exécution supplémentaires que le système d’exploitation peut ordonnancer sur différents cœurs.

La conséquence pratique est la suivante : la concurrence résout les problèmes d’attente (I/O), le parallélisme résout les problèmes de calcul (CPU). Se tromper d’outil est la cause première de la plupart des erreurs de performance dans Node.

Les six phases de la boucle d’événements et la séparation des microtâches

La boucle d’événements s’exécute selon un cycle fixe de six phases, chacune disposant de sa propre file de callbacks qu’elle vide entièrement avant de passer à la suivante. Conformément au guide officiel de la boucle d’événements Node.js, les phases dans l’ordre sont :

  1. Timers — exécute les callbacks planifiés par setTimeout() et setInterval() dont le seuil a été atteint.
  2. Pending callbacks — exécute certains callbacks système différés depuis un cycle précédent.
  3. Idle, prepare — usage interne uniquement.
  4. Poll — récupère les nouveaux événements I/O et exécute leurs callbacks ; la boucle se bloque ici en attente d’I/O s’il n’y a rien d’autre à faire.
  5. Check — exécute les callbacks de setImmediate().
  6. Close callbacks — exécute les gestionnaires de fermeture tels que socket.on('close', ...).

Notez la troisième phase : de nombreuses explications n’en listent que cinq et omettent idle/prepare, qui existe pourtant et est réservée à la gestion interne de libuv. Elle est bien réelle ; vous ne planifiez simplement jamais rien directement dans cette phase.

Les microtâches ne sont pas une phase

Les microtâches ne constituent pas une phase de la boucle d’événements. Les callbacks de process.nextTick sont traités en premier, puis la file des microtâches de promesses est vidée, et seulement ensuite la boucle passe à la macrotâche suivante — ainsi, process.nextTick est prioritaire sur Promise.then, qui est lui-même prioritaire sur setTimeout. Le guide Node est explicite sur le fait que process.nextTick ne fait techniquement pas partie de la boucle d’événements ; sa file est traitée après la fin de l’opération en cours, quelle que soit la phase actuelle, et la file des promesses lui succède — toutes deux avant que la boucle n’avance.

Cela donne une priorité claire à trois niveaux : process.nextTick → microtâches de promesses → macrotâches (timers, I/O, setImmediate).

Une démonstration concrète de l’ordre d’exécution

La confusion classique porte sur setImmediate vs. setTimeout(0). À l’intérieur d’un callback I/O, l’ordre est déterministe ; au niveau supérieur, il ne l’est pas.

// Run on Node.js 24.16.0
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('1: setTimeout(0)'), 0);
  setImmediate(() => console.log('2: setImmediate'));
  Promise.resolve().then(() => console.log('3: promise'));
  process.nextTick(() => console.log('4: nextTick'));
});

Affiche :

4: nextTick
3: promise
2: setImmediate
1: setTimeout(0)

nextTick et la promesse sont tous deux traités avant que la boucle n’avance, nextTick en premier. Ensuite, comme les callbacks ont été planifiés depuis l’intérieur d’un cycle I/O (la phase poll), la boucle atteint la phase check en suivant, de sorte que setImmediate se déclenche avant que la boucle ne revienne à la phase timers. Le guide Node confirme que setImmediate() s’exécute toujours avant un timer lorsque les deux sont planifiés à l’intérieur d’un cycle I/O. Si vous planifiez les mêmes deux callbacks au niveau supérieur, l’ordre devient non déterministe — ne vous y fiez pas en dehors d’un callback I/O.

Le pool de threads libuv : 4 par défaut, 1 024 au maximum — et ce qui l’utilise réellement

Le pool de threads libuv est un ensemble fixe de threads en arrière-plan — 4 par défaut, extensible jusqu’à un maximum de 1 024 — que libuv utilise pour exécuter des opérations qui ne disposent pas de primitive OS non bloquante. Selon la documentation du pool de threads libuv, ce maximum est défini via la variable d’environnement UV_THREADPOOL_SIZE. (Le plafond est passé de 128 à 1 024 dans libuv 1.30.0 — les articles plus anciens citant « 128 » sont obsolètes.) Le pool est partagé entre toutes les boucles d’événements d’un processus.

Ce qui s’y exécute est une liste spécifique et finie. La documentation CLI de Node.js pour UV_THREADPOOL_SIZE en nomme les utilisateurs : les API fs (à l’exception des observateurs et des variantes explicitement synchrones), dns.lookup(), ainsi que les opérations crypto et zlib asynchrones telles que crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.generateKeyPair() et la compression zlib.

Le point le plus important ici est ce qui n’utilise pas le pool. Les I/O réseau ne touchent pas au pool de threads. Comme l’explique le guide officiel Don’t Block the Event Loop, les sockets réseau sont gérés par le mécanisme de polling du système d’exploitation — epoll sous Linux, kqueue sous macOS/BSD, IOCP sous Windows — et remontent directement dans la phase poll. Il existe également une subtilité DNS : dns.lookup() (qui appelle getaddrinfo) utilise le pool, mais la famille dns.resolve*() (qui utilise c-ares) ne l’utilise pas — affirmer globalement que « DNS utilise le pool » est donc inexact.

# À définir avant le démarrage de Node — le pool est préalloué lors de sa première utilisation.
UV_THREADPOOL_SIZE=8 node server.js

Un point opérationnel important : libuv préalloue le nombre maximum de threads lors de la première utilisation du pool, donc UV_THREADPOOL_SIZE doit être défini avant ce moment — en pratique, avant le démarrage de Node. Modifier process.env.UV_THREADPOOL_SIZE après que le pool a été sollicité n’a aucun effet.

Idée reçue fréquente : « Augmenter UV_THREADPOOL_SIZE accélère les tâches CPU. » Augmenter UV_THREADPOOL_SIZE accélère les I/O concurrentes, jamais le JavaScript intensif en calcul — pour les tâches CPU, vous avez besoin d’un worker thread, car le pool n’exécute pas votre JavaScript. Il exécute les opérations C de libuv, pas vos fonctions JS.

Les worker threads : un vrai parallélisme pour le JavaScript intensif en calcul

Les worker threads exécutent du JavaScript en parallèle sur des threads séparés pour une seule raison — les tâches intensives en calcul qui bloqueraient autrement le thread JS unique — et ils n’apportent pas d’amélioration pour les tâches liées aux I/O, que les I/O asynchrones natives de Node gèrent déjà plus efficacement. La documentation de worker_threads indique que les workers sont utiles pour les opérations JavaScript intensives en calcul et n’apportent pas grand-chose pour les tâches intensives en I/O, car les I/O asynchrones natives de Node sont plus efficaces que ce que les workers peuvent offrir.

Le point de précision que la plupart des articles manquent : un worker thread n’est pas « simplement un thread OS ». Chacun est un isolat V8 distinct avec sa propre boucle d’événements et sa propre boucle libuv. C’est précisément cette isolation qui explique pourquoi les workers ne peuvent pas partager d’objets JavaScript ordinaires et pourquoi tout ce que vous transmettez via postMessage est copié.

Idée reçue fréquente : « Les workers sont juste des threads OS. » Chaque worker est un isolat V8 complet, plus sa propre boucle d’événements et sa propre boucle libuv, s’exécutant dans un thread — c’est pourquoi il n’y a ni globaux partagés ni portée de fermeture partagée entre le thread principal et un worker.

Isolation et passage de messages

Les workers communiquent par passage de messages, et la charge utile est copiée en profondeur. Les données transmises via workerData ou postMessage() sont clonées selon l’algorithme de clonage structuré HTML — les fonctions, les prototypes de classes et les références vivantes ne survivent pas au transfert. Le seul moyen d’échapper à la copie est la mémoire partagée : les worker threads peuvent partager de la mémoire uniquement via un SharedArrayBuffer (ou en transférant un ArrayBuffer, ce qui déplace la propriété plutôt que de copier). Tout le reste est cloné structurellement.

// main.js — Run on Node.js 24.16.0
const { Worker } = require('node:worker_threads');

const worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });
worker.on('message', (result) => console.log('fib(42) =', result));
worker.on('error', (err) => console.error(err));
// fib-worker.js
const { parentPort, workerData } = require('node:worker_threads');

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

isMainThread permet à un seul fichier de se comporter différemment selon son rôle lorsque vous préférez ne pas diviser les fichiers. Pour la surface complète de l’API — MessageChannel, MessagePort, listes de transfert, receiveMessageOnPort — consultez la documentation de worker_threads. Par rapport aux threads en C++ ou Java, ce modèle échange la mémoire partagée par défaut (et les verrous, mutex et conditions de course qui l’accompagnent) contre l’isolation par défaut : plus sûr, au prix de la copie.

Utilisez un pool, pas un worker par tâche

Instancier un Worker pour chaque requête est coûteux. La documentation Node est explicite : il faut utiliser un pool de workers en pratique, car le surcoût de création des workers dépasserait probablement leur bénéfice dans le cas contraire. Le pool standard dans la communauté est piscina. Piscina est une implémentation rapide et efficace de pool de worker threads pour Node.js ; sa dernière version est la 5.2.0. Écrivez 5.x dans votre plage package.json et épinglez la version précise en CI.

Il y a un corollaire qui surprend souvent : ne déplacez pas dans un worker des tâches déjà asynchrones. Les opérations crypto asynchrones, fs et zlib s’exécutent déjà sur les threads en arrière-plan de libuv, donc les envelopper dans un worker revient simplement à planifier un thread pour en planifier un autre, sans aucun gain. Les workers ne justifient leur coût que pour du JavaScript synchrone et intensif en calcul.

Exemple concret : une route intensive en calcul qui bloque tout le monde

Une tâche CPU synchrone sur le thread principal gèle la boucle d’événements pour toutes les requêtes en cours, pas seulement celle qui l’a déclenchée. Voici le scénario d’échec dans un handler Express :

// server-blocking.js — Run on Node.js 24.16.0, express 5.x
const express = require('express');
const app = express();

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

app.get('/report', (req, res) => {
  res.json({ value: fib(45) }); // bloque la boucle d'événements pendant plusieurs secondes
});

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);

Pendant l’exécution de fib(45), /health ne répond pas — le thread unique est occupé à calculer, et chaque requête concurrente se met en file d’attente derrière lui. La solution consiste à déléguer le travail CPU à un pool et à await le résultat, libérant ainsi la boucle d’événements pour continuer à servir les requêtes :

// server-pooled.js — Run on Node.js 24.16.0, express 5.x, piscina 5.2.0
const express = require('express');
const Piscina = require('piscina');
const path = require('node:path');

const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });
const app = express();

app.get('/report', async (req, res) => {
  const value = await pool.run({ n: 45 }); // s'exécute sur un worker ; la boucle reste libre
  res.json({ value });
});

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);
// fib-task.js
module.exports = ({ n }) => {
  const fib = (x) => (x < 2 ? x : fib(x - 1) + fib(x - 2));
  return fib(n);
};

Désormais, fib(45) s’exécute sur un worker, la boucle d’événements reste réactive, et /health répond immédiatement pendant que la route lourde calcule en arrière-plan.

Une boucle d’événements bloquée présente une signature caractéristique en production : comme une tâche CPU synchrone gèle le thread JS unique pour toutes les requêtes en cours, elle se manifeste par le blocage simultané de nombreux utilisateurs au même instant — et non par le problème réseau d’un seul utilisateur. Ce schéma de gel corrélé est précisément ce que la relecture de session révèle sur des sessions simultanées, et vous pouvez le confirmer à l’exécution avec monitorEventLoopDelay depuis node:perf_hooks, où un p99 élevé indique que la boucle (ou le pool de threads) est saturé.

Le clustering et les processus multiples : mise à l’échelle des I/O sur plusieurs cœurs

Le clustering permet de faire évoluer une application Node sur plusieurs cœurs CPU en forgeant plusieurs processus, chacun exécutant l’application complète avec sa propre boucle d’événements et sa propre mémoire, tout en partageant un socket d’écoute. Le module cluster est le moyen natif d’y parvenir ; c’est l’outil approprié lorsque votre goulot d’étranglement est le débit lié aux I/O et que vous souhaitez utiliser tous les cœurs de la machine plutôt qu’un seul.

La distinction avec les worker threads est importante. Les workers de cluster sont des processus séparés avec une mémoire totalement isolée qui communiquent via IPC ; les worker threads sont des threads séparés au sein d’un même processus qui peuvent partager de la mémoire via SharedArrayBuffer. Le cluster vise à traiter davantage de requêtes concurrentes sur plusieurs cœurs ; les worker threads visent à sortir le JavaScript intensif en calcul du thread de requête. En pratique, un service à haut débit utilise souvent les deux : le cluster (ou un gestionnaire de processus / des réplicas de conteneurs) pour couvrir les cœurs, et un pool de workers à l’intérieur de chaque processus pour absorber les pics CPU occasionnels.

Guide de décision : async, pool, worker ou cluster

Choisissez en fonction de votre goulot d’étranglement : async/await pour les tâches liées aux I/O, les worker threads pour le JavaScript intensif en calcul, le cluster (ou plusieurs processus) pour répartir la charge I/O sur plusieurs cœurs, et un pool de workers comme piscina lorsque vous souhaitez éviter de payer le coût de démarrage d’un worker à chaque requête.

OutilExécute JS en parallèle ?Partage la mémoire ?Idéal pourCoût principal
async/await + boucle d’événementsNonN/A (un seul thread)Tâches liées aux I/O (réseau, BDD, fichiers)Bloque si vous faites du calcul CPU
Pool de threads libuvNon (exécute du C, pas votre JS)N/Afs/dns.lookup/crypto/zlib concurrentsTaille fixe ; pas pour votre JS
Worker threads (+ pool)OuiVia SharedArrayBuffer uniquementJavaScript intensif en calculDémarrage + copie par clonage structuré
Cluster / processus multiplesOuiNon (IPC uniquement)Mise à l’échelle de la charge I/O sur plusieurs cœursSurcoût des processus ; pas d’état partagé

Règles rapides :

  • Tâche liée aux I/O et pas encore parallélisée ? Utilisez async/await. Le pool de threads et le système d’exploitation vous offrent déjà la concurrence gratuitement.
  • Une route effectue des calculs synchrones lourds ? Déléguez à un worker thread, derrière un pool pour la réutilisation.
  • Saturation d’un cœur sous charge concurrente ? Répartissez sur plusieurs cœurs avec le cluster (ou exécutez plusieurs réplicas de conteneurs).
  • Tenté d’augmenter UV_THREADPOOL_SIZE pour gagner en performance ? Seulement si vous êtes limité par les I/O sur des opérations gérées par le pool (nombreux fs/crypto concurrents). Cela n’accélérera jamais le JavaScript intensif en calcul.

Une note prospective pour le code que vous écrivez aujourd’hui : à partir de Node.js 27 en octobre 2026, le projet passe à une version majeure par an, mettant fin à la cadence pair/impair. Suivez le calendrier des versions Node.js et développez contre la LTS en cours.

Le modèle mental qui permet de garder tout cela clair est de cesser de se demander « Node est-il mono-thread ? » et de commencer à se demander « quel est mon goulot d’étranglement ? » L’attente est le rôle de la boucle d’événements ; le calcul en parallèle est celui du pool de workers ; la répartition de la charge sur plusieurs cœurs est celle du cluster. Profilez l’endpoint lent, identifiez s’il est bloqué en attente ou bloqué en calcul, et le bon outil s’impose naturellement — puis vérifiez la correction avec monitorEventLoopDelay avant de déployer.

FAQ

Quelle est la différence entre les worker threads et le module cluster dans Node.js ?

Les worker threads sont des threads séparés au sein d'un même processus, chacun étant un isolat V8 avec sa propre boucle d'événements, pouvant partager de la mémoire via un SharedArrayBuffer ; le cluster crée des processus séparés avec une mémoire totalement isolée qui communiquent via IPC et partagent un socket d'écoute. Utilisez les worker threads pour sortir le JavaScript intensif en calcul du thread de requête, et le cluster pour répartir la charge I/O sur les cœurs CPU. Les services à haut débit combinent souvent les deux.

Pourquoi augmenter UV_THREADPOOL_SIZE n'accélère-t-il pas mon code intensif en calcul ?

Le pool de threads libuv exécute des opérations au niveau C telles que les appels au système de fichiers, dns.lookup, les opérations crypto asynchrones et zlib, et non votre JavaScript. Augmenter UV_THREADPOOL_SIZE accroît uniquement le nombre de ces opérations de type I/O pouvant s'exécuter simultanément ; cela n'accélère jamais le JavaScript intensif en calcul, car ce code s'exécute toujours sur le thread JS unique. Pour les tâches CPU, vous avez besoin d'un worker thread, qui exécute du JavaScript en parallèle dans son propre isolat V8.

Node.js gère-t-il les requêtes réseau via le pool de threads libuv ?

Non. Les sockets réseau sont gérés par le mécanisme de polling du système d'exploitation — epoll sous Linux, kqueue sous macOS et BSD, et IOCP sous Windows — et remontent directement dans la phase poll de la boucle d'événements. Le pool de threads libuv prend en charge les opérations sur le système de fichiers, dns.lookup via getaddrinfo, les opérations crypto asynchrones et zlib, mais pas les I/O réseau. Notez que les fonctions dns.resolve utilisent la bibliothèque c-ares et contournent également le pool — affirmer que « DNS utilise le pool » n'est donc vrai que pour dns.lookup.

Les worker threads peuvent-ils partager des objets JavaScript avec le thread principal ?

Non. Les workers communiquent par passage de messages, et les données transmises via workerData ou postMessage sont copiées en profondeur selon l'algorithme de clonage structuré HTML — les fonctions, les prototypes de classes et les références vivantes ne survivent pas au transfert. Le seul moyen de partager de la mémoire est un SharedArrayBuffer, ou le transfert d'un ArrayBuffer, qui déplace la propriété plutôt que de copier. Cette isolation par défaut évite les verrous et les conditions de course inhérents à la programmation multi-thread avec mémoire partagée dans des langages comme C++ ou Java.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.