Skip to content

Process model — worker_threads, cluster, child_process, signaux

TL;DR — Node est mono-thread pour ton JS, mais te donne quatre façons de scaler : worker_threads (partage mémoire via SharedArrayBuffer, idéal CPU-bound), child_process.fork (Node enfant, IPC JSON), child_process.spawn/exec (n'importe quel binaire), et cluster (legacy, à éviter en 2026 — préfère un orchestrateur externe). Gère SIGTERM correctement (graceful shutdown), évite process.exit() dans du code librairie, et capture toujours unhandledRejection et uncaughtException pour logger avant de crasher proprement.

🧠 Mental model — ASCII + analogie

                  ┌──────────────────────────────┐
                  │       Process Node (PID)     │
                  │                              │
                  │   ┌────────────────────┐     │
                  │   │  Main thread (JS)  │     │
                  │   │  - Event loop      │     │
                  │   │  - V8 isolate      │     │
                  │   └────────┬───────────┘     │
                  │            │                 │
                  │   ┌────────▼───────────┐     │
                  │   │  libuv threadpool  │     │  ← fs, crypto, dns.lookup
                  │   │  (4 threads par    │     │
                  │   │   défaut)          │     │
                  │   └────────────────────┘     │
                  │                              │
                  │   worker_threads (V8 isolés) │
                  │     ┌───┐ ┌───┐              │
                  │     │ W1│ │ W2│  ← MessageChannel
                  │     └───┘ └───┘     SharedArrayBuffer + Atomics
                  └──────────────┬───────────────┘
                                 │ IPC (fork) ou pipes (spawn)
                  ┌──────────────▼───────────────┐
                  │   child_process (PID séparé) │  ← Node ou binaire
                  └──────────────────────────────┘

Analogie : ton process Node est un atelier avec un seul artisan principal (le thread JS) et une équipe d'assistants (threadpool) qui s'occupent du portage. Pour aller plus vite sur du CPU, tu peux soit embaucher des artisans supplémentaires dans le même atelier (worker_threads, mémoire partageable, communication rapide), soit ouvrir un atelier voisin (child_process.fork, isolation complète, communication par IPC), soit appeler un sous-traitant externe (spawn, n'importe quel programme).

🛠️ Code minimal (ts/js)

worker_threads avec SharedArrayBuffer + Atomics

ts
// main.ts
import { Worker } from 'node:worker_threads';

// Mémoire partagée : 1 Int32 = compteur partagé
const shared = new SharedArrayBuffer(4);
const counter = new Int32Array(shared);

const worker = new Worker(new URL('./worker.ts', import.meta.url), {
  workerData: { shared },
});

worker.on('message', (msg) => console.log('worker says:', msg));
worker.on('exit', (code) => console.log('worker exited', code));

setInterval(() => {
  // Lecture atomique — pas de race condition
  console.log('main reads counter =', Atomics.load(counter, 0));
}, 500);
ts
// worker.ts
import { parentPort, workerData } from 'node:worker_threads';

const counter = new Int32Array(workerData.shared as SharedArrayBuffer);

setInterval(() => {
  // Increment atomique
  Atomics.add(counter, 0, 1);
  parentPort?.postMessage({ count: Atomics.load(counter, 0) });
}, 200);

child_process — 3 variantes

ts
import { spawn, exec, fork } from 'node:child_process';
import { promisify } from 'node:util';

// spawn : streaming, n'importe quel binaire — préféré pour les gros outputs
const ls = spawn('ls', ['-la', '/tmp']);
ls.stdout.on('data', (chunk) => process.stdout.write(chunk));
ls.on('close', (code) => console.log('exit', code));

// exec : buffer en mémoire, max 1 MB par défaut — pratique mais piège mémoire
const execAsync = promisify(exec);
const { stdout } = await execAsync('git rev-parse HEAD');
console.log('sha =', stdout.trim());

// fork : un Node enfant avec canal IPC bidirectionnel
const child = fork(new URL('./child.ts', import.meta.url));
child.send({ task: 'compute', n: 1_000_000 });
child.on('message', (result) => console.log('child:', result));

Signaux POSIX courants

SignalComportement par défautCapturableUsage typique
SIGTERMTermine le processOuiArrêt propre demandé (k8s, systemd, docker stop)
SIGINTTermine le processOuiCtrl+C en terminal
SIGHUPTermine le processOuiReload config (legacy daemons) ou terminal hangup
SIGUSR1Termine le processOuiRéservé par Node pour le debugger (inspecte)
SIGUSR2Termine le processOuiLibre — souvent utilisé pour heap snapshot, reload
SIGPIPETermine le processOuiÉcriture sur pipe fermé (Node l'ignore par défaut)
SIGKILLTermine immédiatementNonForce kill — rien à faire côté process
SIGSTOPSuspend le processNonPause forcée

⚠️ Sur Windows, seuls SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGBREAK ont du sens. Les autres signaux POSIX n'existent pas vraiment.

Graceful shutdown — pattern complet

ts
import http from 'node:http';
import { setTimeout as delay } from 'node:timers/promises';

const server = http.createServer(async (req, res) => {
  await delay(50);
  res.end('ok\n');
});

server.listen(3000);

let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals) {
  if (shuttingDown) return; // idempotent : SIGTERM puis SIGINT = un seul shutdown
  shuttingDown = true;
  console.log(`[shutdown] reçu ${signal}, arrêt en cours…`);

  // Hard-deadline AVANT tout le reste : si quoi que ce soit pend, on force.
  // .unref() pour ne pas maintenir la boucle vivante à cause de ce timer.
  const hardKill = setTimeout(() => {
    console.error('[shutdown] timeout dépassé, force exit');
    process.exit(1);
  }, 25_000);
  hardKill.unref();

  try {
    // 1. Arrêter d'accepter de nouvelles connexions. server.close() attend
    //    que les requêtes EN COURS finissent avant d'appeler le callback.
    //    En Node 18.2+, server.closeIdleConnections() tue les keep-alive idle.
    await new Promise<void>((resolve, reject) => {
      server.closeIdleConnections?.();
      server.close((err) => (err ? reject(err) : resolve()));
    });

    // 2. Drain des ressources : fermer pools (DB, redis, brokers, etc.)
    // await Promise.all([db.end(), redis.quit(), kafka.disconnect()]);

    clearTimeout(hardKill);
    console.log('[shutdown] terminé proprement');
    process.exit(0);
  } catch (err) {
    console.error('[shutdown] erreur pendant le drain:', err);
    process.exit(1);
  }
}

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGHUP', shutdown);

🧠 Ordre qui compte. Le hard-deadline est armé en premier : si server.close() ne rend jamais la main (une requête keep-alive lente, un long-poll, un client qui ne ferme pas), tu veux quand même mourir au bout de 25 s. Et la séquence prod réelle est : (1) /healthz → 503 pour sortir du LB, (2) attendre 1 RTT de health-check (~5 s) pour que le LB te retire, (3) puis server.close(). Si tu fermes le serveur avant que le LB ait constaté le 503, tu génères des connection refused côté client.

🎯 Patterns courants — 7

1. Worker pool pour CPU-bound parallèle

Plutôt que créer/détruire un worker par tâche (coûteux ~50 ms par spawn), garde un pool persistant et distribue les tâches.

ts
import { Worker } from 'node:worker_threads';

class WorkerPool {
  private workers: Worker[] = [];
  private queue: Array<{ task: unknown; resolve: (v: unknown) => void }> = [];
  private idle: Worker[] = [];

  constructor(private url: URL, size: number) {
    for (let i = 0; i < size; i++) {
      const w = new Worker(url);
      w.on('message', (result) => {
        const job = (w as any).__job;
        (w as any).__job = null;
        this.idle.push(w);
        job?.resolve(result);
        this.dispatch();
      });
      this.workers.push(w);
      this.idle.push(w);
    }
  }

  run(task: unknown): Promise<unknown> {
    return new Promise((resolve) => {
      this.queue.push({ task, resolve });
      this.dispatch();
    });
  }

  private dispatch() {
    while (this.queue.length && this.idle.length) {
      const w = this.idle.pop()!;
      const job = this.queue.shift()!;
      (w as any).__job = job;
      w.postMessage(job.task);
    }
  }

  async terminate() {
    await Promise.all(this.workers.map((w) => w.terminate()));
  }
}

2. unhandledRejection et uncaughtException — log et exit

Depuis Node 15, une promise rejetée non gérée crashe le process par défaut (--unhandled-rejections=throw). C'est le bon comportement. Mais loggue avant.

ts
let crashing = false;
function fatal(label: string, err: unknown, extra?: Record<string, unknown>) {
  if (crashing) return; // évite la double-sortie si les deux handlers se déclenchent
  crashing = true;
  // Log SYNCHRONE — un transport async (réseau) ne flushera peut-être jamais.
  // En prod, écris sur stderr (fd 2) que ton runtime/sidecar récupère.
  process.stderr.write(JSON.stringify({ level: 'fatal', label, err: String(err), ...extra }) + '\n');
  // Petit délai best-effort pour laisser un flush async tenter sa chance, puis exit dur.
  // Surtout PAS de logique de récupération : l'état du process est indéterminé.
  process.exit(1);
}

process.on('uncaughtException', (err, origin) => fatal('uncaughtException', err, { origin }));
process.on('unhandledRejection', (reason) => fatal('unhandledRejection', reason));

⚠️ N'utilise jamais uncaughtException pour "récupérer". Après un uncaughtException, des finally, des destructeurs et des callbacks peuvent ne pas s'être exécutés : locks tenus, transactions demi-ouvertes, fichiers demi-écrits. Loggue, exit, laisse ton orchestrateur (systemd Restart=on-failure, k8s restartPolicy) te redémarrer dans un état propre. Le seul usage défendable de uncaughtException qui ne exit pas est uncaughtExceptionMonitor (Node 13.7+), un hook observateur qui ne change pas le comportement par défaut — parfait pour envoyer à Sentry sans interférer avec le crash :

ts
process.on('uncaughtExceptionMonitor', (err, origin) => {
  // Observe seulement (ne supprime pas le crash). Idéal pour la télémétrie.
  reportToSentry(err, { origin });
});

3. Forward des signaux aux child processes

Si ton process parent gère SIGTERM, ses enfants ne le reçoivent pas automatiquement (sauf si tu les as démarrés avec detached: false et qu'ils sont dans le même process group, ce qui est le défaut).

ts
const child = spawn('long-task', [], { stdio: 'inherit' });

const forward = (sig: NodeJS.Signals) => {
  process.on(sig, () => {
    child.kill(sig);
  });
};
forward('SIGTERM');
forward('SIGINT');

child.on('exit', (code) => process.exit(code ?? 0));

4. PID file pour debug en prod

Écris ton PID au boot pour pouvoir le kill -USR2 $(cat /var/run/myapp.pid) (déclencher un heap snapshot, par exemple).

ts
import fs from 'node:fs';
const pidFile = '/var/run/myapp.pid';
fs.writeFileSync(pidFile, String(process.pid));
process.on('exit', () => {
  try { fs.unlinkSync(pidFile); } catch {}
});

5. SIGUSR2 pour heap snapshot à la demande

ts
import v8 from 'node:v8';
process.on('SIGUSR2', () => {
  const file = v8.writeHeapSnapshot();
  console.log('heap snapshot écrit dans', file);
});

6. Probe k8s /healthz qui respecte le shutdown

ts
let healthy = true;
process.on('SIGTERM', () => {
  healthy = false; // /healthz commence à renvoyer 503
});
server.on('request', (req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(healthy ? 200 : 503).end();
    return;
  }
  // ... reste de l'app
});

Avec un terminationGracePeriodSeconds: 30 dans k8s, ce pattern permet au LB de te retirer du pool avant que tu commences à fermer les connexions actives. Pas de 5xx visibles côté client.

7. Communiquer entre workers via MessageChannel

Les workers peuvent s'envoyer des messages sans passer par le main thread grâce à MessageChannel. Réduit la latence et décharge le main.

ts
import { Worker, MessageChannel } from 'node:worker_threads';

const w1 = new Worker(new URL('./w.ts', import.meta.url));
const w2 = new Worker(new URL('./w.ts', import.meta.url));

const { port1, port2 } = new MessageChannel();
w1.postMessage({ port: port1 }, [port1]);
w2.postMessage({ port: port2 }, [port2]);
// w1 et w2 communiquent directement via port1/port2

🧭 Comment un staff engineer choisit — arbre de décision

La question n'est jamais « threads ou process ? » mais « qu'est-ce qui sature ? ». Mesure d'abord (--prof, clinic flame, 0x), choisis ensuite.

Ta tâche bloque l'event loop (CPU) > 50 ms ?

├─ NON → tu n'as PAS de problème de concurrence. C'est de l'I/O.
│        → garde un seul thread, optimise l'async (batching, pipelining,
│          connection pooling). Un worker ici ne ferait qu'ajouter de la latence.

└─ OUI → besoin d'isolation crash (lib native qui segfault) ?

         ├─ OUI → child_process.fork (un crash ≠ tout le service)
         │        ou process séparés derrière un LB.

         └─ NON → besoin de partager de la mémoire (gros dataset chaud) ?

                  ├─ OUI → worker_threads + SharedArrayBuffer + Atomics

                  └─ NON → worker_threads + transferList (zéro-copie)
                           via un POOL persistant (jamais éphémère).

Tableau de tradeoffs approfondi

Critèreworker_threadschild_process.forkspawn (binaire)Processus séparés + LB
Coût de démarrage~30-80 ms (V8 isolate)~50-150 ms (full Node)dépend du binairedéploiement complet
Mémoirepartagée possible (SAB)isolée (copie)isoléeisolée
Comm latencetrès faible (postMessage/SAB)moyenne (IPC sérialisé)pipes (octets bruts)réseau
Isolation crashnon (un throw non capté peut tuer le worker, pas le main ; un OOM/segfault natif peut tout tuer)oui (PID séparé)ouioui (réplique)
Partage de port serveurvia transfert de handlevia cluster/handle passingn/aLB s'en charge
Scalingvertical (cœurs d'une machine)verticaln/ahorizontal (N machines)
Observabilitéun seul PID, threadId dans les logsN PIDs, ps/top distinctsN PIDsmétriques par réplique

Failure modes à connaître

  • Worker qui throw sans handler → événement error sur le Worker, puis exit(1). Si tu n'écoutes pas error, la promesse en attente côté pool fuit (jamais résolue). Toujours câbler error + rejeter le job en cours.
  • Worker OOM--max-old-space-size par worker est indépendant du main, mais la RSS totale du process additionne tous les isolates. Un worker qui fuit la mémoire fait OOMKill tout le pod.
  • postMessage d'un objet non clonable (fonction, classe avec méthodes, WeakMap) → DataCloneError synchrone. Sérialise explicitement.
  • Deadlock Atomics.wait → un worker qui attend un notify qui n'arrive jamais bloque le thread pour toujours. Toujours un timeout : Atomics.wait(view, 0, expected, timeoutMs).
  • Zombies child_process → un child dont tu ne lis pas exit reste en <defunct> jusqu'à ce que le parent le reap. fork/spawn reap automatiquement à l'exit, mais un parent qui ne traite jamais l'événement garde des FDs ouverts.

Observabilité en production

  • Toujours tagger les logs avec threadId (worker) ou pid (child) pour corréler.
  • Exporte des métriques par worker : profondeur de queue du pool, temps en queue (p50/p99), durée de tâche, taux de respawn. Une queue qui croît = backpressure = ajoute des workers ou throttle l'entrée.
  • worker.performance.eventLoopUtilization() (Node 14.10+, et par-worker) détecte un worker bloqué : ELU proche de 1.0 = saturé.
  • Heap snapshot à la demande via SIGUSR2 (voir pattern #5) + --heapsnapshot-signal=SIGUSR2 pour automatiser sans code.

🔄 Versions — Node 18 / 20 / 22 / 24

  • Node 18worker_threads mature. child_process stable. cluster toujours présent mais déjà déconseillé pour les nouveaux projets en faveur de PM2/k8s.
  • Node 20process.permission (modèle de permissions expérimental, --experimental-permission). --watch-path pour rerun sur fichier. Améliorations worker_threads (création plus rapide ~30 %).
  • Node 22process.execve (Linux uniquement, remplace le process courant — utile pour des wrappers). --run exécute les scripts package.json sans npm. WebSocket natif global. --experimental-permission plus stable.
  • Node 24 — Permissions model stable (sans flag), modèle Node-natif (--permission --allow-fs-read=/data --allow-net=api.example.com:443). C'est un game-changer pour les agents/CLI tiers exécutés en sandbox. cluster reste mais explicitement déconseillé dans la doc.

🚨 cluster continue de fonctionner, mais en 2026 la recommandation officielle est : si tu veux scaler verticalement, utilise worker_threads ; si tu veux scaler horizontalement, mets plusieurs processus derrière un load balancer (k8s, fly.io, systemd template units). cluster ajoute une couche de complexité (sticky sessions, état partagé, IPC) que tu veux éviter.

⚠️ Pitfalls — 10

  1. process.exit() dans une lib — empêche toute logique de cleanup du caller. Ne fais ça que dans le binaire d'entry.

  2. exec avec output volumineux — buffer en mémoire avec maxBuffer (1 MB par défaut). Au-delà, error ERR_CHILD_PROCESS_STDIO_MAXBUFFER. Pour des gros outputs, utilise spawn et stream.

  3. exec avec input utilisateurexec('ls ' + userInput) = command injection. Utilise spawn(cmd, [args]) avec args séparés, qui passe par execve sans shell.

  4. fork qui ne meurt pas avec le parent — par défaut un child fork survit si le parent crashe (sauf si detached: false est respecté). Pour garantir la mort, surveille process.ppid côté child et exit si parent disparu.

  5. worker_threads qui partagent un Buffer non-shared — passer un Buffer par postMessage le copie (structured clone). Pour partager, utilise SharedArrayBuffer. Pour transférer (sans copie, mais l'original devient inutilisable côté sender), passe-le dans le tableau de transfer : port.postMessage(buf, [buf.buffer]).

  6. Race conditions sur SharedArrayBuffer sans Atomics — n'écris jamais directement view[0] = x si plusieurs threads y touchent. Toujours Atomics.store/add/sub/compareExchange.

  7. Oublier de worker.terminate() — un worker non terminé maintient le process vivant. Ton service ne s'arrête pas.

  8. SIGKILL (kill -9) non capturable — tu ne peux pas gérer SIGKILL, point. Tes data en cours d'écriture peuvent être corrompues. C'est pour ça qu'on configure k8s avec terminationGracePeriodSeconds: 30 et qu'on respecte SIGTERM.

  9. unhandledRejection ignoré — si tu fais juste un console.log sans exit, ton process continue dans un état corrompu (transaction DB demi-ouverte, locks non libérés). Log + exit + restart est la seule réponse safe.

  10. uncaughtException qui replante — si ton handler lui-même throw, Node force l'exit avec un message peu lisible. Garde le handler trivial : log synchrone + process.exit(1).

🧪 Testing — node --test

ts
// process.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';

test('graceful shutdown respecte SIGTERM', async () => {
  const child = spawn(process.execPath, [
    '-e',
    `
    const http = require('http');
    const s = http.createServer((_, r) => r.end('ok'));
    s.listen(0, () => {
      process.send && process.send({ port: s.address().port });
    });
    process.on('SIGTERM', () => {
      s.close(() => process.exit(0));
    });
    `,
  ], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });

  const [msg] = await once(child, 'message');
  assert.ok(msg.port > 0);

  child.kill('SIGTERM');
  const [code] = await once(child, 'exit');
  assert.equal(code, 0);
});

test('worker calcule en parallèle', async () => {
  const { Worker } = await import('node:worker_threads');
  const src = `
    const { parentPort } = require('node:worker_threads');
    parentPort.on('message', (n) => {
      let s = 0;
      for (let i = 0; i < n; i++) s += i;
      parentPort.postMessage(s);
    });
  `;
  const w = new Worker(src, { eval: true });
  w.postMessage(1_000_000);
  const [result] = await once(w, 'message');
  assert.equal(result, 499999500000);
  await w.terminate();
});

🎬 Cas d'usage concrets

Scénario 1 — Ingestion contrats cabinet juridique avec worker threads

Un cabinet d'affaires ingère 5000 contrats PDF par nuit pour un client M&A. Chaque PDF passe par : extraction texte (pdf-parse, ~150 ms CPU), détection clauses (regex compilée, ~80 ms), embedding OpenAI, indexation Elasticsearch. Le bottleneck est CPU-bound : sur un seul thread, 5000 × 230 ms = 19 min de pure CPU.

Architecture : process principal Node lit la file SQS, dispatch sur un pool de 8 Worker (un par cœur du serveur 8 vCPU). Chaque worker traite un PDF, retourne { text, clauses } via parentPort.postMessage. Le main thread garde l'I/O (S3 download, Postgres write) car les workers ne servent qu'au CPU. Le SharedArrayBuffer n'est pas utilisé — les contrats sont indépendants, transferList suffit pour les Buffer PDF.

Résultat : ingestion 5000 PDF en 3 min 20s (vs 19 min). RSS stable à 2.1 GB grâce au --max-old-space-size=512 par worker et au flushing immédiat des buffers.

Scénario 2 — E-commerce image processing pool de workers

Plateforme e-commerce avec upload vendeur de 50 images produit. Chaque image : resize (sharp, 4 tailles), watermark, conversion WebP+AVIF, upload S3. CPU-bound à cause de libvips natif.

cluster n'aide pas ici (pas une question de scaler le HTTP) — c'est worker_threads avec un pool fixe (4 workers, taille = cœurs - 1 pour laisser le main réactif). Le pool est implémenté avec une queue interne ; le main fait pool.run(buffer), attend la MessagePort réponse. Les buffers d'image sont passés en transferList pour éviter la copie (zéro-copy, le main perd l'ownership).

Mesuré sur Apple M2 8 cœurs : 50 images traitées en 4,2 s (vs 16 s en sériel sur le main thread, qui bloquait toutes les autres requêtes pendant ce temps).

Scénario 3 — Monitoring industrie multi-process supervisor

Usine 4.0 : agrégateur télémétrie de 200 capteurs MQTT (température, vibration, courant). Chaque capteur émet 10 msg/s. Le service doit calculer en streaming des statistiques (moyenne glissante, détection anomalie SPC) puis pousser sur Kafka.

Architecture : process supervisor (master) fork 4 child processes via child_process.fork, chacun gérant 50 capteurs. Communication IPC pour les commandes (recharger config, drain). Si un worker crash (segfault dans une lib native), le master le relance via child.on('exit'). Le master expose /health qui agrège l'état de tous les workers. Les workers utilisent process.send pour remonter leurs métriques au master qui les pousse à Prometheus.

L'isolation process (vs thread) est cruciale : un crash dans le parser binaire d'un capteur ne tue que 50 capteurs, pas les 200. Le restart prend 800 ms.

🛠️ Exemple end-to-end

Pool de worker threads pour traitement CPU-bound (extraction PDF cabinet juridique), avec gestion lifecycle, transferList, et backpressure.

ts
// main.ts
import { Worker } from "node:worker_threads";
import { cpus } from "node:os";
import { EventEmitter } from "node:events";
import { readFile } from "node:fs/promises";

type Job = { id: string; buffer: ArrayBuffer };
type Result = { id: string; text: string; clauses: string[] };

class WorkerPool extends EventEmitter {
  private idle: Worker[] = [];
  private queue: Array<{ job: Job; resolve: (r: Result) => void; reject: (e: Error) => void }> = [];

  constructor(private readonly script: string, size = Math.max(1, cpus().length - 1)) {
    super();
    for (let i = 0; i < size; i++) this.spawn();
  }

  private spawn() {
    const w = new Worker(this.script);
    w.on("error", (err) => this.emit("error", err));
    w.on("exit", (code) => {
      if (code !== 0) {
        this.emit("error", new Error(`worker exit ${code}`));
        this.spawn(); // self-heal
      }
    });
    this.idle.push(w);
    this.drain();
  }

  private drain() {
    while (this.idle.length && this.queue.length) {
      const w = this.idle.pop()!;
      const { job, resolve, reject } = this.queue.shift()!;
      const onMsg = (r: Result) => {
        w.off("message", onMsg);
        this.idle.push(w);
        resolve(r);
        this.drain();
      };
      w.on("message", onMsg);
      try {
        w.postMessage(job, [job.buffer]);
      } catch (e) {
        reject(e as Error);
      }
    }
  }

  run(job: Job): Promise<Result> {
    return new Promise((resolve, reject) => {
      this.queue.push({ job, resolve, reject });
      this.drain();
    });
  }

  async destroy() {
    await Promise.all(this.idle.map((w) => w.terminate()));
  }
}

const pool = new WorkerPool(new URL("./pdf-worker.js", import.meta.url).pathname, 8);
const files = ["c1.pdf", "c2.pdf", "c3.pdf"];
const results = await Promise.all(
  files.map(async (f) => {
    const buf = await readFile(f);
    return pool.run({
      id: f,
      buffer: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
    });
  })
);
console.log(`extracted ${results.length} contracts`);
await pool.destroy();
ts
// pdf-worker.ts
import { parentPort } from "node:worker_threads";
import pdf from "pdf-parse";

parentPort!.on("message", async ({ id, buffer }: { id: string; buffer: ArrayBuffer }) => {
  const data = await pdf(Buffer.from(buffer));
  const clauses = [...data.text.matchAll(/Article\s+\d+[^\n]+/g)].map((m) => m[0]);
  parentPort!.postMessage({ id, text: data.text, clauses });
});

Points clés : pool fixe (taille = cœurs - 1), transferList pour buffers (zéro-copie), self-healing sur exit non-zero, queue interne pour borner la concurrence, terminate propre.


🔁 Quand utiliser / éviter

ModèleUtiliser quandÉviter quand
worker_threadsCPU-bound > 50 ms, parallélisme avec shared memoryI/O-bound, code qui dépend de modules CJS lourds (coût de boot)
child_process.forksous-process Node isolé, IPC bidirectionnelquand worker_threads suffit (plus léger)
child_process.spawnexécuter un binaire externe, streaming I/Ogros JSON parsing (préfère du JS natif)
child_process.execcommande courte, output < 1 MB, scriptsen production prolongée, avec input utilisateur
clustermaintenance d'un système existantnouveau projet (préfère orchestrateur externe)
process.exitdans le binaire d'entry, après cleanupdans une lib, dans un handler async

🧬 Aller plus loin

Coût de démarrage d'un worker_thread

Un worker démarre un nouveau V8 isolate : ~30-80 ms selon les modules chargés. Pour des tâches courtes, ce coût rend inutile la parallélisation. Règle empirique : un pool persistant rentabilise un worker dès que la tâche dépasse 10 ms et que tu en as > 1000 à traiter.

ts
// Mauvais : worker éphémère pour chaque hash
function hash(data: Buffer) {
  const w = new Worker('./hash-worker.js');
  w.postMessage(data);
  return new Promise((r) => w.once('message', r));
}
// → 50 ms de boot par hash, alors que le hash lui-même prend 2 ms

// Bon : pool persistant
const pool = new WorkerPool(new URL('./hash-worker.js', import.meta.url), 4);
const result = await pool.run(data);

MessagePort.postMessage — transferable list

Par défaut, postMessage(value) fait une copie structurée (algorithme structured clone). Pour de gros buffers, c'est coûteux. Tu peux transférer la propriété :

ts
const buf = new ArrayBuffer(10_000_000);
port.postMessage(buf, [buf]); // 0 copie, buf devient detached côté sender
console.log(buf.byteLength); // 0 — n'appartient plus à ce thread

SharedArrayBuffer n'a pas besoin d'être transféré : les deux threads voient le même backing memory.

Atomics.wait et Atomics.notify — synchronisation primitive

ts
// Producteur : écrit, notifie
const shared = new SharedArrayBuffer(4);
const view = new Int32Array(shared);
Atomics.store(view, 0, 42);
Atomics.notify(view, 0);

// Consommateur : attend, lit (bloque le thread !)
Atomics.wait(view, 0, 0); // attend que view[0] != 0
console.log(Atomics.load(view, 0)); // 42

⚠️ Atomics.wait bloque le thread JS. Ne l'utilise jamais dans le main thread — uniquement dans les workers. Et préfère les API événementielles (MessagePort) sauf cas spécifiques de synchronisation fine.

cluster — pourquoi c'est legacy en 2026

cluster distribue les connexions entrantes entre N workers (un par CPU généralement). Mais :

  • Le load balancing round-robin de Node est moins bon qu'un vrai LB (nginx, k8s service).
  • Pas de rolling restart propre intégré.
  • État partagé impossible sans IPC.
  • En conteneur, tu veux un process par conteneur : k8s/ECS scale les répliques pour toi.

Pour du dev local et des cas legacy, OK. Nouveau projet : process Node mono-thread + orchestrateur externe.

Détecter qu'on est dans un worker

ts
import { isMainThread, threadId } from 'node:worker_threads';

if (isMainThread) {
  console.log('main thread');
} else {
  console.log('worker', threadId);
}

Utile pour des libs qui veulent un comportement différent (skip init lourde dans les workers, par ex.).

process.kill envoie un signal, pas forcément un kill

ts
process.kill(pid, 'SIGHUP'); // pas "tue", mais "envoie SIGHUP à pid"
process.kill(pid, 0);         // ping : vérifie si le process existe (sans envoyer de signal)

Pratique pour les pid file managers.

🔗 Liens

process.on('beforeExit') vs 'exit'

  • 'beforeExit' est émis quand la boucle est vide et qu'aucun travail asynchrone n'est planifié. Tu peux encore y planifier de l'async (timer, I/O), ce qui empêche le process de mourir. Utile pour finir un flush.
  • 'exit' est émis juste avant que le process meure. Tu ne peux plus rien d'async ; seul du code synchrone passe.
ts
process.on('beforeExit', async (code) => {
  // Dernière chance pour du nettoyage async
  await flushLogs();
});

process.on('exit', (code) => {
  console.log('byebye', code); // synchrone uniquement
});

process.env est partagé entre threads ?

Non par défaut : chaque worker a sa propre copie de process.env au démarrage. Modifier process.env.X dans un worker n'affecte pas le main. Pour partager du config dynamique, passe par workerData ou MessageChannel.

Sécurité : child_process.exec vs spawn avec args

ts
// JAMAIS : command injection si userInput contient `;`, `&&`, `$()`, etc.
exec(`grep ${userInput} /var/log/app.log`, cb);

// Toujours : args en tableau séparé, pas de shell parsing
spawn('grep', [userInput, '/var/log/app.log'], { stdio: 'inherit' });

// Si tu DOIS utiliser un shell, échappe avec shell-quote ou similaire
import { quote } from 'shell-quote';
exec(`grep ${quote([userInput])} /var/log/app.log`, cb);

Limite mémoire par process

  • --max-old-space-size=4096 : limite la heap V8 à 4 GB. Par défaut, ~1.5 GB sur Node < 14, ~64 % de la RAM sur Node 14+.
  • --max-semi-space-size=128 : nursery young generation (impact GC perf).
  • En conteneur k8s, fixe ces limites en cohérence avec les resources.limits.memory du pod, sinon le pod sera OOMKilled.
bash
# Pour un conteneur avec 4 GiB de RAM :
NODE_OPTIONS="--max-old-space-size=3584" node server.js
# (laisser ~512 Mo pour Buffer, native heap, stack)

🧠 Sur Node 20+, V8 lit déjà cgroup v2 et dimensionne sa heap par défaut sur la limite mémoire du conteneur — mais ne te fie pas au défaut pour la prod : fixe --max-old-space-size explicitement, car le défaut laisse peu de marge pour la native heap (sharp, Buffer, threadpool) et tu te fais OOMKill avant que la GC V8 ne réagisse.

🏋️ Exercices

Exercice 1 — Worker pool typé et générique (implémentation)

Objectif : transformer le WorkerPool du fichier en une classe générique WorkerPool<TTask, TResult> type-safe, avec une méthode run(task: TTask): Promise<TResult> et une taille par défaut cpus().length - 1.

Indice/Solution : paramètre l'EventEmitter avec des génériques, type le payload postMessage/message, et ajoute une Map<Worker, PendingJob> plutôt que (w as any).__job pour suivre le job courant de chaque worker proprement.

Exercice 2 — Backpressure et borne de file (production-grade)

Objectif : ajouter au pool une limite maxQueue. Au-delà, run() rejette avec ERR_POOL_SATURATED au lieu d'accumuler en mémoire à l'infini. Exporte aussi stats() qui renvoie { queued, busy, idle }.

Indice/Solution : vérifie this.queue.length >= maxQueue en tête de run() et rejette immédiatement. C'est du load shedding : mieux vaut un rejet rapide qu'un OOM silencieux. Branche stats() sur un endpoint /metrics Prometheus pour voir la queue grimper sous charge.

Exercice 3 — Graceful shutdown qui respecte le LB (production-grade)

Objectif : implémenter la séquence complète : SIGTERM/healthz passe à 503 → attendre 5 s (laisser le LB constater) → closeIdleConnections()server.close() → drain DB/redis → exit. Hard-deadline à 25 s.

Indice/Solution : un flag healthy = false dans le handler, un await delay(5_000) avant le server.close(), et un setTimeout(...).unref() armé en premier. Teste avec autocannon en boucle pendant que tu envoies SIGTERM : tu ne dois voir aucun 5xx ni ECONNRESET côté client.

Exercice 4 — Synchronisation par Atomics avec timeout (CPU + concurrence)

Objectif : un main thread distribue des indices de travail à 4 workers via un seul SharedArrayBuffer (un compteur atomique = curseur partagé). Chaque worker fait Atomics.add(cursor, 0, 1) pour réclamer le prochain item, sans aucun postMessage de coordination. Ajoute une borne et un arrêt propre.

Indice/Solution : Atomics.add renvoie l'ancienne valeur — c'est ton index réclamé atomiquement. Quand l'index dépasse total, le worker s'arrête. Compare le débit avec une version postMessage round-robin : le SAB élimine la latence de coordination mais exige une discipline atomique stricte.

Exercice 5 — Break-then-fix : le worker zombie (debug)

Objectif : pars d'un pool où un worker fait throw new Error('boom') aléatoirement sur 1 tâche sur 10. Observe que des promesses run() ne se résolvent jamais (fuite). Corrige.

Indice/Solution : le bug est l'absence de handler error/exit qui rejette le job en cours. Capture l'error, rejette la Promise du job actuellement assigné à ce worker, respawn un worker de remplacement (self-heal), et reprends le drain(). Vérifie qu'aucune promesse ne reste pendante avec un compteur de jobs résolus + rejetés == jobs soumis.

Exercice 6 — Sandbox par permissions Node 24 (sécurité, hard)

Objectif : lancer un worker/child de traitement « non fiable » (parser de fichier tiers) sous le modèle de permissions stable de Node 24 : autoriser uniquement la lecture d'/data, interdire tout accès réseau et toute écriture FS. Prouver qu'une tentative d'fetch() ou d'écriture lève ERR_ACCESS_DENIED.

Indice/Solution : node --permission --allow-fs-read=/data worker.js. Le child hérite des permissions du parent. Tente une écriture → ERR_ACCESS_DENIED. C'est la défense en profondeur pour exécuter du code/plugins tiers sans VM lourde.

🎤 En entretien

Q : Node est mono-thread — comment scales-tu un endpoint CPU-bound (ex. génération de PDF) sans bloquer les autres requêtes ? R : Je sors le CPU de l'event loop vers un pool de worker_threads persistant (taille ≈ cœurs - 1), avec une queue bornée pour le backpressure et du transferList pour passer les buffers sans copie. Le main reste dédié à l'I/O. cluster ne répond pas à ça : il duplique le HTTP, pas le calcul.

Q : Différence entre worker_threads et child_process.fork ? Quand l'un plutôt que l'autre ? R : fork lance un process Node complet (PID séparé, mémoire isolée, IPC sérialisé) → choix quand tu veux l'isolation crash (lib native qui segfault) ou tuer/restart indépendamment. worker_threads partage le même process (V8 isolate, mémoire partageable via SharedArrayBuffer, comm plus rapide) → choix pour du CPU pur sans besoin d'isolation. Threads = plus rapide et plus léger ; process = plus robuste au crash.

Q : Que se passe-t-il sur SIGTERM en k8s, et comment éviter les 5xx pendant un rolling update ? R : k8s envoie SIGTERM puis attend terminationGracePeriodSeconds avant SIGKILL. Le piège : le pod reçoit encore du trafic du LB pendant quelques secondes après le SIGTERM. La séquence correcte : basculer /healthz en 503 → attendre un cycle de health-check (~5 s) pour sortir du pool → puis server.close() + drain des pools → exit. Sans ce délai, tu fermes le serveur avant que le LB t'ait retiré et tu génères des connection refused.

Q : Pourquoi ne JAMAIS « récupérer » d'un uncaughtException ? R : Après une exception non capturée, l'état du process est indéterminé : des finally, destructeurs et callbacks ont pu ne pas s'exécuter (locks tenus, transactions demi-ouvertes, FDs non fermés). Continuer = corruption silencieuse. La seule réponse safe : logger synchrone sur stderr, process.exit(1), et laisser l'orchestrateur redémarrer un process propre. Pour la télémétrie sans interférer, utilise uncaughtExceptionMonitor.

Bibliothèque tech perso — Achref