Menu

Event loop Node.js: fasi, nextTick e setImmediate

L'event loop di Node.js esegue le callback per fasi (timers, poll, check, close) e tra una callback e l'altra svuota la coda di process.nextTick e poi quella delle promise. Esegui gli esempi per vedere l'ordine esatto.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

L'event loop di Node.js è il ciclo che decide quale callback eseguire dopo che il codice sincrono è finito. Attraversa delle fasi in un ciclo fisso: timers (setTimeout, setInterval), pending callbacks, poll (I/O come la lettura di file e i dati di rete), check (setImmediate) e close callbacks. Dopo ogni callback, Node svuota prima la coda di process.nextTick e poi quella delle promise, quindi queste vengono sempre eseguite prima che il loop vada avanti.

Stampa sync, nextTick, promise, setTimeout, l'ordine inverso rispetto a quello in cui sono scritte. Il codice sincrono finisce sempre per primo, poi Node svuota la coda nextTick, poi quella delle promise, e solo allora il loop parte e arriva alla fase timers.

Le fasi dell'event loop

Il loop di Node viene da libuv, la libreria C che Node usa per l'I/O. Prima del primo giro esegue i timer già scaduti, e poi ogni giro esegue queste fasi in ordine:

  1. Pending callbacks: alcune callback di sistema rimandate dal giro precedente, come certi errori di rete.
  2. Poll: i nuovi eventi di I/O e le loro callback (una lettura di file finita, dati arrivati su un socket). Quando non c'è altro in programma, il loop aspetta qui.
  3. Check: le callback di setImmediate.
  4. Close callbacks: gli eventi 'close', per esempio un socket distrutto.
  5. Timers: le callback di setTimeout e setInterval il cui tempo è scaduto.

Molte guide mettono i timer al primo posto in ogni giro, che è come funzionava Node prima della versione 20 (libuv 1.45). Le callback girano nello stesso ordine in entrambi i casi: ai timer di un giro segue il poll del giro successivo.

Due code stanno fuori dalle fasi: la coda nextTick e la coda dei microtask (le callback delle promise e queueMicrotask). Node le svuota entrambe dopo ogni singola callback, in qualsiasi fase, prima nextTick. Il loop continua a girare finché c'è qualcosa da aspettare (un timer, un file o un socket aperto, un server) e il processo termina quando non resta niente.

Questo è il loop di Node. Il browser condivide l'idea di task e microtask ma non ha fasi, né setImmediate né process.nextTick; la pagina sull'event loop di JavaScript copre il quadro a livello di linguaggio.

process.nextTick e promise

Entrambi vengono eseguiti prima che il loop vada avanti, ma non si mescolano. Node esegue tutta la coda nextTick, compresi i tick aggiunti mentre gira, e solo dopo la coda delle promise. Prova a indovinare:

L'ordine è tick 1, tick 2, tick from tick, promise 1, promise 2. Il tick programmato dentro un tick entra nella stessa coda e passa comunque prima di qualsiasi promise, anche se la promise è stata creata prima.

Al livello principale di un modulo ES (un file che usa import) i due si scambiano: il codice del modulo gira dentro un job di promise, quindi le sue callback di promise finiscono prima che Node arrivi alla coda nextTick. Dentro le callback la regola "prima nextTick" vale in entrambi i sistemi di moduli.

setImmediate e setTimeout(fn, 0)

setTimeout(fn, 0) in realtà significa "dopo almeno 1 ms, nella fase timers". setImmediate(fn) significa "nella prossima fase check". Quale venga eseguito prima dipende da dove li chiami.

Dentro una callback di I/O l'ordine è fisso. La callback gira nella fase poll, la fase check arriva subito dopo e la fase timers solo dopo:

Stampa nextTick, setImmediate, setTimeout, ogni volta. Per questo setImmediate è la scelta giusta per "esegui questo dopo il lavoro di I/O in corso".

Nel modulo principale, fuori da qualsiasi callback, l'ordine dei due non è fisso:

// Order depends on how fast the process started
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

Se quando il loop parte è passato almeno 1 ms, il timer è scaduto e stampa per primo; altrimenti vince setImmediate. Non scrivere codice che dipenda da uno dei due risultati.

Viene eseguitoDa usare per
process.nextTick(fn)Subito dopo l'operazione corrente, prima delle promiseEmettere un evento o un errore dopo che il chiamante ha collegato i listener
Promise.then, queueMicrotaskQuando la coda nextTick è vuotaIl normale codice asincrono
setImmediate(fn)Fase check, dopo le callback di I/OCedere il turno al loop tra un pezzo di lavoro e l'altro
setTimeout(fn, 0)Fase timers, almeno 1 ms dopoRitardi, quando il momento esatto non conta

Bloccare l'event loop

Visto che c'è un solo thread JavaScript, una callback che dura a lungo ferma tutto il resto: timer, callback di I/O e, in un server, ogni altra richiesta. Il ritardo di un timer è un minimo, non una garanzia.

Prima stampa loop finished, poi true: il timer era scaduto dopo 10 ms ma non ha potuto girare finché il ciclo attivo non ha restituito il thread. In un server, lo stesso ciclo dentro un route handler blocca ogni altra richiesta per mezzo secondo.

Per un lavoro lungo la soluzione è dividerlo e lasciare un giro al loop tra un pezzo e l'altro con setImmediate:

Il timer passa dopo la parte 1. Sostituisci setImmediate(() => work(part + 1)) con work(part + 1) e rieseguilo: le tre parti girano tutte insieme e timer stampa per ultimo.

Per un lavoro pesante dall'inizio alla fine, come l'elaborazione di immagini, dividerlo non basta: spostalo fuori dal thread principale con worker_threads.

Errori comuni

Chiamare process.nextTick in modo ricorsivo. Una callback nextTick che continua a programmarne un'altra non lascia mai proseguire il loop, e timer e I/O restano a secco. Usa setImmediate per il lavoro ripetuto.

Usare API sincrone in un server. fs.readFileSync, crypto.pbkdf2Sync e le chiamate a JSON.parse su dati grandi bloccano il loop per tutti gli utenti. Dentro i request handler usa le versioni asincrone.

Aspettarsi che setTimeout(fn, 0) parta subito. Parte dopo tutto il codice sincrono, ogni nextTick e ogni callback di promise, e solo quando il loop arriva alla fase timers.

Domande frequenti

Node.js è single thread?

Il tuo JavaScript gira su un solo thread, quindi due callback non vengono mai eseguite nello stesso momento. Node però usa altri thread: libuv tiene un pool (quattro thread di default) per il file system, le risoluzioni DNS e parte della crittografia, e restituisce i risultati al loop.

Quando termina un programma Node.js?

Quando l'event loop non ha più niente da aspettare: nessun timer, nessun I/O in corso, nessun server o socket aperto. Un setInterval attivo o un server in ascolto tiene vivo il processo finché non lo fermi o lo chiudi.

L'event loop di Node.js è uguale a quello del browser?

L'idea è la stessa, un thread che esegue callback in coda, ma i dettagli cambiano. Il browser non ha setImmediate né process.nextTick e inserisce il rendering tra un task e l'altro, mentre Node segue le fasi di libuv. La pagina sull'event loop di JavaScript copre la parte comune, quella del linguaggio.

Come capisco se l'event loop è bloccato?

Misura quanto è in ritardo: monitorEventLoopDelay() di perf_hooks registra il ritardo, e una media di decine di millisecondi vuol dire che qualche callback tiene il thread troppo a lungo. Avvia Node con --cpu-prof per scoprire quale funzione è.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

Inizia