back to top

Node.js Event Loop: cos’è e come funziona

Nela lezione introduttiva abbiamo visto che uno dei componenti fondamentali di Node.js è la libreria multipiattaforma libuv (scritta in linguaggio C) che viene utilizzata per la gestione asincrona delle operazioni di I/O. Per far ciò, libuv implementa un ciclo che prende il nome di Event Loop. In questa lezione cercheremo di capire meglio come funziona e analizzeremo quali sono le diverse fasi che lo costituiscono.

Come già accennato, Node.js fa uso dell’Event Loop per gestire le diverse operazioni di I/O (lettura/scrittura files, accesso database, richieste di rete) in maniera asincrona e non bloccante garantendo ottime prestazioni e mettendo a disposizione degli sviluppatori un’API intuitiva e semplice da usare. Una possibile alternativa a questa soluzione potrebbe essere quella di eseguire il codice in maniera sincrona. In tal caso però, ogni volta che viene richiesta una risorsa, il resto del programma resta in attesa finché la risorsa stessa non è disponibile e non è quindi possibile eseguire altre istruzioni. Un altro metodo, sicuramente più efficace di quello appena descritto, è fare uso della tecnica della programmazione concorrente attraverso l’uso di più thread. Sebbene quest’ultima soluzione sia più efficiente della precedente, usare diversi thread che accedono a risorse condivise può risultare più complicato. Ecco spiegato il motivo per cui Node.js fa uso dell’Event Loop. Perché grazie a quest’ultimo Node.js assicura l’esecuzione di operazioni di I/O in maniera asincrona e non bloccante garantendo da un lato un’elevata efficienza e permettendo dall’altro di usufruire della semplicità di un linguaggio come Javascript che, attraverso il motore V8, esegue una sola istruzione per volta.

Cos’è l’Event Loop

Schema funzionamento Node.js, libuv e Event Loop

Ogni volta che lanciamo l’esecuzione di uno script, Node.js inizializza in automatico l’Event Loop che verrà poi terminato al completamanto dello script. L’Event Loop viene impiegato da Node.js per implementare il modello asincrono e non bloccante della gestione delle operazioni di I/O. Ricordiamo infatti che ogni volta che viene richiesta una certa risorsa, Node.js, invece di bloccare la sua esecuzione e restare in attesa, delega il sistema operativo sottostante che può gestire più operazioni contemporaneamente. Quando il sistema operativo ha completato una certa operazione, informa Node.js attraverso la generazione di un evento. Questi eventi verranno inseriti all’interno di una coda e le rispettive funzioni callback (registrate in fase di scrittura del programma) verranno eseguite una alla volta quando la call stack del motore V8 è vuota, ovvero quando V8 non sta eseguendo altre istruzioni. In fase di scrittura dei nostri programmi, indicheremo tramite delle funzioni callback quale codice eseguire ogni volta che una certa risorsa è pronta per essere usata. In questo modo sembrerà che vengano eseguite più istruzioni contemporaneamente quando in realtà V8 è in grado di elaborarne una sola per volta.

Le diverse fasi dell’Event Loop

Le diverse fasi dell'Event Loop

Nell’immagine in alto abbiamo riportato una rappresentazione semplificata delle principali fasi che caratterizzano l’Event Loop. Ciascuna di queste fasi ha una propria coda di funzioni callback che vengono eseguite ogni volta che l’Evento Loop entra in quella determinata fase. L’Event Loop passerà alla fase successiva se sono state eseguite tutte le funzioni callback della coda oppure è stato raggiunto un numero massimo di callback eseguite in quella fase. Analizziamo velocemente le diverse fasi.

Fase 1: Timers

L’Event Loop esegue inizialmente le callback registrate attraverso le funzioni setTimeout() e setInterval(). Consideriamo per un momento il codice sottostante per evidenziare un aspetto importante dei timer.

setTimeout(function cb() {
  console.log('Esecuzione cb() attraverso setTimeout');
}, 1000);

È importante notare che la funzione callback cb verrà eseguita dopo che è trascorso almeno un secondo, ma non è garantito che venga eseguita esattamente dopo un secondo. Ciò dipende dal funzionamento dell’Event Loop che potrebbe trovarsi in una diversa fase nel preciso momento in cui sono trascorsi 1000ms.

Fase 2: I/O Callbacks

Quando l’Event Loop entra in questa fase, preleva dalla rispettiva coda ed esegue le funzioni callback registrate per le diverse operazioni di I/O.

Fase 3: Poll

In questa fase vengono prelevati e inseriti nella coda nuovi eventi collegati al completamento di operazioni di I/O. Se la coda non è vuota, verranno processate le diverse callback in essa presenti. In caso contrario, se sono state registrate delle callback con la funzione setImmediate() (si tratta di una funzione disponibile globalmente in Node.js), verrà terminata questa fase e si passerà alla fase successiva per processare tutte le callback registrate proprio tramite la funzione setImmediate(). Nel caso in cui non siano state registrate callback con la funzione setImmediate, l’Event Loop resta in attesa che vengano aggiunte nuove callback alla coda. Mentre è in questa fase, l’Event Loop controlla se sono presenti callback nella coda dei Timer che devono essere eseguite. In tal caso ritorna nella prima fase, che abbiamo già descritto, ed esegue le callback presenti in quella coda.

Fase 4: setImmediate

Attraverso la funzione setImmediate(), disponibile globalmente in Node.js, è possibile definire quali funzioni eseguire immediatamente dopo la fase di Poll. La funzione setImmediate() accetta come argomenti il riferimento alla funzione da eseguire e uno o più argomenti opzionali che verranno passati alla funzione specificata come primo argomento.

function saluta(nome) {
  console.log(`Ciao, ${nome}!`);
}
setImmediate(saluta, 'Lisa'); // Ciao, Lisa!

Differenza fra setTimeout() e setImmediate()

Come già detto, la funzione setTimeout() consente di specificare un intervallo minimo dopo il quale verrà eseguita la funzione passata come primo argomento. Al contrario la funzione setImmediate() viene eseguita non appena la fase di Poll viene completata. Un caso particolare si ha quando la funzione setTimeout() viene invocata passando 0 come secondo argomento. Se all’interno di uno script vengono invocate setTimeout(callback, 0) e setImmediate(callback), non è possibile prevedere a priori e senza specificare il contesto di esecuzione l’ordine in cui le due vengono eseguite. Tuttavia, se queste vengono chiamate all’interno della funzione callback passata come argomento a una funzione che effettua operazioni di I/O, allora la callback passata a setImmediate verrà sempre eseguita prima di quella passata a setTimeout. Possiamo osservare quanto appena detto nell’esempio sottostante.

const fs = require('fs');

for(let i = 0; i < 3; i++) {
  fs.readFile('file_di_testo.txt', function(err, data) {
    setTimeout(() => {
      console.log('2) Funzione callback passata a setTimeout');
    }, 0);
    setImmediate(() => {
      console.log('1) Funzione callback passata a setImmediate');
    });
  });
}

Eseguendo lo script, otterremo un risultato simile a quello riportato.

node setImmediate_setTimeout.js

1) Funzione callback passata a setImmediate
2) Funzione callback passata a setTimeout
1) Funzione callback passata a setImmediate
1) Funzione callback passata a setImmediate
2) Funzione callback passata a setTimeout
2) Funzione callback passata a setTimeout

Cos’è e quando usare process.nextTick()

Infine, un discorso a parte vale per la funzione process.nextTick() (Parleremo nuovamente del modulo process nei prossimi articoli) che tecnicamente non fa parte dell’Event Loop, ma che viene anch’essa usata per posticipare l’esecuzione di una data funzione. La segnatura di questo metodo è analoga a quella della funzione setImmediate().

