Un solo thread, ma non sequenziale
JavaScript gira su un solo thread. C'è una sola call stack e, in ogni momento, viene eseguita esattamente una funzione. Nello stesso realm, due righe del tuo codice non girano mai in parallelo.
Sembra un limite finché non pensi a cosa fa davvero JavaScript: recupera dati, aspetta i clic dell'utente, legge file. La maggior parte del "lavoro" è attesa. L'event loop è il trucco che rende l'attesa economica: il tuo codice passa un compito al browser o a Node, torna a fare altro e riceve un avviso quando il risultato è pronto.
primo e secondo vengono stampati in ordine. terzo arriva dopo, anche se il timeout è 0. Quello scarto è l'event loop al lavoro, e capire il perché è lo scopo di tutta questa pagina.
La call stack
Ogni chiamata di funzione aggiunge un frame alla call stack. Quando la funzione restituisce un valore, il suo frame viene tolto. La stack è semplicemente una pila: l'ultimo che entra è il primo che esce.
Quando viene eseguita outer(), Node mette sulla pila outer, poi inner, toglie inner quando restituisce "fatto" e poi toglie outer. La pila è di nuovo vuota. Quel momento di "pila vuota" è proprio ciò che l'event loop sta aspettando.
Il codice sincrono gira dall'inizio alla fine sulla stack. Niente di asincrono può interromperlo. Se scrivi un ciclo while (true), la stack non si svuota mai e la pagina si blocca: niente clic, niente timer, niente callback delle promise. L'event loop non ha niente da fare perché non gli arriva mai il turno.
Dove vive davvero il lavoro asincrono
JavaScript da solo non sa fare una richiesta di rete né aspettare 100 millisecondi. Quelle API appartengono all'host: il browser o Node. Quando chiami setTimeout(fn, 100), ecco cosa succede:
- Il timer viene registrato presso l'host.
setTimeoutrestituisce subito il controllo. La stack continua a girare.- Dopo 100ms, l'host mette
fnin una coda. - Quando la stack è vuota, l'event loop prende
fndalla coda e la esegue.
La callback del timer non può essere eseguita finché il ciclo for e console.log("fine") non hanno finito, perché la stack non è ancora vuota. I timer indicano un ritardo minimo, non una garanzia.
Due code: task e microtask
Non c'è una sola coda. Ce ne sono due, e la differenza spiega la maggior parte delle sorprese dell'event loop.
- Task queue (a volte chiamata macrotask queue):
setTimeout,setInterval, callback di I/O, eventi della UI. - Microtask queue: callback delle promise (
.then,.catch,.finally), continuazioni diawaite tutto ciò che viene programmato conqueueMicrotask.
La regola che segue l'event loop:
- Esegue un task dalla task queue.
- Svuota tutta la microtask queue: ogni microtask, compresi quelli programmati mentre la sta svuotando.
- Esegue il rendering se serve (nei browser).
- Torna al punto 1.
I microtask vengono sempre eseguiti prima del task successivo. Ecco perché questo esempio sorprende molti:
Ordine dell'output: sync 1, sync 2, promise, timeout. Prima gira il codice sincrono. Poi la stack si svuota. Poi l'event loop svuota i microtask (promise). Solo a quel punto prende il task del timer (timeout).
I microtask possono affamare i task
Visto che la coda dei microtask si svuota completamente prima del task successivo, un microtask che continua a programmare altri microtask blocca per sempre la task queue:
Il timer non scatterebbe mai, perché ogni microtask ne mette in coda un altro e il loop non lascia mai svuotare la coda. Le catene di promise sono sicure perché ogni .then programma una sola continuazione, ma i cicli di microtask scritti a mano sono una trappola che conviene conoscere.
await è zucchero sintattico per un microtask
Quando fai await su una promise, la funzione si mette in pausa e il resto viene programmato come microtask, da eseguire quando la promise si conclude. Niente di magico: sotto c'è semplicemente un .then.
Output: A, C, B. L'await restituisce il controllo a chi ha chiamato la funzione. console.log("C") gira sulla stack corrente. Poi la coda dei microtask si svuota e il resto di demo riprende, stampando B.
Tienilo a mente quando leggi codice asincrono. await non blocca: cede il controllo.
Un esempio completo: mettere tutto in ordine
Mettiamo insieme tutti i pezzi:
Ordine:
1: sync: gira sulla stack.6: sync: sempre sulla stack.- La stack si svuota. La coda dei microtask viene svuotata:
3: promise,5: microtask, poi4: nested microtask(programmato durante lo svuotamento, viene comunque preso in questo giro). - Viene eseguito il task successivo:
2: timeout.
Output finale: 1, 6, 3, 5, 4, 2. Se riesci a seguire questo esempio, hai capito l'event loop.
Node ha più fasi
L'event loop di Node è un'estensione del modello del browser. Ha fasi distinte (timers, pending I/O callbacks, poll, check, close) e i microtask vengono svuotati tra una fase e l'altra. setImmediate gira nella fase check, mentre process.nextTick gira prima dei microtask normali (ha una sua coda con priorità ancora più alta).
Non serve imparare a memoria lo schema delle fasi il primo giorno. Il concetto chiave è lo stesso del browser: il codice sincrono gira fino alla fine, poi si svuotano i microtask, poi il loop prende la prossima callback in coda.
Perché è importante
Quando il modello ti è chiaro, molto codice asincrono smette di essere un mistero:
- Un ciclo
forlungo blocca la UI perché l'event loop non riesce ad avere il suo turno. setTimeout(fn, 0)è un modo per rimandare del lavoro a dopo la fine del task corrente e dei microtask.- Una callback
.thenche parte "subito" su una promise già risolta aspetta comunque la fine del codice sincrono corrente. - Un
awaitdentro un ciclo serializza il lavoro, perché ogni iterazione cede il passo alla coda dei microtask prima di continuare.
Fare debug di codice asincrono significa soprattutto chiedersi: "cosa c'è sulla stack in questo momento, e cosa c'è in coda?". L'event loop è la risposta.
Prossimo argomento: le callback
Prima delle promise e di async/await, l'unico strumento di JavaScript per il lavoro asincrono era la callback: una funzione che passi a un'API perché la chiami più tardi. Le callback sono ancora ovunque (event listener, API di base di Node), e capirle è la base di tutto il resto di questo capitolo.
Domande frequenti
Cos'è l'event loop in JavaScript?
È il meccanismo che permette a JavaScript, che è single-threaded, di eseguire lavoro asincrono senza bloccarsi. Il loop osserva la call stack: quando è vuota, prende la prossima callback in coda e la esegue. Timer, I/O e continuazioni delle promise finiscono tutti in code che l'event loop svuota un elemento alla volta.
Perché JavaScript è single-threaded?
La specifica del linguaggio definisce una sola call stack per realm, quindi il tuo codice gira su un unico thread. La concorrenza arriva dall'host (browser o Node), che delega il lavoro ad API in background, come timer, rete e I/O su file, e mette in coda una callback quando finiscono. Non vedrai mai due pezzi di JS eseguiti nello stesso momento nello stesso contesto.
Qual è la differenza tra microtask e macrotask?
I microtask arrivano dalle promise (.then, await) e da queueMicrotask. I macrotask arrivano da setTimeout, setInterval, I/O ed eventi della UI. Dopo ogni macrotask, l'event loop svuota tutta la coda dei microtask prima di eseguire il macrotask successivo. Per questo un Promise.resolve().then(...) viene sempre eseguito prima di un setTimeout(..., 0) programmato nello stesso istante.
Perché setTimeout con 0ms non viene eseguito subito?
setTimeout(fn, 0) non significa 'eseguilo adesso', significa 'metti fn in coda come macrotask, non prima di 0ms'. Il codice sincrono corrente deve finire, la coda dei microtask deve svuotarsi e solo allora l'event loop prende la callback del tuo timer. Quindi 0 è un minimo, non una garanzia.