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.
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) equeueMicrotask(). 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.
- Esegui il codice sincrono fino a svuotare lo stack.
- Svuota la coda dei microtask, compresi quelli accodati mentre la svuoti.
- Prendi il prossimo macrotask ed eseguilo.
- 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(moduloworker_threads). JSON.parseoJSON.stringifysu documenti molto grandi: sono operazioni sincrone e proporzionali alla dimensione.- Le varianti
*Syncdifsecryptodentro 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
- The Node.js Event Loop (nodejs.org) - definizione di event loop, le fasi, setImmediate contro setTimeout dentro e fuori da una callback di I/O, rischio di starvation con process.nextTick, consultata il 2026-11-12
- Using microtasks in JavaScript with queueMicrotask() (MDN) - differenza fra task e microtask, la coda dei microtask si svuota prima del giro successivo, consultata il 2026-11-12
- Don't Block the Event Loop (or the Worker Pool) (nodejs.org) - perché un calcolo sincrono lungo ritarda tutte le richieste, i casi ReDoS e JSON DoS, il partizionamento dei task, consultata il 2026-11-12
- Timers (documentazione API di Node.js) - un ritardo minore di 1 ms viene portato a 1 ms, nessuna garanzia sul momento esatto né sull'ordine, consultata il 2026-11-12
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.

Commenti
Caricamento commenti...
Accedi per commentare