Salta al contenuto principale

02 - Event Loop JavaScript: Come Funziona Davvero, Call Stack, Microtask e Macrotask

Perché una Promise batte setTimeout a 0 ms e un ciclo lungo congela Node.js: call stack, microtask e macrotask spiegati con programmi eseguiti.

Cosa ne pensi di questo articolo?

02 - Event Loop JavaScript: Come Funziona Davvero, Call Stack, Microtask e Macrotask
ConcurrencyFoto:Brett SaylesPexels

In Breve

Perché una Promise batte setTimeout a 0 ms e un ciclo lungo congela Node.js: call stack, microtask e macrotask spiegati con programmi eseguiti.

Concurrency 14 min

Perché un setTimeout da zero millisecondi parte dopo una Promise, perché un ciclo lungo congela un server intero e come cedere il thread senza riscrivere tutto: l'event loop spiegato con programmi eseguiti, non con schemi.

event loop javascript call stack microtask queue macrotask queue Promise vs setTimeout

Un solo thread, tante attese: dove sta il trucco

Pensa a un cameriere solo in una sala con quaranta tavoli. Non resta fermo davanti alla cucina ad aspettare che il primo piatto sia pronto: prende l'ordine, lo porta in cucina, passa al tavolo successivo e torna quando la cucina suona il campanello. JavaScript lavora così. Esegue una sola istruzione alla volta sul proprio thread, ma le attese lunghe (rete, disco, timer) non le passa sul thread: le affida al sistema operativo e riprende quando il risultato è pronto.

Il meccanismo che decide quando riprendere si chiama event loop. In Node.js lo implementa libuv, la libreria C che esegue le operazioni asincrone e le restituisce al motore V8. La documentazione ufficiale lo dice così: l'event loop permette a Node.js di fare I/O non bloccante nonostante il thread JavaScript sia uno solo, delegando le operazioni al kernel quando può. Ne discende la regola che regge l'intero articolo: se il tuo codice sincrono occupa il thread, nient'altro gira, nemmeno le risposte già pronte. Per il confronto con gli altri modelli di concorrenza vedi il primo articolo della serie.

I listati sono stati eseguiti con Node.js 24.11.0 su Linux; l'uscita sotto ciascuno è quella osservata. Dove l'ordine dipende dal carico della macchina lo dico espressamente.

Le quattro parti della macchina

Per ragionare sull'ordine di esecuzione bastano quattro oggetti. Ogni voce dell'elenco risponde a una domanda diversa: dove gira il codice, chi svolge il lavoro lento, chi riporta il risultato, e chi ha la precedenza.

  • Call stack: la pila delle funzioni in esecuzione. Una chiamata vi entra, il ritorno la toglie. Finché non è vuota il thread è occupato.
  • API dell'ambiente (browser o Node): setTimeout, fetch, fs.readFile. Il lavoro vero avviene fuori dal thread JavaScript.
  • Coda dei macrotask (nelle specifiche HTML: task queue): le callback di timer, eventi e I/O completati. Ne viene eseguita una per giro.
  • Coda dei microtask: le continuazioni delle Promise (.then, await) e queueMicrotask(). Viene svuotata per intero dopo ogni macrotask e dopo il codice sincrono.

Il criterio che tiene insieme le quattro voci è la priorità: lo stack ha la precedenza assoluta, i microtask passano prima dei macrotask, e i macrotask avanzano uno alla volta. La guida di MDN sui microtask lo formula così: dopo il task più vecchio della coda i microtask vengono eseguiti finché la coda è vuota, e solo allora il browser può aggiornare il rendering e passare al giro successivo. La conseguenza pratica è che una Promise già risolta supera sempre un timer, qualunque ritardo tu gli abbia dato.

L'ordine di esecuzione, sul caso più semplice

Il passo successivo è osservare la regola in un programma di otto righe. Mescola codice sincrono, un timer da zero millisecondi e una catena di due Promise. Prima di leggere l'uscita, prova a prevederla: la maggior parte delle persone mette il timer prima delle Promise perché «zero millisecondi significa subito».

console.log('1 - sincrono');

setTimeout(() => console.log('4 - macrotask'), 0);

Promise.resolve()
  .then(() => console.log('2 - microtask'))
  .then(() => console.log('3 - microtask, concatenata'));

console.log('1b - sincrono');

// Output:
// 1 - sincrono
// 1b - sincrono
// 2 - microtask
// 3 - microtask, concatenata
// 4 - macrotask

