Accueil / Articles

Partage de la mémoire entre les threads workers de Node.js à l’aide de SharedArrayBuffer et d’Atomics

Pourquoi postMessage fournit à chaque travailleur Node.js une copie privée, alors que SharedArrayBuffer serait plus approprié, et comment Atomics empêche les mises à jour perdues et permet l’attente entre threads.

Éditeur : REACTAPP.TOP7 min de lecture1628 mots

Sources: The Day I Learned JavaScript Threads Don’t Actually Share Memory

Les threads de travail de Node.js sont des threads réels du système d’exploitation, chacun disposant de sa propre instance du moteur JavaScript. Cela incite à penser qu’ils partagent la mémoire comme le font les threads en C ou en Java, mais cette supposition se révèle fausse dès que deux threads de travail doivent coordonner leurs actions sur les mêmes données. Cet article montre ce que fait réellement postMessage avec vos valeurs, quand un véritable partage de mémoire est justifié, et comment Atomics assure la correction des mises à jour concurrentes.

Si vous êtes nouveau en matière de threads de travail en général, commencez par le transfert de tâches gourmandes en CPU dans Node.js à l’aide de threads de travail et de pools ; ici, l’attention se porte sur un aspect plus spécifique : ce qui arrive à la mémoire une fois qu’un thread de travail existe.

postMessage envoie une copie, pas une référence

L’expérience commence par le thread principal qui crée un travailleur à partir d’un fichier distinct.

// main.js
const { Worker } = require("worker_threads");
const worker = new Worker("./worker.js");

Il envoie ensuite un tableau ordinaire au travailleur et, un instant plus tard, enregistre sa propre copie de ce tableau.

const sharedLookingArray = [1, 2, 3];
worker.postMessage(sharedLookingArray);setTimeout(() => {
  console.log("Main thread's array:", sharedLookingArray); // still [1, 2, 3]
}, 100);

De son côté, le travailleur reçoit le tableau, y ajoute un élément et enregistre le résultat.

// worker.js
const { parentPort } = require("worker_threads");
parentPort.on("message", (arr) => {
  arr.push(4);
  console.log("Worker's array:", arr); // [1, 2, 3, 4]
});

Le travailleur affiche [1, 2, 3, 4], tandis que le thread principal affiche toujours [1, 2, 3] quel que soit le temps d’attente. La raison en est que postMessage fait passer la valeur par l’algorithme de clonage structuré, le même mécanisme exposé globalement sous le nom de structuredClone. Les données sont sérialisées du côté émetteur et reconstituées en tant qu’objet indépendant du côté récepteur, de sorte qu’après l’appel il y a deux tableaux non liés entre eux, un par thread.

C’est un paramètre par défaut délibéré et sûr. Deux threads ne peuvent pas accéder simultanément à de la mémoire qui ne leur a jamais été attribuée à tous les deux. Le coût réside dans la copie elle-même, qui augmente avec la taille des données transmises et est engagée pour chaque message. (Pour de grandes quantités de données binaires, Node peut également transférer un ArrayBuffer vers l’autre thread en tant qu’objet transférable, ce qui évite la copie mais empêche l’expéditeur d’utiliser ces données ; il s’agit alors d’un transfert, et non d’un partage.)

Lorsque la mémoire partagée est l’outil adapté

La copie devient un mauvais compromis lorsque les threads doivent coordonner leurs actions sur les mêmes données à haute fréquence, au point que l’envoi et la réception de ces données coûteraient plus cher que le travail à paralléliser. Dans ce cas, JavaScript propose SharedArrayBuffer : un bloc de mémoire brut auquel plusieurs threads peuvent se référer directement, sans aucune copie nécessaire.

Le thread principal charge à nouveau le module worker :

// main.js
const { Worker } = require("worker_threads");

Ensuite, il alloue un buffer partagé de quatre octets, suffisant pour un entier 32 bits, l’enveloppe dans une vue Int32Array et transmet le buffer au travailleur via workerData.

