Chiunque abbia lavorato seriamente con JavaScript conosce bene la sensazione di avere un'interfaccia che si blocca durante un'operazione pesante: un parsing di un file JSON da diversi megabyte, una trasformazione di immagini, un algoritmo di ricerca su strutture dati molto grandi. Il motivo riguarda il fatto che JavaScript nasce come linguaggio single-threaded, il che significa che tutto — rendering della pagina, gestione degli eventi, esecuzione del codice — avviene sullo stesso thread, il cosiddetto main thread. Quando questo thread è impegnato, l'utente non può fare nulla.
Per anni l'unica soluzione era frammentare il lavoro in piccoli pezzi usando setTimeout o requestAnimationFrame, cedendo il controllo al browser tra un pezzo e l'altro. Una tecnica valida, ma macchinosa e non sempre sufficiente. Poi sono arrivati i Web Workers, e con loro una forma autentica di parallelismo nel browser.
Come funziona il modello a thread singolo in JavaScript e perché è un problema
Per capire l'utilità dei Web Workers è utile avere chiaro cosa succede sotto il cofano. Il browser gestisce ogni scheda attraverso un processo che include un unico thread JavaScript. Questo thread esegue tutto ciò che riguarda la logica dell'applicazione: parsing dell'HTML, esecuzione degli script, gestione degli eventi del DOM, animazioni CSS controllate da JavaScript. Il motore V8 (o SpiderMonkey, o JavaScriptCore a seconda del browser) processa le istruzioni in sequenza, una alla volta.
L'event loop è il meccanismo che rende tutto questo tollerabile: quando un'operazione asincrona come una fetch o un setTimeout termina, il suo callback viene messo in coda e verrà eseguito non appena il thread è libero. Ma se il thread è bloccato da un calcolo sincrono che dura anche solo 200 millisecondi, nessun callback verrà processato, nessun evento del mouse risponderà, e il browser mostrerà una pagina congelata.
I Web Workers e JavaScript: come avere a disposizione un secondo thread
Un Web Worker è essenzialmente un thread separato che il browser mette a disposizione dello sviluppatore. Non ha accesso al DOM, non può toccare il window, ma può eseguire codice JavaScript arbitrario in parallelo rispetto al main thread. La comunicazione tra i due avviene tramite un sistema a messaggi: si usano postMessage per inviare dati e onmessage per riceverli.
I dati scambiati vengono serializzati e deserializzati (tramite l'algoritmo di structured clone), il che significa che ogni messaggio è una copia indipendente, non un riferimento condiviso.
Creare un worker è immediato. Si crea un file JavaScript separato che conterrà il codice da eseguire in background, e nel main thread si istanzia un oggetto Worker puntando a quel file.
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ tipo: 'calcola', dati: arrayDiNumeri });
worker.onmessage = function(evento) {
console.log('Risultato dal worker:', evento.data);
};
worker.onerror = function(errore) {
console.error('Errore nel worker:', errore.message);
};
// worker.js
self.onmessage = function(evento) {
const { tipo, dati } = evento.data;
if (tipo === 'calcola') {
const risultato = dati.reduce((acc, n) => acc + n, 0);
self.postMessage({ risultato });
}
};
In questo esempio il main thread invia un array di numeri al worker, che li somma e restituisce il risultato. Nel frattempo il main thread è completamente libero di gestire eventi, aggiornare l'interfaccia, rispondere ai click dell'utente. Il calcolo, per quanto lungo, non blocca nulla.
Trasferire dati invece di copiarli: i Transferable Objects
La serializzazione tramite structured clone ha un costo: per dati grandi, copiare un ArrayBuffer da diversi megabyte ad ogni messaggio può diventare un collo di bottiglia in sé. Il browser offre una soluzione più efficiente con i Transferable Objects: invece di copiare il buffer, ne viene trasferita la proprietà dal thread mittente al thread ricevente.
Il thread originale non può più accedere a quel buffer una volta trasferito. È un meccanismo simile alla mossa (move semantics) in linguaggi come Rust o C++.
// Creazione di un buffer grande
const buffer = new ArrayBuffer(1024 * 1024 * 10); // 10 MB
// Trasferimento senza copia: il secondo argomento lista i transferable
worker.postMessage({ buffer }, [buffer]);
// Dopo questa riga, buffer.byteLength === 0 nel main thread
// La proprietà è stata ceduta al worker
Questo pattern è particolarmente utile quando si lavora con immagini, audio, o qualsiasi struttura dati binaria di grandi dimensioni.
Conclusioni
In questa lezioni abbiamo analizzato alcune soluzioni ai limiti dati dall'architettura nativamente single-threaded di JavaScript, con particolare attenzione per i Web Workers. Nella prossima lezione ci spingeremo oltre parlando di SharedArrayBuffer e della memoria condivisa tra i thread.
Se vuoi aggiornamenti su JavaScript e il problema del modello a thread singolo inserisci la tua email nel box qui sotto: