Kodokon kodokon.com

L'event loop côté Node : phases et pièges

Découvrez les phases libuv, la vraie différence entre setImmediate et setTimeout, et comment détecter un event loop bloqué.

10 min · 3 questions

Ouvrir cette leçon dans Kodokon

Node.js délègue sa boucle d'événements à libuv, qui l'organise en phases distinctes : timers (setTimeout, setInterval), pending callbacks, idle/prepare (interne), poll (I/O : sockets, fichiers), check (setImmediate) et close callbacks. À chaque tour, la boucle traverse ces phases dans cet ordre. Détail que peu de développeurs connaissent : entre chaque callback, Node vide d'abord la file de process.nextTick, puis les microtâches des promesses - et ce depuis Node 11, après chaque callback et non plus seulement entre les phases.

JAVASCRIPT
import { readFile } from "node:fs";

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log("io timeout"), 0);
  setImmediate(() => console.log("io immediate"));
});
L'ordre n'est garanti que dans un callback d'I/O.

Au démarrage du script, l'ordre entre setTimeout(fn, 0) et setImmediate(fn) est non déterministe : setTimeout(fn, 0) est en réalité plafonné à 1 ms, et si la boucle atteint la phase timers en moins d'une milliseconde, le timer n'est pas encore éligible. Dans un callback d'I/O en revanche, vous êtes dans la phase poll : la phase check (setImmediate) arrive toujours avant le retour à la phase timers. io immediate s'affiche donc systématiquement avant io timeout.

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
Dans un callback, nextTick devance les microtâches.

Piège subtil : au niveau supérieur d'un module ES, cet ordre s'inverse - les microtâches des promesses passent avant process.nextTick. La raison : l'évaluation d'un module ES est elle-même exécutée comme un job de promesse, la file des microtâches est donc déjà en cours de drainage quand votre code synchrone se termine. En CommonJS, nextTick garde la priorité même au niveau supérieur. Vérifiez toujours ce genre d'ordre dans le format de module que vous déployez réellement.

L'event loop tourne sur un seul thread : tout calcul synchrone long - JSON.parse d'un gros payload, expression régulière catastrophique, chiffrement synchrone - gèle toutes les requêtes en cours. En production, mesurez la latence de la boucle avec monitorEventLoopDelay : un p99 au-dessus de quelques dizaines de millisecondes signale un blocage. Le code suivant provoque un blocage volontaire puis le mesure.

JAVASCRIPT
import { monitorEventLoopDelay }
  from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const end = Date.now() + 200;
while (Date.now() < end) {}

setTimeout(() => {
  h.disable();
  const ms = h.max / 1e6;
  console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);
L'histogramme révèle le blocage de 200 ms.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Dans un callback d'I/O (par exemple fs.readFile), vous planifiez setTimeout(fn, 0) et setImmediate(fn). Lequel s'exécute en premier ?
    • setTimeout, toujours
    • setImmediate, toujours
    • L'ordre est aléatoire, comme au démarrage du script
  2. Après un callback, dans quel ordre Node vide-t-il les files d'attente prioritaires ?
    • Les microtâches des promesses passent d'abord
    • Les deux files sont vidées en alternance stricte
    • La file de process.nextTick est vidée d'abord, puis les microtâches
  3. Quel outil natif mesure précisément les blocages de l'event loop en production ?
    • console.time autour de chaque requête
    • monitorEventLoopDelay de node:perf_hooks
    • process.memoryUsage appelé dans un setInterval
    • le module node:trace_events