Retour au cours

frontend / javascript

Web Workers : parallélisme dans le navigateur

Leçon 251 exercice

Explication

Ce que vous allez apprendre

  • Expliquer pourquoi un calcul long bloque l'interface tant qu'il reste sur le thread principal
  • Créer un Web Worker et communiquer avec lui via postMessage/onmessage
  • Identifier ce qu'un Worker ne peut jamais faire (accès DOM, variables du script principal)
  • Choisir entre un clonage classique et une liste de transfert pour de gros volumes de données
  • Décider quand un calcul mérite réellement d'être déporté dans un Worker

Dans quel contexte ?

Une application de traitement d'images fige complètement l'interface pendant 3 secondes chaque fois qu'un utilisateur applique un filtre sur une photo haute résolution : impossible de cliquer, de scroller, la page semble plantée. Le calcul du filtre tourne directement sur le thread principal, qui est aussi celui qui gère l'affichage. En déplaçant ce calcul dans un Web Worker, l'interface reste réactive pendant le traitement, et le résultat est renvoyé au thread principal via postMessage une fois terminé.

1. Une contrainte qui revient depuis le début du cours

JavaScript s'exécute sur un seul thread : un calcul long BLOQUE littéralement tout le reste, y compris le rendu visuel de la page, qui se fige pendant toute sa durée. Les Web Workers offrent une vraie échappatoire à cette limite.

Prérequis

Cette leçon suppose l'event loop mono-thread (leçon 13) bien compris : un Web Worker n'est pas une astuce de syntaxe, mais un vrai thread séparé du système d'exploitation, ce qui explique à la fois sa puissance et ses limitations.

2. Un thread séparé, mais isolé

Un Worker exécute un script JavaScript dans un thread complètement séparé, en parallèle réel. La contrepartie : il n'a aucun accès au DOM, ni aux variables du script principal.