Le due righe sincrone escono per prime perché lo stack va svuotato prima di ogni altra cosa. Poi tocca ai microtask: la seconda .then viene accodata solo quando la prima ha finito, ma il motore continua a svuotare la coda finché è vuota, quindi anche la riga 3 passa prima del timer. L'argomento 0 di setTimeout non significa «subito»: significa «non prima di questo ritardo minimo», e comunque la callback entra nella coda dei macrotask, che aspetta il suo turno.

L'ordine, quindi, si riassume in quattro passi. Qui l'elenco è numerato perché la sequenza è il contenuto: scambia i passi 2 e 3 e il comportamento cambia.

  1. Esegui il codice sincrono fino a svuotare lo stack.
  2. Svuota la coda dei microtask, compresi quelli accodati mentre la svuoti.
  3. Prendi il prossimo macrotask ed eseguilo.
  4. Torna al passo 2.

Il punto delicato è il «compresi quelli accodati mentre la svuoti»: un microtask può generarne altri, e l'event loop non passa al macrotask successivo finché non sono finiti tutti. È la porta d'ingresso dell'anti-pattern che vedi tra poco.

Che cosa fa davvero await

Un await non blocca il thread: sospende la funzione e restituisce il controllo a chi l'ha chiamata. La continuazione dopo l'await diventa un microtask. Il listato seguente lo rende visibile: main stampa «A», pianifica un timer, si sospende, e il codice sincrono che segue la chiamata («E») passa davanti alla sua ripresa («C»).

async function main() {
  console.log('A');                       // sincrono: main gira fino al primo await
  setTimeout(() => console.log('B'), 0);  // macrotask
  await Promise.resolve('pronto');        // main si sospende; la continuazione e' un microtask
  console.log('C');                       // riprende dopo che lo stack si e' svuotato
}

console.log('D');
main();
console.log('E');                         // gira prima di C: main ha ceduto il controllo

// Output:
// D
// A
// E
// C
// B

Leggi l'uscita come una storia in tre atti. Atto uno, sincrono: «D», poi dentro main «A», poi l'await restituisce il controllo e arriva «E». Atto due, microtask: lo stack è vuoto, riprende «C». Atto tre, macrotask: «B». Se toglievi l'await, «C» sarebbe uscito subito dopo «A», e l'intera funzione sarebbe stata un blocco sincrono.

C'è una sottigliezza che le guide dimenticano. Si dice spesso che async/await sia «zucchero sintattico» per .then(), e il senso è giusto, ma i tempi non coincidono. Nel listato che segue, una funzione con due await null e una catena di tre .then partono insieme.

// Due catene di microtask che avanzano a passi alterni.
async function conAwait() {
  console.log('await: inizio');
  await null;
  console.log('await: dopo il primo await');
  await null;
  console.log('await: dopo il secondo await');
}

function conThen() {
  return Promise.resolve()
    .then(() => console.log('then: primo'))
    .then(() => console.log('then: secondo'))
    .then(() => console.log('then: terzo'));
}

conAwait();
conThen();
console.log('sincrono finito');

// Output:
// await: inizio
// sincrono finito
// await: dopo il primo await
// then: primo
// await: dopo il secondo await
// then: secondo
// then: terzo

Le due catene avanzano a passi alterni: ogni await e ogni .then costa un giro della coda dei microtask, e il motore alterna le due catene a ogni giro. Il consiglio concreto è di non costruire mai la correttezza di un programma sull'ordine fine fra due catene indipendenti: se la sequenza conta, collegale con un await o un Promise.all. Il numero di giri che costa un await l'ho osservato su Node.js 24.11.0 con un valore nullo; con un oggetto thenable al posto di una Promise nativa il conteggio è diverso, e per questo la sequenza fine non va mai data per scontata.

Microtask starvation: quando la coda non si svuota mai

Se i microtask continuano a generarne altri, la regola «svuota la coda» diventa una trappola: i macrotask, compresi i timer e le risposte di rete, restano in attesa. La documentazione di Node.js descrive lo stesso rischio per process.nextTick(): chiamate ricorsive affamano l'I/O, perché l'event loop non raggiunge più la fase poll. Per mostrarlo senza congelare la macchina uso una catena limitata a 200.000 passi.

// Versione limitata della "starvation": 200000 microtask in catena.
let passi = 0;
const t0 = performance.now();

setTimeout(() => {
  const ms = Math.round(performance.now() - t0);
  console.log(`timer(0) eseguito dopo ${passi} microtask, circa ${ms} ms`);
}, 0);

