Skip to content

Il Loop degli Eventi di Node.js

Cos'è il Loop degli Eventi?

Il loop degli eventi permette a Node.js di eseguire operazioni I/O non bloccanti — nonostante il fatto che per impostazione predefinita venga utilizzato un singolo thread JavaScript — scaricando le operazioni sul kernel di sistema quando possibile.

Poiché la maggior parte dei kernel moderni sono multi-thread, possono gestire l'esecuzione di più operazioni in background. Quando una di queste operazioni si completa, il kernel lo comunica a Node.js in modo che la callback appropriata possa essere aggiunta alla coda di polling per essere eventualmente eseguita. Spiegheremo questo in dettaglio più avanti in questo argomento.

Il Loop degli Eventi Spiegato

Quando Node.js si avvia, inizializza il loop degli eventi, elabora lo script di input fornito (o passa alla REPL, che non è trattata in questo documento) che potrebbe effettuare chiamate API asincrone, pianificare timer o chiamare process.nextTick(), quindi inizia l'elaborazione del loop degli eventi.

Il seguente diagramma mostra una panoramica semplificata dell'ordine delle operazioni del loop degli eventi.

bash
   ┌───────────────────────────┐
┌─>           timers
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
     pending callbacks
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
       idle, prepare
  └─────────────┬─────────────┘      ┌───────────────┐
  ┌─────────────┴─────────────┐   incoming:
           poll<─────┤  connections,
  └─────────────┬─────────────┘   data, etc.
  ┌─────────────┴─────────────┐      └───────────────┘
           check
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
└──┤      close callbacks
   └───────────────────────────┘

TIP

Ogni riquadro verrà chiamato "fase" del loop degli eventi.

Ogni fase ha una coda FIFO di callback da eseguire. Mentre ogni fase è speciale a modo suo, in generale, quando il loop degli eventi entra in una determinata fase, eseguirà tutte le operazioni specifiche di quella fase, quindi eseguirà le callback nella coda di quella fase fino a quando la coda non sarà esaurita o il numero massimo di callback sarà stato eseguito. Quando la coda è esaurita o viene raggiunto il limite delle callback, il loop degli eventi passa alla fase successiva e così via.

Poiché una qualsiasi di queste operazioni può pianificare più operazioni e i nuovi eventi elaborati nella fase poll sono in coda dal kernel, gli eventi di polling possono essere messi in coda mentre vengono elaborati gli eventi di polling. Di conseguenza, le callback di lunga durata possono consentire alla fase di polling di funzionare molto più a lungo della soglia di un timer. Vedere le sezioni timer e poll per maggiori dettagli.

TIP

Esiste una leggera discrepanza tra l'implementazione di Windows e quella Unix/Linux, ma questo non è importante per questa dimostrazione. Le parti più importanti sono qui. Ci sono in realtà sette o otto passaggi, ma quelli a cui teniamo — quelli che Node.js usa effettivamente — sono quelli sopra.

Panoramica delle Fasi

  • timers: questa fase esegue le callback pianificate da setTimeout() e setInterval().
  • pending callbacks: esegue le callback I/O differite alla successiva iterazione del loop.
  • idle, prepare: usate solo internamente.
  • poll: recupera nuovi eventi I/O; esegue le callback relative all'I/O (quasi tutte tranne le callback di chiusura, quelle pianificate dai timer e setImmediate()); Node si bloccherà qui quando appropriato.
  • check: le callback setImmediate() vengono invocate qui.
  • close callbacks: alcune callback di chiusura, es. socket.on('close', ...).

Tra ogni esecuzione dell'event loop, Node.js controlla se sta aspettando I/O asincroni o timer e si spegne in modo pulito se non ce ne sono.

Dettagli delle Fasi

timers

Un timer specifica la soglia dopo la quale una callback fornita può essere eseguita piuttosto che l'ora esatta in cui una persona vuole che venga eseguita. Le callback dei timer verranno eseguite il prima possibile dopo che l'intervallo di tempo specificato è trascorso; tuttavia, la pianificazione del sistema operativo o l'esecuzione di altre callback potrebbero ritardarle.

TIP

Tecnicamente, la fase poll controlla quando vengono eseguiti i timer.

Ad esempio, supponiamo di pianificare un timeout da eseguire dopo una soglia di 100 ms, quindi lo script inizia a leggere asincronamente un file che richiede 95 ms:

js
const fs = require('node:fs')
function someAsyncOperation(callback) {
  // Si assume che questa operazione richieda 95 ms per il completamento
  fs.readFile('/path/to/file', callback)
}
const timeoutScheduled = Date.now()
setTimeout(() => {
  const delay = Date.now() - timeoutScheduled
  console.log(`${delay}ms sono trascorsi da quando sono stato pianificato`)
}, 100)
// esegui someAsyncOperation che richiede 95 ms per il completamento
someAsyncOperation(() => {
  const startCallback = Date.now()
  // fai qualcosa che richiederà 10ms...
  while (Date.now() - startCallback < 10) {
    // non fare niente
  }
})

Quando l'event loop entra nella fase poll, ha una coda vuota (fs.readFile() non è stata completata), quindi aspetterà il numero di ms rimanenti fino a quando non viene raggiunta la soglia del timer più prossimo. Mentre aspetta, trascorrono 95 ms, fs.readFile() termina la lettura del file e la sua callback, che richiede 10 ms per il completamento, viene aggiunta alla coda poll ed eseguita. Quando la callback termina, non ci sono più callback nella coda, quindi l'event loop vedrà che la soglia del timer più prossimo è stata raggiunta e quindi tornerà alla fase dei timer per eseguire la callback del timer. In questo esempio, si vedrà che il ritardo totale tra la pianificazione del timer e l'esecuzione della sua callback sarà di 105 ms.

TIP

Per evitare che la fase poll affami l'event loop, libuv (la libreria C che implementa l'event loop di Node.js e tutti i comportamenti asincroni della piattaforma) ha anche un massimo hard (dipendente dal sistema) prima che interrompa l'polling per altri eventi.

callback in sospeso

Questa fase esegue i callback per alcune operazioni di sistema, come i tipi di errori TCP. Ad esempio, se un socket TCP riceve ECONNREFUSED durante il tentativo di connessione, alcuni sistemi *nix desiderano attendere per segnalare l'errore. Questo verrà messo in coda per l'esecuzione nella fase dei callback in sospeso.

poll

La fase poll ha due funzioni principali:

  1. Calcolare per quanto tempo dovrebbe bloccare e sondare per l'I/O, quindi
  2. Elaborare gli eventi nella coda poll.

Quando il loop degli eventi entra nella fase poll e non ci sono timer pianificati, una di queste due cose accadrà:

  • Se la coda poll non è vuota, il loop degli eventi itererà attraverso la sua coda di callback eseguendole sincronicamente fino a quando la coda non sarà esaurita o verrà raggiunto il limite massimo dipendente dal sistema.

  • Se la coda poll è vuota, una di queste due cose accadrà:

    • Se gli script sono stati pianificati da setImmediate(), il loop degli eventi terminerà la fase poll e continuerà alla fase di controllo per eseguire quegli script pianificati.

    • Se gli script non sono stati pianificati da setImmediate(), il loop degli eventi attenderà che i callback vengano aggiunti alla coda, quindi li eseguirà immediatamente.

Una volta che la coda poll è vuota, il loop degli eventi verificherà i timer le cui soglie di tempo sono state raggiunte. Se uno o più timer sono pronti, il loop degli eventi tornerà alla fase timer per eseguire i callback di quei timer.

check

Questa fase consente di eseguire i callback immediatamente dopo il completamento della fase poll. Se la fase poll diventa inattiva e gli script sono stati messi in coda con setImmediate(), il loop degli eventi potrebbe continuare alla fase di controllo invece di aspettare.

setImmediate() è in realtà un timer speciale che viene eseguito in una fase separata del loop degli eventi. Utilizza un'API libuv che pianifica l'esecuzione dei callback dopo il completamento della fase poll.

In generale, mentre il codice viene eseguito, il loop degli eventi alla fine raggiungerà la fase poll in cui attenderà una connessione in arrivo, una richiesta, ecc. Tuttavia, se un callback è stato pianificato con setImmediate() e la fase poll diventa inattiva, terminerà e continuerà alla fase check invece di attendere gli eventi poll.

Callback di chiusura

Se un socket o un handle viene chiuso bruscamente (es. socket.destroy()), l'evento 'close' verrà emesso in questa fase. Altrimenti verrà emesso tramite process.nextTick().

setImmediate() vs setTimeout()

setImmediate() e setTimeout() sono simili, ma si comportano in modo diverso a seconda di quando vengono chiamati.

  • setImmediate() è progettato per eseguire uno script una volta completata la fase poll corrente.
  • setTimeout() pianifica l'esecuzione di uno script dopo che è trascorso un tempo minimo in ms.

L'ordine in cui i timer vengono eseguiti varia a seconda del contesto in cui vengono chiamati. Se entrambi vengono chiamati all'interno del modulo principale, la temporizzazione sarà vincolata dalle prestazioni del processo (che possono essere influenzate da altre applicazioni in esecuzione sulla macchina).

Ad esempio, se eseguiamo lo script seguente che non si trova all'interno di un ciclo I/O (cioè il modulo principale), l'ordine in cui vengono eseguiti i due timer è non deterministico, in quanto è vincolato dalle prestazioni del processo:

js
// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout')
}, 0)
setImmediate(() => {
  console.log('immediate')
})
bash
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout

Tuttavia, se sposti le due chiamate all'interno di un ciclo I/O, il callback immediato viene sempre eseguito per primo:

js
// timeout_vs_immediate.js
const fs = require('node:fs')
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})
bash
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout

Il principale vantaggio nell'utilizzo di setImmediate() rispetto a setTimeout() è che setImmediate() verrà sempre eseguito prima di qualsiasi timer se pianificato all'interno di un ciclo I/O, indipendentemente dal numero di timer presenti.

process.nextTick()

Comprendere process.nextTick()

Potresti aver notato che process.nextTick() non è stato visualizzato nel diagramma, anche se fa parte dell'API asincrona. Questo perché process.nextTick() non fa tecnicamente parte del loop degli eventi. Invece, la nextTickQueue verrà elaborata dopo il completamento dell'operazione corrente, indipendentemente dalla fase corrente del loop degli eventi. Qui, un'operazione è definita come una transizione dal gestore sottostante C/C++ e la gestione del JavaScript che deve essere eseguito.

Riguardando il nostro diagramma, ogni volta che si chiama process.nextTick() in una data fase, tutte le callback passate a process.nextTick() saranno risolte prima che il loop degli eventi continui. Questo può creare alcune situazioni spiacevoli perché permette di "affamare" il tuo I/O effettuando chiamate ricorsive a process.nextTick(), che impedisce al loop degli eventi di raggiungere la fase poll.

Perché dovrebbe essere permesso?

Perché una cosa del genere dovrebbe essere inclusa in Node.js? Parte di ciò è una filosofia di progettazione in cui un'API dovrebbe essere sempre asincrona anche quando non è necessario. Prendi questo frammento di codice ad esempio:

js
function apiCall(arg, callback) {
  if (typeof arg !== 'string') return process.nextTick(callback, new TypeError('argument should be string'))
}

Il frammento esegue un controllo degli argomenti e se non è corretto, passerà l'errore alla callback. L'API aggiornata di recente per consentire il passaggio di argomenti a process.nextTick(), consentendo di propagare tutti gli argomenti passati dopo la callback come argomenti della callback in modo da non dover annidare le funzioni.

Quello che stiamo facendo è passare un errore all'utente ma solo dopo aver permesso al resto del codice dell'utente di essere eseguito. Usando process.nextTick() garantiamo che apiCall() esegua sempre la sua callback dopo il resto del codice dell'utente e prima che il loop degli eventi possa procedere. Per ottenere questo, lo stack di chiamate JS è autorizzato a svuotarsi e quindi eseguire immediatamente la callback fornita, il che consente a una persona di effettuare chiamate ricorsive a process.nextTick() senza raggiungere un RangeError: Maximum call stack size exceeded from v8.

Questa filosofia può portare ad alcune situazioni potenzialmente problematiche. Prendi questo frammento di codice ad esempio:

js
let bar
// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {
  callback()
}
// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
  console.log('bar', bar) // undefined
})
bar = 1