const sharedBuffer = new SharedArrayBuffer(4); // 4 bytes, one 32-bit integer
const sharedCounter = new Int32Array(sharedBuffer);const worker = new Worker("./worker.js", { workerData: { sharedBuffer } });

Le travailleur crée sa propre vue Int32Array sur ce même buffer :

// worker.js
const { workerData } = require("worker_threads");
const sharedCounter = new Int32Array(workerData.sharedBuffer);

Dès lors, toute écriture effectuée à travers l’une des vues est visible depuis l’autre, car les deux pointent vers les mêmes octets.

sharedCounter[0] += 1; // this thread and the main thread are now touching the same memory

Cela représente une véritable rupture avec le comportement habituel de la concurrence en JavaScript. Le code d’un même domaine s’exécute sur un seul thread, de sorte que les conflits asynchrones familiers, où deux fonctions async s’enchaînent dans un ordre surprenant, ne sont en réalité que le même thread qui s’intercale à des moments bien définis. Avec un SharedArrayBuffer, deux threads peuvent lire et écrire dans la même mémoire littéralement au même instant.

La mise à jour perdue qui est désormais possible

Considérons la manière évidente d’incrémenter le compteur partagé :

// naive, and genuinely unsafe with real shared memory
sharedCounter[0] = sharedCounter[0] + 1;

Cela semble être une seule opération, mais en réalité il s’agit de trois : lire la valeur actuelle, ajouter un, puis écrire le résultat. Si un autre thread lit le compteur entre la lecture de ce thread et son écriture, les deux calculent la même nouvelle valeur et un incrément disparaît. Rien n’est émis en erreur ni enregistré ; le nombre final est simplement trop bas. Comme le résultat dépend du timing exact des deux threads du système d’exploitation, ce bug peut apparaître une fois sur mille exécutions et disparaître lorsque l’on ajoute des enregistrements.

Atomes : opérations indivisibles sur la mémoire partagée

L’objet Atomics fournit des opérations sur des tableaux entiers partagés qui s’exécutent en tant qu’unité unique. Aucun autre thread ne peut observer ou interférer avec une opération atomique en cours d’exécution.

Atomics.add(sharedCounter, 0, 1); // read, increment, and write, as one indivisible step

Si chaque thread incrémente avec Atomics.add au lieu d’utiliser la séquence lecture-modification-écriture, aucune mise à jour ne peut être perdue, car le moteur garantit que d’autres opérations atomiques sur cet élément ne peuvent pas s’intercaler dans celle-ci. Cette garantie n’est valable que si tous les écrivains utilisent des opérations atomiques ; un seul thread qui continue d’utiliser une incrémentation classique réintroduit le risque de concurrence. Tout mécanisme plus complexe que l’on pourrait construire sur une mémoire partagée, qu’il s’agisse de compteurs, de verrous ou de files d’attente, est finalement composé d’opérations comme celle-ci, qui sont atomiques au niveau matériel et non simplement « généralement fiables ».

Atomics.wait et Atomics.notify : blocage entre threads

Atomics offre également quelque chose que JavaScript ne possède pas : un moyen pour un thread d’arrêter complètement son exécution jusqu’à ce qu’un autre thread le réveille. Dans le worker, Atomics.wait bloque tant que le compteur vaut encore 0:

// worker.js — waits for main thread to signal it's time to proceed
Atomics.wait(sharedCounter, 0, 0); // blocks this thread while sharedCounter[0] === 0
console.log("Signaled to continue, value is now:", sharedCounter[0]);

Le thread principal enregistre une nouvelle valeur puis réveille un thread en attente :

// main.js — does the signaling
Atomics.store(sharedCounter, 0, 1);
Atomics.notify(sharedCounter, 0, 1); // wakes up one waiting thread

Il est important d’enregistrer la valeur avant de notifier. Atomics.wait vérifie d’abord la valeur et retourne immédiatement si elle ne correspond plus à celle attendue, de sorte qu’un signal envoyé avant que le worker ne commence à attendre n’est pas perdu.

C’est une véritable appel bloquant, ce qui est inhabituel en JavaScript, où le travail asynchrone s’effectue normalement via des appels de retour dans la boucle d’événements. Bloquer le thread qui exécute la boucle d’événements gelerait tout ce que ce thread gère, c’est pourquoi les navigateurs n’autorisent pas Atomics.wait sur le thread principal et pourquoi, en général, il doit être utilisé dans des threads d’ouvrier. (Les navigateurs exigent également que la page soit isolée entre origines avant même que SharedArrayBuffer ne soit disponible ; Node n’a pas de telle restriction.)

Une utilisation réaliste : suivi en temps réel à travers un pool de threads d’ouvrier

Ce cas montre bien comment la complexité ajoutée s’avère bénéfique. Plusieurs travailleurs gourmands en ressources CPU traitent chacun un morceau d’un grand ensemble de données, et le thread principal souhaite disposer d’un compteur de progression en temps réel sans que chaque travailleur doive envoyer un message pour chaque élément. Le thread principal crée donc un compteur partagé et le transmet à chaque travailleur accompagné de son morceau de données :

// main.js
const progress = new Int32Array(new SharedArrayBuffer(4));
const workers = chunks.map((chunk) =>
  new Worker("./process-chunk.js", { workerData: { chunk, progressBuffer: progress.buffer } })
);

Il échantillonne ensuite ce compteur toutes les demi-seconde à l’aide de Atomics.load, ce qui garantit qu’il lit une valeur complète et à jour :

setInterval(() => {
  console.log(`Processed ${Atomics.load(progress, 0)} items so far`);
}, 500);

Chaque travailleur encapsule le buffer partagé dans sa propre vue :

// process-chunk.js
const { workerData } = require("worker_threads");
const progress = new Int32Array(workerData.progressBuffer);

Pendant le traitement des éléments, il incrémente atomiquement le compteur partagé :

for (const item of workerData.chunk) {
  processItem(item);
  Atomics.add(progress, 0, 1); // every worker safely increments the same shared counter
}

Tous les travailleurs écrivent directement dans ces quatre octets identiques, il n’y a pas de surcoût lié à des messages par élément, et le thread principal peut lire un total précis à tout moment sans devoir demander à quiconque de faire un rapport. Ce type de problème, caractérisé par une coordination fréquente et étroite entre des tâches CPU véritablement parallèles, justifie l’utilisation de la mémoire partagée plutôt que du copié-collé.

La plupart des codes ne devraient pas recourir à cela

Le code typique backend Node a rarement besoin de ces mécanismes. La plupart des opérations concurrentes dans un serveur API sont liées aux entrées/sorties, ce que le cycle d’événements à thread unique gère déjà très bien. Pour les tâches gourmandes en CPU qui surviennent, telles que le traitement d’images, la transformation de données ou la cryptographie, des threads de travail utilisant simplement postMessage pour le copié-collé suffisent généralement.

SharedArrayBuffer et Atomics sont destinés à des cas plus spécifiques : une coordination à haute fréquence entre threads parallèles, où la copie à chaque échange deviendrait elle-même un goulot d’étranglement. Adopter de la mémoire partagée avant d’avoir mesuré ce problème ne rend pas le code plus rapide ; cela introduit simplement des concurrences au niveau du matériel que le modèle de copie était conçu pour éviter.

Points clés

  • postMessage crée des clones structurés des données, de sorte que chaque thread travaille sur sa propre copie et que les modifications ne se transmettent jamais entre elles.
  • SharedArrayBuffer permet à plusieurs threads d’accéder directement aux mêmes octets via des vues de type tableau.
  • Une simple opération x = x + 1 sur de la mémoire partagée peut faire perdre silencieusement des mises à jour ; utilisez Atomics.add et ses fonctions associées pour chaque écriture.
  • Atomics.wait bloque réellement une thread et doit être utilisé dans les workers ; l’associez à Atomics.store et Atomics.notify.
  • N’ayez recours à la mémoire partagée qu’après avoir constaté que la copie des messages constitue le goulot d’étranglement.