function catena() {
  if (++passi < 200000) Promise.resolve().then(catena);
}
catena();

// Output:
// timer(0) eseguito dopo 200000 microtask, circa 9 ms

Il timer è stato registrato prima della catena, eppure scatta dopo tutti i 200.000 microtask. Il tempo (circa 9 ms sulla mia macchina) è un ordine di grandezza che cambia da un'esecuzione all'altra; ciò che non cambia è l'ordine. Togli il if che limita i passi e il timer non scatterà mai, il processo resterà al 100% di CPU e nessuna richiesta verrà servita.

In Node.js la precedenza ha un livello in più. Il listato seguente mette a confronto le cinque sorgenti di lavoro differito: codice sincrono, process.nextTick, Promise, timer e setImmediate.

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sincrono');

// Output:
// sincrono
// nextTick
// promise
// timeout
// immediate

nextTick passa prima della Promise, in un modulo CommonJS: la sua coda viene svuotata prima di quella dei microtask delle Promise. Per questo la guida di Node.js raccomanda setImmediate() «in tutti i casi», perché è più facile da prevedere, e riserva nextTick a chi deve gestire un errore o ripulire risorse prima che l'event loop prosegua. Anche la coppia timeout/immediate in coda al listato merita attenzione: dal modulo principale il loro ordine non è garantito, come mostra la prossima sezione.

Le fasi dell'event loop di Node.js

Il ciclo di libuv attraversa sei fasi, sempre nello stesso ordine: timers (callback di setTimeout e setInterval scadute), pending callbacks (alcune callback di sistema rinviate), idle/prepare (uso interno), poll (recupera eventi I/O ed esegue le loro callback), check (callback di setImmediate) e close callbacks (per esempio socket.on('close')). Fra una callback e la successiva Node svuota nextTick e microtask.

Da questo ordine segue una conseguenza testabile: setImmediate vive nella fase check, subito dopo poll. Se sei dentro una callback di I/O (fase poll), la fase check arriva prima del giro successivo dei timer, quindi immediate vince sempre.

const fs = require('fs');