AspectThread principalWeb Worker
Accès au DOMOuiNon
Accès aux variables du script principalOuiNon (communication par messages uniquement)
Bloqué par un calcul longOui (fige l'interface)Non (le principal reste réactif)

3. Comment les deux threads communiquent

La communication passe exclusivement par messages (postMessage/onmessage), et les données échangées sont, par défaut, clonées plutôt que partagées en mémoire.

4. Il reste un problème : le coût du clonage

Ce clonage devient coûteux pour de gros volumes de données binaires. La liste de transfert (postMessage(data, [buffer])) résout ce problème en déplaçant la PROPRIÉTÉ du buffer plutôt que de le copier.

Piège fréquent

Après un transfert via liste de transfert, le buffer d'origine devient inutilisable côté expéditeur (sa taille passe à 0). Ne transférez un buffer que si vous êtes certain de ne plus en avoir besoin de ce côté-là.

5. La contrepartie de ce transfert

Ce transfert est à coût de copie nul, mais l'expéditeur perd immédiatement l'accès à ces données une fois transférées : il faut donc être sûr de ne plus en avoir besoin.

Un Worker convient parfaitement pour un calcul CPU-intensif ponctuel qui bloquerait sinon l'interface pendant plusieurs centaines de millisecondes. Cette leçon prépare la suivante, consacrée aux Service Workers, qui utilisent une infrastructure similaire, mais dans un but différent : intercepter le réseau plutôt que paralléliser un calcul.

Commandes & code

Web Workers

Exécuter du JavaScript CPU-intensif sur un thread séparé, sans geler l'interface.

js
// --- Probleme : JS est mono-thread, un calcul lourd BLOQUE tout le rendu de la page ---
function calculLourdBloquant(n) {
    let total = 0;
    for (let i = 0; i < n; i++) total += Math.sqrt(i);
    return total;
}
// calculLourdBloquant(1_000_000_000);   // gele l'UI pendant tout le calcul

// --- fichier worker.js : execute dans un thread SEPARE, sans acces au DOM ---
// self.onmessage = (event) => {
//     const { n } = event.data;
//     let total = 0;
//     for (let i = 0; i < n; i++) total += Math.sqrt(i);
//     self.postMessage({ resultat: total });
// };

// --- Cote page principale : creation et communication ---
const worker = new Worker("worker.js");

worker.postMessage({ n: 1_000_000_000 });    // envoie des donnees (clonees, pas partagees)

worker.onmessage = (event) => {
    console.log("Resultat recu du worker :", event.data.resultat);
};

worker.onerror = (erreur) => {
    console.error(`Erreur dans le worker : ${erreur.message} (${erreur.filename}:${erreur.lineno})`);
};

// La page reste totalement reactive pendant que le worker calcule en arriere-plan
console.log("L'UI continue de repondre immediatement");

// --- Terminer un worker devenu inutile (libere ses ressources) ---
function nettoyer() {
    worker.terminate();
}

// --- Worker avec Promise : pattern pour une API request/response propre ---
function creerWorkerAvecPromise(url) {
    const w = new Worker(url);
    let prochainId = 0;
    const requetesEnAttente = new Map();

    w.onmessage = (event) => {
        const { id, resultat, erreur } = event.data;
        const { resolve, reject } = requetesEnAttente.get(id);
        requetesEnAttente.delete(id);
        if (erreur) reject(new Error(erreur));
        else resolve(resultat);
    };

    return function appeler(donnees) {
        const id = prochainId++;
        return new Promise((resolve, reject) => {
            requetesEnAttente.set(id, { resolve, reject });
            w.postMessage({ id, ...donnees });
        });
    };
}
// const calculer = creerWorkerAvecPromise("worker.js");
// const resultat = await calculer({ n: 1_000_000 });

// --- Transferable objects : deplacer un ArrayBuffer SANS LE COPIER (zero-copy) ---
const gros_buffer = new ArrayBuffer(100 * 1024 * 1024);    // 100 Mo
// Par defaut, postMessage CLONE les donnees (structured clone) -- couteux pour un gros buffer.
// worker.postMessage({ buffer: gros_buffer });             // copie complete des 100 Mo

// Avec la liste de transfert : le buffer change de PROPRIETAIRE, aucune copie
worker.postMessage({ buffer: gros_buffer }, [gros_buffer]);
console.log(gros_buffer.byteLength);    // 0 -- le buffer est maintenant "neutre" (detache) cote appelant

// --- SharedArrayBuffer : memoire REELLEMENT partagee entre le thread principal et un worker ---
// (necessite les headers COOP/COEP pour des raisons de securite, cote serveur)
// const sab = new SharedArrayBuffer(1024);
// const vue = new Int32Array(sab);
// Atomics.add(vue, 0, 1);       // operation atomique, evite les races entre threads

// --- Pool de workers : repartir des taches sur plusieurs threads (imite un thread pool) ---
class PoolDeWorkers {
    #workers;
    #index = 0;

    constructor(url, taille = navigator.hardwareConcurrency || 4) {
        this.#workers = Array.from({ length: taille }, () => new Worker(url));
    }

    executer(donnees) {
        const w = this.#workers[this.#index];
        this.#index = (this.#index + 1) % this.#workers.length;   // round-robin
        return new Promise((resolve) => {
            w.onmessage = (e) => resolve(e.data);
            w.postMessage(donnees);
        });
    }

    detruire() {
        this.#workers.forEach((w) => w.terminate());
    }
}

// --- Ce qu'un Worker NE PEUT PAS faire ---
// Pas d'acces au DOM (document, window), mais acces a : fetch, setTimeout, IndexedDB,
// et importScripts() pour charger des scripts additionnels dans le worker.

Résumé

  • Un Worker exécute du JS sur un thread séparé : l'UI reste réactive pendant un calcul CPU-intensif.
  • La communication passe par postMessage/onmessage avec copie des données (structured clone), pas de mémoire partagée par défaut.
  • La liste de transfert (postMessage(data, [buffer])) déplace un ArrayBuffer sans le copier, mais le rend inutilisable côté expéditeur.
  • Un Worker n'a pas accès au DOM ; SharedArrayBuffer + Atomics permet une vraie mémoire partagée quand c'est nécessaire.

Exercices pratiques

1 disponible
1

Mission : réparer un Worker de traitement d'image

Objectif : Corriger un Worker qui plante à cause d'un accès DOM interdit, et optimiser le transfert d'un gros buffer de pixels.

Contexte

Un développeur écrit un Worker de filtre d'image. Dans worker.js, il lit une valeur de configuration avec document.querySelector("#intensite").value. Côté page principale, il envoie le buffer de pixels avec :

js
const worker = new Worker("worker.js");
const bufferPixels = new ArrayBuffer(50 * 1024 * 1024); // 50 Mo
worker.postMessage({ buffer: bufferPixels });

Le worker plante immédiatement avec une erreur document is not defined, et l'envoi du buffer prend, en plus, plusieurs centaines de millisecondes à lui seul.

Résoudre l’exercice →