L'utente definisce someAsyncApiCall() per avere una firma asincrona, ma in realtà opera in modo sincrono. Quando viene chiamato, la callback fornita a someAsyncApiCall() viene chiamata nella stessa fase del loop degli eventi perché someAsyncApiCall() non fa effettivamente nulla in modo asincrono. Di conseguenza, la callback tenta di fare riferimento a bar anche se potrebbe non avere ancora quella variabile nell'ambito, perché lo script non è stato in grado di eseguire fino al completamento.

Inserendo la callback in un process.nextTick(), lo script ha ancora la possibilità di eseguire fino al completamento, consentendo a tutte le variabili, funzioni, ecc. di essere inizializzate prima che venga chiamata la callback. Ha anche il vantaggio di non consentire al loop degli eventi di continuare. Potrebbe essere utile per l'utente essere avvisato di un errore prima che al loop degli eventi sia consentito di continuare. Ecco l'esempio precedente usando process.nextTick():

js
let bar
function someAsyncApiCall(callback) {
  process.nextTick(callback)
}
someAsyncApiCall(() => {
  console.log('bar', bar) // 1
})
bar = 1

Ecco un altro esempio del mondo reale:

js
const server = net.createServer(() => {}).listen(8080)
server.on('listening', () => {})

Quando viene passata solo una porta, la porta viene associata immediatamente. Quindi, la callback 'listening' potrebbe essere chiamata immediatamente. Il problema è che la callback .on('listening') non sarà ancora stata impostata a quel punto.

Per ovviare a questo, l'evento 'listening' viene messo in coda in un nextTick() per consentire allo script di eseguire fino al completamento. Ciò consente all'utente di impostare tutti i gestori di eventi desiderati.

process.nextTick() vs setImmediate()

Abbiamo due chiamate che sono simili per quanto riguarda gli utenti, ma i loro nomi sono fuorvianti.

  • process.nextTick() viene eseguito immediatamente nella stessa fase
  • setImmediate() viene eseguito nella successiva iterazione o 'tick' del loop degli eventi

In sostanza, i nomi dovrebbero essere scambiati. process.nextTick() viene eseguito più immediatamente di setImmediate(), ma questo è un artefatto del passato che è improbabile che cambi. Effettuare questo cambio romperebbe una grande percentuale dei pacchetti su npm. Ogni giorno vengono aggiunti nuovi moduli, il che significa che ogni giorno aspettiamo, si verificano più potenziali rotture. Sebbene siano confusi, i nomi stessi non cambieranno.

TIP

Si consiglia agli sviluppatori di utilizzare setImmediate() in tutti i casi perché è più facile da comprendere.

Perché usare process.nextTick()?

Ci sono due ragioni principali:

  1. Consentire agli utenti di gestire gli errori, pulire eventuali risorse non più necessarie o forse riprovare la richiesta prima che il loop degli eventi continui.

  2. A volte è necessario consentire l'esecuzione di una callback dopo che lo stack delle chiamate si è svuotato ma prima che il loop degli eventi continui.

Un esempio è quello di soddisfare le aspettative dell'utente. Esempio semplice:

js
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})

Supponiamo che listen() venga eseguito all'inizio del loop degli eventi, ma la callback di ascolto sia posizionata in un setImmediate(). A meno che non venga passato un nome host, il binding alla porta avverrà immediatamente. Affinché il loop degli eventi proceda, deve raggiungere la fase di polling, il che significa che esiste una probabilità non nulla che una connessione possa essere stata ricevuta consentendo l'attivazione dell'evento di connessione prima dell'evento di ascolto.

Un altro esempio è l'estensione di un EventEmitter e l'emissione di un evento dall'interno del costruttore:

js
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
  constructor() {
    super()
    this.emit('event')
  }
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
  console.log('an event occurred!')
})

Non è possibile emettere un evento dal costruttore immediatamente perché lo script non sarà stato elaborato fino al punto in cui l'utente assegna una callback a quell'evento. Quindi, all'interno del costruttore stesso, è possibile utilizzare process.nextTick() per impostare una callback per emettere l'evento dopo che il costruttore è terminato, il che fornisce i risultati previsti:

js
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
  constructor() {
    super()
    // use nextTick to emit the event once a handler is assigned
    process.nextTick(() => {
      this.emit('event')
    })
  }
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
  console.log('an event occurred!')
})