function saluta(nome) {
  console.log(`Ciao, ${nome}!`);
}
process.nextTick(saluta, 'Lisa'); // Ciao, Lisa!

La particolarità di process.nextTick() consiste nell’avere una propria coda di funzioni callback che vengono eseguite al termine di ciascuna fase dell’Event Loop, prima che quest’ultimo possa passare alla fase successiva.

È consigliato usare setImmediate() quando possibile al posto di process.nextTick() anche perché usando quest’ultima in modo errato, specie nel caso di chiamate ricorsive, possono sorgere vari problemi, tra cui il rischio che non venga data la possibilità all’Event Loop di transitare nella fase di Poll per prelevare nuovi eventi di I/O. Vediamo un esempio in cui usiamo entrambi le funzioni ricorsivamente.

function chiamaRicorsivamenteProcessNextTick(contatore, limite = 5) {
  if (limite === contatore) {
    return;
  }
  process.nextTick(function() {
    console.log(`${contatore}) funzione process.nextTick`);
    chiamaRicorsivamenteProcessNextTick(++contatore);
  });
}

setTimeout(function cb() {
  console.log('Funzione cb() invocata da setTimeout()');
}, 0)

/* 
* il valore 0 verrà passato alla funzione chiamaRicorsivamenteProcessNextTick
* come primo argomento
*/
process.nextTick(chiamaRicorsivamenteProcessNextTick, 0);

Lanciando lo script tramite il comando node <nome-del-file.js>, otterremo un output simile a quello riportato in basso.

0) funzione process.nextTick
1) funzione process.nextTick
2) funzione process.nextTick
3) funzione process.nextTick
4) funzione process.nextTick
Funzione cb() invocata da setTimeout()

Ripetendo lo stesso esempio ma con la funzione setImmediate, (notate che in questo caso invochiamo prima setImmediate e poi setTimeout) avremo un risultato simile a quello sottostante.

function chiamaRicorsivamenteSetImmediate(contatore, limite = 5) {
  if (limite === contatore) {
    return;
  }
  setImmediate(function() {
    console.log(`${contatore}) funzione setImmediate`);
    chiamaRicorsivamenteSetImmediate(++contatore);
  });
}

/* 
* il valore 0 verrà passato alla funzione chiamaRicorsivamenteSetImmediate
* come primo argomento
*/
setImmediate(chiamaRicorsivamenteSetImmediate, 0);

setTimeout(function cb() {
  console.log('Funzione cb() invocata da setTimeout()');
}, 0)
Funzione cb() invocata da setTimeout()
0) funzione setImmediate
1) funzione setImmediate
2) funzione setImmediate
3) funzione setImmediate
4) funzione setImmediate

Facciamo ancora una volta riferimento alle funzioni chiamaRicorsivamenteSetImmediate e chiamaRicorsivamenteProcessNextTicke invochiamo le funzioni come nel frammento di codice riportato in basso.

setImmediate(chiamaRicorsivamenteSetImmediate, 0);

setTimeout(function cb() {
  console.log('Funzione cb() invocata da setTimeout()');
}, 0)

process.nextTick(chiamaRicorsivamenteProcessNextTick, 0);

Otterremo ancora una volta, un risultato simile a quello riportato di seguito.

0) funzione process.nextTick
1) funzione process.nextTick
2) funzione process.nextTick
3) funzione process.nextTick
4) funzione process.nextTick
Funzione cb() invocata da setTimeout()
0) funzione setImmediate
1) funzione setImmediate
2) funzione setImmediate
3) funzione setImmediate
4) funzione setImmediate

Ciò nonostante, l’uso di process.nextTick() può risultare vantaggioso in varie occasioni. In Node.js riveste un ruolo fondamentale il modulo events e la ‘classe’ EventEmitter. (Approfondiremo l’argomento in uno dei prossimi articoli.) Per ora ci interessa solo sapere che ogni oggetto di tipo Event Emitter ha due metodi: emit() e on(). Attraverso il metodo on(nomeEvento, listener) registriamo una funzione callback (listener) da invocare ogni volta che viene lanciato l’evento nomeEvento tramite la funzione