fs.readFile(__filename, () => {
  // Siamo nella fase poll: la prossima e' check (setImmediate), poi i timer.
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

// Output:
// immediate
// timeout

Ho lanciato il programma 20 volte: immediate è uscito per primo 20 volte su 20, ed è ciò che la documentazione garantisce per questo caso. Fuori da una callback di I/O la garanzia non c'è: il file main-module.js, con le stesse due righe a livello di modulo, ha dato immediate per primo 19 volte su 20 e timeout una volta. La documentazione lo spiega: dal modulo principale l'ordine dipende dalle prestazioni del processo, perché la documentazione dei timer dice che un ritardo inferiore a 1 viene portato a 1 ms, e il timer può essere o non essere già scaduto quando il ciclo entra nella fase timers.

Bloccare l'event loop: la misura

Un server Node serve tutti i client dallo stesso thread, quindi un calcolo sincrono lungo ritarda tutti gli altri. Per vederlo senza costruire un server, ho usato un timer da zero millisecondi come sentinella: se il thread è libero scatta quasi subito, se è occupato scatta in ritardo. Il lavoro pesante è una derivazione scrypt (parametro di costo N = 216), una funzione crittografica deliberatamente costosa in CPU.

const crypto = require('crypto');
const t0 = performance.now();
setTimeout(() => {
  console.log(`timer(0) in ritardo di ${Math.round(performance.now() - t0)} ms`);
}, 0);

// Blocca il thread per un lavoro sincrono.
crypto.scryptSync('password', 'sale', 64, { N: 2 ** 16, maxmem: 128 * 1024 * 1024 });
console.log(`scryptSync terminata dopo ${Math.round(performance.now() - t0)} ms`);

// Output:
// scryptSync terminata dopo 157 ms
// timer(0) in ritardo di 159 ms

Il timer chiesto per «subito» è scattato dopo 159 ms, esattamente quanto è durata la derivazione sincrona: per tutto quel tempo il server non avrebbe risposto a nessuno. Il valore dipende dalla CPU e dal carico, non va preso come cifra di riferimento; è la proporzione che conta. La stessa derivazione in forma asincrona cambia il quadro.

const crypto = require('crypto');
const t0 = performance.now();
setTimeout(() => {
  console.log(`timer(0) eseguito dopo ${Math.round(performance.now() - t0)} ms`);
}, 0);

// Stessa derivazione, ma nel threadpool di libuv.
crypto.scrypt('password', 'sale', 64, { N: 2 ** 16, maxmem: 128 * 1024 * 1024 }, () => {
  console.log(`scrypt asincrona terminata dopo ${Math.round(performance.now() - t0)} ms`);
});

// Output:
// timer(0) eseguito dopo 2 ms
// scrypt asincrona terminata dopo 157 ms

Adesso il timer scatta dopo 2 ms e la derivazione dura quanto prima, 157 ms: il lavoro non è diventato più veloce, è stato spostato nel threadpool di libuv e il thread JavaScript è rimasto libero. Questo è il significato operativo di «non bloccare l'event loop»: non evitare il lavoro, ma farlo altrove. Le occasioni più frequenti sono quattro, e vale per tutte lo stesso schema (versione sincrona che occupa il thread, versione asincrona che usa il threadpool o un worker).

  • Calcoli CPU-intensivi in JavaScript puro: spostali in un Worker (modulo worker_threads).
  • JSON.parse o JSON.stringify su documenti molto grandi: sono operazioni sincrone e proporzionali alla dimensione.
  • Le varianti *Sync di fs e crypto dentro il codice di un server: usa quelle asincrone.
  • Espressioni regolari con backtracking catastrofico su input controllato dall'utente (la guida di Node le chiama ReDoS).

Le voci sono in ordine di frequenza che incontro, non di gravità: la quarta è la più insidiosa perché una regex scritta bene può diventare un attacco di negazione del servizio con un solo input malevolo. Il criterio comune è la durata sincrona: ogni tratto di codice che non cede il controllo per più di qualche decina di millisecondi è un candidato a essere spostato o spezzato.

Cedere il thread senza spostare il lavoro

Non sempre puoi usare un worker: a volte il lavoro è un ciclo su dati in memoria che deve restare nello stesso processo. In quel caso lo spezzi in blocchi e, fra un blocco e l'altro, restituisci il controllo all'event loop con setImmediate. Il listato somma un milione di elementi a blocchi da centomila e tiene acceso un timer da 1 ms per verificare che nel frattempo il thread respiri.

// Elabora 1.000.000 di elementi a blocchi di 100.000, cedendo il thread tra un blocco e l'altro.
const dati = Array.from({ length: 1_000_000 }, (_, i) => i);
let somma = 0;
let tick = 0;

const timer = setInterval(() => { tick++; }, 1);

function elabora(da) {
  const fine = Math.min(da + 100_000, dati.length);
  for (let i = da; i < fine; i++) somma += dati[i];
  if (fine < dati.length) {
    setImmediate(() => elabora(fine)); // cede: timer e I/O possono girare
  } else {
    clearInterval(timer);
    console.log(`somma = ${somma}`);
    console.log(`il timer da 1 ms e' scattato ${tick > 0 ? 'durante' : 'solo dopo'} l'elaborazione`);
  }
}
elabora(0);

// Output:
// somma = 499999500000
// il timer da 1 ms e' scattato durante l'elaborazione

La somma è esatta (0 + 1 + … + 999.999 = 499.999.500.000) e il timer è scattato durante l'elaborazione, a riprova che il thread non è rimasto occupato. Il prezzo è un po' di complessità e ogni cessione aggiunge un passaggio nell'event loop. Una variante molto diffusa usa Promise.resolve().then(...) per cedere, e non funziona: i microtask non lasciano mai spazio ai macrotask, quindi sarebbe lo stesso scenario di starvation visto sopra. Per cedere davvero serve un macrotask: setImmediate o setTimeout.

Che cosa sai fare adesso

Sai prevedere l'ordine di un programma che mescola codice sincrono, Promise, await, timer e setImmediate, e sai perché una Promise batte un timer da zero. Sai riconoscere la starvation dei microtask, misurare quanto un calcolo sincrono ritarda il resto del processo con un timer sentinella, e scegliere fra worker, versione asincrona e lavoro a blocchi.

Il prossimo articolo passa a un modello senza event loop condiviso: goroutine e channel in Go. Se il tuo problema è il produttore che supera il consumatore, vai direttamente a backpressure e flow control; per la stessa idea in Python, asyncio avanzato.

Fonti

Tutti i tempi in millisecondi e i conteggi di ordine (19 su 20, 20 su 20) vengono da esecuzioni fatte su una sola macchina con Node.js 24.11.0; ripetili sulla tua prima di usarli come riferimento.

Articolo precedente ← Modelli di Concorrenza
Prossimo nella serie Goroutine e Channel in Go →

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti