13 - Workers et mémoire partagee
Ce que tu vas apprendre
- Comment les Web Workers isolent la mémoire (heap séparé)
- postMessage et le structured clone (copie des donnees)
- Les transferable objects (zero-copy pour les ArrayBuffer)
- SharedArrayBuffer + Atomics (vraie mémoire partagee, vrais race conditions)
- Worker Threads en Node.js
- Quand utiliser des Workers et le coût mémoire par Worker
Prerequisites
Avoir lu l'article 12 sur ArrayBuffer et TypedArrays.
JavaScript est single-threaded. C'est a la fois sa force (pas de deadlocks, pas de mutex, pas de race conditions) et sa faiblesse (un calcul lourd bloque tout). Les Workers resolvent ca en creant des threads separees, chacune avec son propre heap. Mais des qu'on parle de threads, la question mémoire devient interessante.
Web Workers : un heap par thread
Chaque Worker a son propre environnement d'exécution. Son propre heap, son propre GC, ses propres variables globales. Rien n'est partage par défaut.
typescript// main.ts
const worker = new Worker("worker.js");
worker.postMessage({ type: "process", data: bigArray });
worker.onmessage = (e) => {
console.log("Resultat:", e.data);
};
typescript// worker.ts
self.onmessage = (e) => {
const { type, data } = e.data;
if (type === "process") {
const result = heavyComputation(data);
self.postMessage(result);
}
};
Le bigArray envoye via postMessage est copie. Le Worker recoit sa propre version des donnees. Modifier l'un ne modifie pas l'autre.
postMessage et le structured clone
postMessage utilise l'algorithme de structured clone pour sérialiser les donnees. C'est comme JSON.parse(JSON.stringify(...)) mais en mieux : ca supporte les Date, RegExp, Map, Set, ArrayBuffer, les références circulaires.
Le problème : c'est une copie. Si tu envoies un ArrayBuffer de 100 Mo a un Worker, tu utilises 200 Mo de mémoire (100 Mo dans le thread principal + 100 Mo dans le Worker).
typescript// 100 Mo copie -> 200 Mo au total
const buffer = new ArrayBuffer(100 * 1024 * 1024);
worker.postMessage(buffer);
// buffer est toujours utilisable ici (100 Mo)
// Le Worker a sa propre copie (100 Mo)
Pour des petits messages (quelques Ko), la copie est negligeable. Pour des gros blocs de donnees, c'est un problème.
Transferable objects : zero-copy
La solution : les transferable objects. Au lieu de copier, tu transferes la propriété du buffer au Worker. Le buffer devient inutilisable dans le thread d'origine.
typescriptconst buffer = new ArrayBuffer(100 * 1024 * 1024);
// Transferer le buffer (deuxieme argument = liste des transferables)
worker.postMessage(buffer, [buffer]);
// buffer.byteLength === 0 maintenant !
// Le buffer a ete "detache", il n'est plus utilisable ici
console.log(buffer.byteLength); // 0
Zero copie, zero surcharge mémoire. Le buffer passe directement d'un heap a l'autre. C'est quasi instantane, meme pour des buffers de plusieurs Go.
Les types transferables :
ArrayBufferMessagePortReadableStream,WritableStream,TransformStreamImageBitmapOffscreenCanvas
typescript// Pattern courant : envoyer et recuperer un buffer
// Main -> Worker
worker.postMessage({ type: "encode", buffer }, [buffer]);
// Worker -> Main (le Worker transfere le resultat)
self.onmessage = (e) => {
const result = processBuffer(e.data.buffer);
self.postMessage({ result }, [result]);
};
SharedArrayBuffer : vraie mémoire partagee
SharedArrayBuffer va plus loin : le meme bloc de mémoire est accessible depuis le thread principal et tous les Workers. Pas de copie, pas de transfert. Tout le monde lit et écrit au meme endroit.
typescript// Main
const shared = new SharedArrayBuffer(4096);
const view = new Int32Array(shared);
view[0] = 100;
// Envoyer (pas de copie, c'est le meme buffer)
worker.postMessage(shared);
// Worker
self.onmessage = (e) => {
const view = new Int32Array(e.data);
console.log(view[0]); // 100
view[0] = 200; // Visible immediatement depuis le main thread
};
Mais qui dit mémoire partagee dit race conditions. Si deux threads ecrivent au meme endroit en meme temps, le résultat est indefini.
Headers requis dans le navigateur
Apres les attaques Spectre en 2018, les navigateurs ont désactivé SharedArrayBuffer par défaut. Pour le reactiver, ton serveur doit envoyer deux headers :
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Sans ces headers, SharedArrayBuffer est undefined.
Atomics : opérations thread-safe
Le module Atomics fournit des opérations atomiques sur les SharedArrayBuffer. Ca garantit qu'une opération de lecture-écriture est indivisible.
typescriptconst shared = new SharedArrayBuffer(4);
const view = new Int32Array(shared);
// Au lieu de view[0]++ (non atomique, dangereux en multi-thread)
Atomics.add(view, 0, 1); // Atomique, thread-safe
// Lire de facon atomique
const value = Atomics.load(view, 0);
// Ecrire de facon atomique
Atomics.store(view, 0, 42);
// Compare-and-swap (CAS)
Atomics.compareExchange(view, 0, 42, 100);
// Si view[0] === 42, le remplacer par 100
Et pour la synchronisation entre threads :
typescript// Worker 1 : attendre que la valeur change
Atomics.wait(view, 0, 0); // Bloque tant que view[0] === 0
// Worker 2 : signaler
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1); // Reveille 1 thread en attente
Atomics.wait bloque le thread. C'est pourquoi ca ne fonctionne que dans les Workers, pas dans le thread principal (bloquer le main thread figerait l'UI).
Worker Threads en Node.js
En Node.js, c'est le module worker_threads :
typescriptimport { Worker, isMainThread, parentPort, workerData } from "worker_threads";
if (isMainThread) {
const shared = new SharedArrayBuffer(1024);
const worker = new Worker(new URL(import.meta.url), {
workerData: { shared },
});
worker.on("message", (msg) => {
console.log("Resultat:", msg);
});
} else {
const { shared } = workerData;
const view = new Int32Array(shared);
// Traitement...
parentPort?.postMessage("done");
}
Pas besoin de headers COOP/COEP en Node.js, SharedArrayBuffer est disponible directement.
Cout mémoire par Worker
Chaque Worker a un coût fixe. En Node.js, un Worker Thread consomme environ 2-5 Mo de mémoire de base (heap V8 + structures internes). Dans le navigateur, c'est similaire.
typescript// Mesurer la memoire d'un Worker en Node.js
import { Worker, ResourceLimits } from "worker_threads";
const worker = new Worker("./task.js", {
resourceLimits: {
maxOldGenerationSizeMb: 128, // Limiter le heap du Worker
maxYoungGenerationSizeMb: 32,
codeRangeSizeMb: 16,
},
});
Creer 100 Workers pour paralleliser un calcul, ca coûte 200-500 Mo rien que pour les heaps vides. C'est pour ca qu'on utilise des worker pools : un nombre fixe de Workers qui traitent les taches en file d'attente.
typescript// Avec la lib piscina (worker pool pour Node.js)
import Piscina from "piscina";
const pool = new Piscina({
filename: "./worker.js",
maxThreads: 4, // 4 Workers reutilises
});
const results = await Promise.all(
tasks.map((task) => pool.run(task))
);
Quand utiliser des Workers
Les Workers sont pertinents pour :
- Calcul CPU intensif : compression, crypto, parsing lourd, traitement d'image
- Taches bloquantes : opérations synchrones longues qui figeraient l'event loop
- Isolation : code tiers ou non fiable dans un environnement isole
Les Workers ne sont PAS utiles pour :
- Les appels HTTP (deja asynchrones)
- Les requêtes base de donnees (deja asynchrones)
- Les opérations I/O classiques (Node gere ca avec l'event loop)
Sur paltemps.fr, j'utilise un worker pool pour le redimensionnement d'images. C'est du pur calcul CPU, et le transférer dans un Worker évité de bloquer l'event loop du serveur. Les ArrayBuffer des images sont transferes (pas copies) vers le Worker, et le résultat est transféré en retour.
Résumé
- Chaque Worker a son propre heap, isole du thread principal
- postMessage copie les donnees (structured clone), coûteux pour les gros objets
- Les transferable objects (ArrayBuffer, etc.) evitent la copie (zero-copy)
- SharedArrayBuffer partage la mémoire entre threads, avec le risque de race conditions
- Atomics fournit des opérations thread-safe sur SharedArrayBuffer
- Les Workers coutent 2-5 Mo chacun, utiliser un worker pool pour limiter le nombre
- Reserver les Workers au calcul CPU intensif, pas aux I/O
Article précédent : 12 - ArrayBuffer et TypedArrays Article suivant : 14 - Streams et backpressure