emit(nomeEvento, [...argomentiDaPassareAlleCallbackListener])

Il tutto avviene in maniera simile al Pub/Sub pattern. Notate bene che questi tipi di eventi e la ‘classe’ Event Emitter non hanno nulla a che fare con il sistema precedentemente descritto dell’Event Loop e gli eventi del sistema operativo. In questo caso avviene tutto all’interno del codice Javascript. Quando viene chiamata la funzione emit(), tutti i listener registrati fino a quel momento con la funzione on(), vengono invocati uno alla volta in maniera sincrona.

Proprio per questo motivo possiamo posticipare l’esecuzione della funzione emit() con l’ausilio di process.nextTick. Consideriamo allora il frammento di codice riportato qui in basso.

const EventEmitter = require('events');

function lanciaEvento(nomeEvento) {
  console.log('1) Sto per emettere un evento...');
  const eventEmitter = new EventEmitter();
  eventEmitter.emit(nomeEvento);
  console.log(`2) Evento '${nomeEvento}' emesso!!!`);
  console.log('n==============================================n');
  console.log('Elenco listener registrati per questo evento:n');
  return eventEmitter;
}

const evento = 'esempio';

const eventEmitter = lanciaEvento(evento);

// Le funzioni listener registrate non verranno mai eseguite!
eventEmitter.on(evento, () => console.log('Listener 1'));
eventEmitter.on(evento, () => console.log('Listener 2'));

Riportiamo di seguito il risultato dell’esecuzione di questo script.

1) Sto per emettere un evento...
2) Evento 'esempio' emesso!!!

==============================================

Elenco listener registrati per questo evento:

In questo esempio, eseguiamo la funzione lanciaEvento(nomeEvento) a cui passiamo il nome dell’evento da lanciare. Questa, dopo aver lanciato l’evento attraverso il metodo emit(), restituisce un’istanza della ‘classe’ EventEmitter che useremo per registrare due funzioni listener con il metodo eventEmitter.on(). Tali funzioni non verranno mai eseguite però, perché nel momento in cui sono state registrate, è già stato emesso l’evento ‘esempio’. E come potete osservare, non compare nessun output nell’elenco delle funzioni listener. Per rimediare a questo tipo di situazioni, possiamo usare la funzione process.nextTick().

const EventEmitter = require('events');

function lanciaEvento(nomeEvento) {
  console.log('1) Sto per emettere un evento...');
  const eventEmitter = new EventEmitter();

  // eventEmitter.emit(nomeEvento);

  /*
  * usiamo process.nextTick() a cui passiamo un riferimento alla funzione
  * eventEmitter.emit()
  *
  * Per maggiori informazioni sulla funzione Function.prototype.bind()
  * potete consultare il seguente link:
  * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
  */

  process.nextTick(eventEmitter.emit.bind(eventEmitter, nomeEvento));

  console.log(`2) Evento '${nomeEvento}' emesso!!!`);
  console.log('n==============================================n');
  console.log('Elenco listener registrati per questo evento:n');
  return eventEmitter;
}

const evento = 'esempio';

const eventEmitter = lanciaEvento(evento);

eventEmitter.on(evento, () => console.log('Listener 1'));
eventEmitter.on(evento, () => console.log('Listener 2'));

Eseguendo nuovamente lo script, otteniamo il risultato sperato.

1) Sto per emettere un evento...
2) Evento 'esempio' emesso!!!

==============================================

Elenco listener registrati per questo evento:

Listener 1
Listener 2

Conclusioni

Nella prossima lezione parleremo di un altro argomento fondamentale. Vedremo come organizzare il codice di un’applicazione facendo uso dei moduli, della funzione require() e module.exports.

Pubblicitร