Mémoire et performance JS/TS - 13 - Workers et mémoire partagee

Web Workers, postMessage, transferable objects, SharedArrayBuffer et Atomics. Gestion de la mémoire dans un contexte multi-thread JavaScript.

  1. 01 Mémoire et performance JS/TS - 00 - Pourquoi la mémoire compte meme avec un garbage collector
  2. 02 Mémoire et performance JS/TS - 01 - Stack vs Heap
  3. 03 Mémoire et performance JS/TS - 02 - Le cycle de vie de la mémoire
  4. 04 Mémoire et performance JS/TS - 03 - Le garbage collector
  5. 05 Mémoire et performance JS/TS - 04 - V8 en profondeur
  6. 06 Mémoire et performance JS/TS - 05 - Les 6 fuites mémoire classiques
  7. 07 Mémoire et performance JS/TS - 06 - Closures et mémoire
  8. 08 Mémoire et performance JS/TS - 07 - WeakRef, WeakMap et WeakSet
  9. 09 Mémoire et performance JS/TS - 08 - FinalizationRegistry : savoir quand le GC passe
  10. 10 Mémoire et performance JS/TS - 09 - DevTools Memory : investiguer dans Chrome
  11. 11 Mémoire et performance JS/TS - 10 - Profiling mémoire en Node.js
  12. 12 Mémoire et performance JS/TS - 11 - Détecter et corriger les fuites mémoire
  13. 13 Mémoire et performance JS/TS - 12 - ArrayBuffer et TypedArrays
  14. 14 Mémoire et performance JS/TS - 13 - Workers et mémoire partagee
  15. 15 Mémoire et performance JS/TS - 14 - Streams et backpressure
  16. 16 Mémoire et performance JS/TS - 15 - Fuites mémoire en React
  17. 17 Mémoire et performance JS/TS - 16 - Serveurs Node.js et mémoire
  18. 18 Mémoire et performance JS/TS - 17 - Mémoire et Docker
  19. 19 Mémoire et performance JS/TS - 18 - Optimisations mémoire
  20. 20 Mémoire et performance JS/TS - 19 - Comparaison avec d'autres langages
  21. 21 Mémoire et performance JS/TS - 20 - Tester la mémoire
  22. 22 Mémoire et performance JS/TS - 21 - Glossaire

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 :

  • ArrayBuffer
  • MessagePort
  • ReadableStream, WritableStream, TransformStream
  • ImageBitmap
  • OffscreenCanvas
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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.