Retour au cours

backend / nodejs

Performance — clustering et worker threads

Leçon 151 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi Node.js exécute le JavaScript sur un seul thread, et ce que ça implique
  • Exploiter tous les cœurs CPU d'une machine avec le module cluster
  • Déporter un calcul CPU-intensif sur un thread séparé avec worker_threads
  • Distinguer une opération I/O-bound (pas besoin de worker) d'une opération CPU-bound (qui en a besoin)
  • Éviter le piège de créer un worker par requête grâce à un pool réutilisable

Dans quel contexte ?

Une API qui génère des rapports PDF volumineux voit ses temps de réponse se dégrader dès que plusieurs utilisateurs génèrent un rapport en même temps : chaque génération bloque l'event loop pendant plusieurs secondes, ralentissant TOUTES les autres requêtes en cours, même celles qui n'ont rien à voir avec la génération de PDF. Cette leçon montre comment cluster et worker_threads répondent chacun à une facette différente de ce problème.

Se souvenir d'une règle vue dès la leçon 1

La toute première leçon du cours a établi une règle fondamentale : Node.js exécute ton code JavaScript sur un seul thread. Cette leçon montre les DEUX outils officiels pour dépasser cette limite quand c'est réellement nécessaire.

Il est important de comprendre d'emblée qu'ils répondent à des besoins très différents. Voyons le premier : cluster, qui multiplie les instances plutôt que les threads.

Un serveur Node moderne tourne souvent sur une machine avec plusieurs cœurs CPU. Mais un seul processus Node n'en utilise qu'un seul par défaut, laissant les autres inactifs.

Le module cluster corrige ça : il lance PLUSIEURS processus Node identiques, un par cœur disponible. Il répartit ensuite les connexions entrantes entre eux, augmentant la capacité globale du serveur.

OutilMultiplieBon pour
clusterdes processus completsaugmenter le débit global (plus de requêtes/s)
worker_threadsdes threads dans un même processusisoler UN calcul lourd sans bloquer les autres requêtes

Attention, il y a une limite importante à ce mécanisme. Chaque worker reste single-threaded individuellement : un calcul lourd bloque toujours SON worker précis, pas les autres, mais lui oui.

Pour ce cas précis, un second outil existe : worker_threads, qui isole un calcul précis. Pour un traitement CPU-intensif ponctuel — un hash cryptographique lourd, un traitement d'image — il permet de le déporter sur un thread SÉPARÉ.

Le thread principal continue alors de traiter d'autres requêtes pendant ce temps. La communication entre les deux se fait par passage de messages (postMessage), pas par mémoire partagée directement.

Ce choix évite les bugs classiques de concurrence qu'on trouve dans les langages à mémoire partagée. Un vrai avantage de conception, pas un détail technique secondaire.

Il reste un piège à connaître avant d'utiliser cet outil : le coût de création. Créer un nouveau Worker a un coût réel, démarrer un nouvel environnement JS prend du temps.

En créer un par requête sur une API à fort trafic serait donc contre-productif. Un pool de workers réutilisables amortit ce coût en gardant un nombre fixe de workers déjà démarrés.

Piège fréquent

Créer un new Worker() à chaque requête HTTP entrante peut faire chuter les performances au lieu de les améliorer : le coût de démarrage d'un nouvel environnement JS peut dépasser le temps du calcul lui-même sous forte charge. Utilisez un pool de workers persistants (par exemple avec piscina ou workerpool).

Le piège conceptuel le plus fréquent pour finir : confondre I/O-bound et CPU-bound. Une opération d'I/O (réseau, fichier, base de données) n'a PAS besoin de worker threads — async/await suffit largement, car Node délègue déjà ça à libuv, vu dès la leçon 1.

Commandes & code

Performance — clustering et worker threads

js
// cluster.js — exploiter tous les cœurs CPU en répartissant les connexions
import cluster from "node:cluster";
import { availableParallelism } from "node:os";
import process from "node:process";

const numCPUs = availableParallelism();

