</> HTML5Advent
ENFRESDEITPT

// apis · Web Platform Advent #24

Che cos'è un Web Worker? Un secondo thread, e la copia che decide se conviene

Un Web Worker esegue il tuo script su un thread separato, così il lavoro pesante smette di bloccare l'interfaccia. A che cosa ha accesso e a che cosa no, perché postMessage copia invece di condividere, come trasferire un ArrayBuffer evita quella copia, e in che cosa differisce da un Service Worker.

Una superstrada vista dall'alto, divisa da uno spartitraffico centrale, con una sola auto scura sulla carreggiata di destra

JavaScript gira su un solo thread, e quel thread gestisce anche clic, scorrimento e rendering. Un calcolo lungo lì non rallenta la pagina, la ferma. Un Web Worker è la via d'uscita standard: uno script che gira su un thread separato e fa il lavoro costoso mentre l'interfaccia resta reattiva.

Che cos'è davvero un worker

Punti un worker verso un file di script. Avvia il proprio contesto JavaScript, con il suo ambito globale e il suo ciclo di eventi, e resta in esecuzione finché non lo termini o la pagina non sparisce.

// main.js
const worker = new Worker('./heavy.js', { type: 'module' });

worker.postMessage({ rows: 250000 });

worker.onmessage = (event) => {
  render(event.data.total);
};

worker.onerror = (event) => console.error(event.message);

Dentro il worker l'oggetto globale è self e non window, e la forma è la stessa da entrambe le parti: ascoltare i messaggi, fare il lavoro, rimandare il risultato.

// heavy.js
self.onmessage = (event) => {
  let total = 0;
  for (let i = 0; i < event.data.rows; i++) total += compute(i);
  self.postMessage({ total });
};

Niente di tutto questo è asincrono dentro il worker. Il ciclo blocca esattamente come bloccherebbe altrove; blocca solo un thread che nessuno sta guardando.

Niente DOM, ed è tutto il progetto

Un worker non ha window, non ha document e non ha alcun modo di toccare un elemento. Non è una dimenticanza. Il DOM non è pensato per essere manipolato da più thread, e darne accesso a due thread produrrebbe esattamente le corse critiche che la piattaforma vuole evitare.

Quello che ottiene, invece, è quasi tutto il resto: fetch, WebSocket, IndexedDB, i timer, crypto e importScripts per i worker classici. Un worker può quindi scaricare, analizzare, calcolare e memorizzare. Semplicemente non può mostrare nulla, ed è per questo che ogni risultato deve tornare al thread principale per essere reso.

Un cuoco impiatta gamberi e verdure su un vassoio in una cucina in acciaio inox. Un worker è un secondo paio di mani: prepara in parallelo e restituisce il risultato, ma non serve mai il tavolo di persona.
Un cuoco impiatta gamberi e verdure su un vassoio in una cucina in acciaio inox. Un worker è un secondo paio di mani: prepara in parallelo e restituisce il risultato, ma non serve mai il tavolo di persona.

Parlargli: postMessage copia, il trasferimento sposta

I messaggi non sono riferimenti condivisi. Il valore inviato viene duplicato dall'algoritmo di clonazione strutturata, lo stesso usato da IndexedDB, e le due parti finiscono con copie indipendenti. Funzioni e nodi del DOM non sono clonabili e sollevano un errore.

Quella copia è il costo nascosto, ed è dove il codice ingenuo perde. Spedire sessanta megabyte di array tipizzato significa serializzare e duplicare sessanta megabyte, il che può facilmente superare il tempo che il calcolo ha fatto risparmiare.

La via d'uscita è trasferire anziché copiare. Passa il buffer come secondo argomento e la sua proprietà si sposta al worker invece di essere duplicata; chi invia resta con un buffer vuoto, ed è proprio quello lo scopo.

const buffer = new ArrayBuffer(64 * 1024 * 1024);

// Copied: the main thread keeps its own version.
worker.postMessage(buffer);

// Transferred: ownership moves, nothing is copied,
// and buffer.byteLength becomes 0 on this side.
worker.postMessage(buffer, [buffer]);

I trasferibili spiegano perché l'elaborazione di immagini e l'audio funzionano bene nei worker: i pixel si spostano una volta, non vengono copiati due volte.

Web Worker, Shared Worker, Service Worker

Tre cose portano la parola worker senza essere intercambiabili, ed è lì che sta gran parte della confusione.

Un worker dedicato, quello descritto qui, appartiene alla sola pagina che lo ha creato e muore con essa. Uno shared worker è raggiungibile da più pagine della stessa origine, utile per condividere una connessione fra schede. Un service worker è tutt'altra cosa: si mette fra la pagina e la rete come un proxy, funziona a eventi, il browser lo avvia e lo ferma a piacere, ed è ciò che rende possibili il comportamento offline e le notifiche push. Lo trattiamo a parte in che cos'è un service worker.

La regola breve: un web worker per togliere calcolo dal thread principale, un service worker per controllare che cosa succede alle richieste di rete. Prendere un service worker per accelerare un calcolo è un errore di categoria.

Quando conviene e quando no

Un worker si ripaga quando il lavoro è davvero pesante e i dati che attraversano il confine sono piccoli in confronto. Analizzare un CSV grande, indicizzare testo per una ricerca, comprimere, calcolare hash, decodificare immagini: tutto questo calcola molto e restituisce poco.

Non si ripaga quando l'attività è piccola, perché avviare un worker significa avviare un contesto JavaScript e non è gratis, né quando i dati sono enormi e il calcolo banale, perché il risparmio se ne andrà nella copia. Misura prima di supporre: il costo del confine è la parte che si dimentica.

Se sei qui per un punteggio di performance e non per una funzionalità lenta precisa, nota che questa è esattamente la leva dietro lo spostare lavoro fuori dal thread principale, cosa che il ciclo di eventi rende inevitabile in partenza.