Utiliser les worker threads pour les tâches CPU intensives
Node.js convient particulièrement aux applications dominées par les opérations d’entrée-sortie : requêtes HTTP, accès à une base de données ou appels à des services externes. Pendant ces attentes, sa boucle d’événements peut traiter de nombreuses requêtes sans bloquer. Cette efficacité dépend toutefois d’un équilibre : un calcul JavaScript trop long occupe le fil principal et gèle les réponses.
Pour un traitement CPU intensif, le problème n’est donc pas l’attente, mais la consommation de processeur. Analyser un très grand fichier, compresser des images ou calculer une empreinte de contrôle ne devient pas asynchrone simplement parce que la fonction renvoie une promesse. Ces opérations doivent être déplacées hors du chemin qui reçoit les requêtes.
Le module worker_threads du runtime Node.js crée des threads de travail JavaScript isolés, dotés de leur propre contexte V8. Une requête peut continuer à être traitée pendant qu’un worker effectue un calcul lourd. Ce parallélisme reste limité par le nombre de cœurs disponibles et exige une stratégie pour transmettre les données sans gaspiller de mémoire.
La bonne pratique consiste à découper le travail en unités indépendantes, à les distribuer et à contrôler leur durée. Les workers sont particulièrement utiles dans un serveur web, mais un microservice dédié peut être préférable lorsque la charge doit être isolée, déployée ou mise à l’échelle séparément.
Comprendre la boucle d’événements et l’isolation des calculs
Le rôle de la boucle d’événements est de coordonner les opérations asynchrones, pas de paralléliser le code. Une tâche liée au processeur verrouille l’isolate du fil principal, même si la fonction est déclarée asynchrone. worker_threads répond à cette limite en exécutant les calculs longs dans un contexte séparé.
Les workers échangent avec le fil principal des messages structurés. Pour approfondir les modules natifs de Node.js, les ressources de la plateforme DéveloppeurWeb.Com constituent un point de départ utile. Un ArrayBuffer placé dans une liste de transfert peut éviter une copie, alors qu’une sérialisation ordinaire duplique les données envoyées.
Contrairement à un processus enfant, un worker appartient au même processus. Il ne partage pas directement les objets JavaScript avec le fil principal, ce qui évite les courses sur des variables ordinaires. Les buffers explicitement partagés restent possibles, à condition d’utiliser les opérations atomiques appropriées.
Distribuer les tâches avec une file d’attente
Une file d’attente relie les tâches aux workers disponibles. Le principe d’une file sécurisée entre threads, illustré en Python, aide à distinguer l’ordre des messages de la synchronisation mémoire. En JavaScript, un identifiant par tâche permet de retrouver la promesse correspondante.
Voici un worker qui additionne les nombres premiers jusqu’à une limite reçue :
import { parentPort } from 'node:worker_threads';
function isPrime(value) {
if (value < 2) return false;
for (let divisor = 2; divisor * divisor <= value; divisor += 1) {
if (value % divisor === 0) return false;
}
return true;
}
parentPort.on('message', ({ limit }) => {
let total = 0;
for (let value = 2; value <= limit; value += 1) {
if (isPrime(value)) total += value;
}
parentPort.postMessage(total);
});
Dans un module ESM, le fil principal envoie la limite et attend le résultat :
import { Worker } from 'node:worker_threads';
const worker = new Worker(new URL('./worker.mjs', import.meta.url));
const total = await new Promise((resolve, reject) => {
worker.once('message', resolve);
worker.once('error', reject);
worker.postMessage({ limit: 1_000_000 });
});
console.log(total);
await worker.terminate();
Dans un service réel, ce worker serait réutilisé afin d’éviter le coût de création. Un pool maintient un nombre borné d’instances, place les demandes en attente et les distribue dès qu’un worker se libère. Les erreurs, les délais d’expiration et l’arrêt du processus doivent également être gérés, sans créer indéfiniment de workers sous une forte affluence.
Mesurer les gains et contrôler les échanges
Le seul passage à un worker ne garantit pas une amélioration. Il faut mesurer le temps total, le débit, la latence de la boucle d’événements et l’occupation mémoire avant et après la modification. Les outils perf_hooks, les tests de charge et un suivi des événements retardés révèlent rapidement les gains comme les nouveaux goulots d’étranglement.
Les chaînes de publication statiques, comme Eleventy et le Jamstack, peuvent elles aussi saturer un serveur pendant la génération des pages. Traiter les étapes lourdes dans des workers protège les requêtes de contrôle et les éventuelles requêtes d’API utilisées par le front-end.
Pour les volumes importants, le passage de messages peut lui-même devenir coûteux. Le paramètre transferList de postMessage permet de transférer un ArrayBuffer sans le copier. Un SharedArrayBuffer permet en revanche un accès mémoire concurrent, à condition de synchroniser les lectures et les écritures avec Atomics. Cette seconde approche exige une conception rigoureuse, car plusieurs workers peuvent modifier les mêmes octets.
Choisir l’architecture selon la nature de la charge
Le choix de l’architecture dépend du type de charge, de son volume et du besoin d’isolation. Les workers conviennent lorsque :
- Des calculs bloquants doivent quitter la boucle d’événements.
- Les tâches peuvent être réparties entre plusieurs cœurs physiques.
- Les mêmes traitements se répètent au sein d’un processus Node.js.
Un worker ne remplace pas un service. Il ne dispose ni de son propre déploiement, ni de sa base de données, ni d’un cycle de vie indépendant. D’autres architectures sont préférables lorsque :
- Une isolation mémoire ou une sécurité renforcée impose un processus enfant.
- La répartition des connexions HTTP nécessite le cluster de Node.js.
- Le déploiement doit évoluer indépendamment du reste de l’application.
La règle consiste à commencer par mesurer sur une machine proche de la production. Une petite quantité de workers, définie à partir de availableParallelism et de la charge réelle, laisse généralement au processus principal la responsabilité du réseau et des autres entrées-sorties.
Déployer une solution robuste en production
Pour un déploiement durable, dimensionnez le pool selon la mémoire disponible, le nombre de cœurs et le temps maximal acceptable pour une tâche. Dans un conteneur, les limites de mémoire et de CPU doivent être respectées. Une erreur dans un worker doit être journalisée, associée à sa tâche et propagée au demandeur sans exposer de détails sensibles.
La surveillance de la file, du temps d’attente et du taux d’erreur facilite le diagnostic. Les métriques de la boucle d’événements complètent cette vue. Sur Linux, des ressources comme le podcast Ubuntu éclairent l’outillage open source et l’exploitation des serveurs.
Commencez par extraire un calcul CPU réel de votre application, exécutez-le dans un worker et comparez le débit avant et après. Ajoutez ensuite une file bornée, un délai d’expiration et un arrêt propre. Déployez cette version mesurée, documentez son comportement et surveillez ses métriques sous charge réelle.