if (cluster.isPrimary) {
  console.log(`Processus primaire ${process.pid} — démarrage de ${numCPUs} workers`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code, signal) => {
    console.error(`Worker ${worker.process.pid} arrêté (${signal || code}), redémarrage...`);
    cluster.fork(); // résilience : un worker qui crash est immédiatement remplacé
  });
} else {
  // Chaque worker exécute une instance complète du serveur Express
  await import("./server.js");
  console.log(`Worker ${process.pid} démarré`);
}
js
// worker_threads — déporter du calcul CPU-intensif SANS bloquer l'event loop principal
// worker.js
import { parentPort, workerData } from "node:worker_threads";

function computeHeavyHash(data, iterations) {
  let result = data;
  for (let i = 0; i < iterations; i++) {
    result = require("node:crypto").createHash("sha256").update(result).digest("hex");
  }
  return result;
}

const hash = computeHeavyHash(workerData.input, workerData.iterations);
parentPort.postMessage({ hash });
js
// main.js — déléguer le calcul lourd à un worker thread depuis une route Express
import { Worker } from "node:worker_threads";
import path from "node:path";

function runHashWorker(input, iterations) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.resolve("./worker.js"), {
      workerData: { input, iterations },
    });

    worker.on("message", (msg) => resolve(msg.hash));
    worker.on("error", reject);
    worker.on("exit", (code) => {
      if (code !== 0) reject(new Error(`Worker arrêté avec le code ${code}`));
    });
  });
}

app.post("/hash-intensive", async (req, res) => {
  // pendant ce calcul, l'event loop principal reste libre de traiter d'AUTRES requêtes
  const hash = await runHashWorker(req.body.data, 100_000);
  res.json({ hash });
});
js
// Pool de worker threads réutilisables — évite le coût de création d'un worker par requête
import { Worker } from "node:worker_threads";

class WorkerPool {
  constructor(workerPath, size) {
    this.workers = Array.from({ length: size }, () => new Worker(workerPath));
    this.queue = [];
    this.nextWorkerIndex = 0;
  }

  run(data) {
    return new Promise((resolve, reject) => {
      const worker = this.workers[this.nextWorkerIndex];
      this.nextWorkerIndex = (this.nextWorkerIndex + 1) % this.workers.length;

      worker.once("message", resolve);
      worker.once("error", reject);
      worker.postMessage(data);
    });
  }
}

const pool = new WorkerPool("./worker.js", availableParallelism());
ApprocheCas d'usage
clusterMultiplier les instances d'un serveur HTTP entier (répartition des connexions)
worker_threadsDéporter un calcul CPU-bound précis (hash, image processing, parsing lourd)
Async/await simpleToute opération I/O-bound (déjà non-bloquante nativement)

Résumé

  • cluster fait tourner plusieurs processus Node identiques pour exploiter tous les cœurs CPU.
  • worker_threads isole un calcul lourd sur un thread séparé sans bloquer l'event loop principal.
  • Un pool de workers réutilisables évite le coût (non négligeable) de création d'un thread par tâche.
  • L'I/O (réseau, disque) n'a PAS besoin de worker threads : async/await suffit, c'est déjà non-bloquant.

Exercices pratiques

1 disponible
1

Mission : des rapports PDF qui ralentissent tout le monde, même après clustering

Objectif : Choisir et implémenter le bon outil (cluster vs worker_threads) selon la nature du problème, puis corriger un anti-pattern de création de worker par requête.

Contexte

L'équipe a déjà mis en place cluster avec un worker process par cœur CPU pour améliorer le débit global. Pourtant, dès que deux utilisateurs génèrent un rapport PDF (calcul CPU-intensif de mise en page, environ 3 secondes) sur le MÊME worker process, toutes les autres requêtes traitées par CE worker précis ralentissent, alors que les autres workers du cluster restent parfaitement réactifs. Un développeur propose de résoudre ça en créant new Worker('./pdf-worker.js') à l'intérieur même du handler de route, à chaque requête POST /reports.

Résoudre l’exercice →