Generata usando Leonardo AI

Non Conosci l’EventLoop di Node.js

La Guida Definitiva per Comprendere l’EventLoop in Node.js

--

Questa è la traduzione italiana dell’articolo “You Don’t Know Node.js EventLoopscritto da Nairi Harutyunyan.

Potresti chiederti se il titolo di questo articolo sia un po’ ambizioso, ma non temere! Ho dedicato tempo ed impegno per coprire tutto ciò che devi sapere sull’EventLoop in Node.js. Credimi, scoprirai tante cose nuove lungo il percorso!

Prima di iniziare questo articolo, vorrei informarti che è importante avere una comprensione basilare diNode.js e del suo EventLoop per capire appieno i concetti che tratteremo.

Per essere completata questa guida richiede diverso tempo al fine di coprire ogni dettaglio che è necessario conoscere, quindi prendi una tazza di caffè e preparati per un emozionante viaggio nel mondo affascinante di Node.js. Cominciamo!

Ecco l’elenco degli argomenti trattati in questo articolo.

  • Che cos’è Node.js
  • Pattern Reactor
  • Architettura di Node.js
  • Code degli Eventi (I/O Polling, Macrotasks, Microtasks)
  • Cambiamenti da Node v11
  • CommonJS vs Moduli ES
  • Libuv (pool di thread)
  • Il DNS è problematico in Node.js
  • Promises Custom — Bluebird
  • Riepilogo

Che cos’è Node.js

Se consultassi la documentazione ufficiale, troveresti una breve spiegazione:

Node.js è un runtime JavaScript costruito utilizzando il motore JavaScript V8 di Chrome. Utilizza un modello di I/O basato su eventi, non bloccante, che lo rende leggero ed efficiente.

Beh, questa breve spiegazione non ci dice molto vero?

Ci sono molti importanti concetti legati all’EventLoop in Node.js che richiedono una spiegazione più approfondita.

Esploriamoli insieme!

Pattern Reactor

Modello basato sugli eventi

Node.js è scritto utilizzando il pattern Reactor, che fornisce ciò che comunemente viene definito un modello basato sugli eventi.

Quindi, come funziona il pattern Reactor nella programmazione basata sugli eventi?

Supponiamo di avere una richiesta di I/O, in questo esempio è un’azione che coinvolge il file system.

fs.readFile('./file.txt', callback);

Quando eseguiamo una chiamata di funzione che coinvolge un’operazione di I/O, la richiesta viene indirizzata all’EventLoop, che conseguentemente la passa all’Event Demultiplexer.

I/O sta per Input/Output e si riferisce alla comunicazione con l’unità centrale di elaborazione (CPU) del computer.

Dopo aver ricevuto la richiesta dall’EventLoop, l’Event Demultiplexer decide quale tipo di operazione di I/O hardware deve essere eseguita; nel caso di una lettura da file system, l’operazione viene delegata all’unità appropriata, che leggerà il file.

Una specifica funzione C/C++ leggerà il file richiesto e restituirà il contenuto all’Event Demultiplexer.

uv__fs_read(req)

Quando l’operazione verrà completata, l’Event Demultiplexer genererà un nuovo evento e lo aggiungerà alla coda degli eventi dove potrà essere aggiunto con eventi simili.

Una volta che lo Stack delle Chiamate JavaScript sarà vuoto, l’evento verrà processato dalla coda degli eventi e la nostra funzione di callback verrà eseguita.

Rappresentazione del modello basato su eventi.

Né l’Event Demultiplexer né l’Event Queue sono singoli componenti. Questa è una visione astratta. Ad esempio, l’implementazione dell’Event Demultiplexer e dell’Hardware I/O può variare a seconda del sistema operativo. Inoltre, l’Event Queue non è una singola coda ma è composta da diverse code.

  • Come è reso possibile questo flusso?

libuv

L’EventLoop in Node.js è fornito dalla libreria libuv, scritta in linguaggio C specificamente per Node.js. Esso offre la capacità di lavorare con il sistema operativo utilizzando l’I/O asincrono.

https://libuv.org/
  • Dove si inserisce nel’architettura di Node.js?

Architettura di Node.js

Utilizzando JavaScript, interagiamo con il sistema operativo; per essere precisi, JavaScript è un linguaggio di alto livello che si limita a eseguire operazioni di base, come la creazione di variabili, cicli e funzioni. Da solo, non può fare molto di più.

  • Come si relaziona con il sistema operativo?

Molti linguaggi di programmazione interagiscono direttamente con il sistema operativo, pertanto, se integriamo JavaScript con quei linguaggi, possiamo essenzialmente lavorare con il sistema operativo utilizzando JavaScript.

Questo è come attualmente implementato

Rappresentazione dell’architettura di Node js

Il livello intermedio (Node.js) si occupa del nostro codice JavaScript e interagisce con il sistema operativo. Ora discutiamo dei componenti dell’architettura di Node.js.

V8

Questo è l’engine che analizza ed esegue il nostro codice JavaScript.

libuv

Questa è la libreria di cui abbiamo parlato in precedenza, fornisce l’EventLoop e la maggior parte delle interazioni necessarie per lavorare con il sistema operativo.

Moduli core

Node.js fornisce vari moduli nativi, come fs, http e crypto; questi sono chiamati moduli nativi e includono codice sorgente JavaScript.

Interfacce C++

In Node.js, abbiamo un’API che ci consente di scrivere codice C++, compilarlo e utilizzarlo in JavaScript come modulo. Questi sono chiamati addons. Anche i moduli core possono avere i loro addons.

Node.js fornisce un compilatore che genera addons, che essenzialmente crea un file .node che può essere richiesto.

require('./my-cpp-module.node');

La funzione “require” in Node.js dà priorità al caricamento dei file .js e .json, seguiti dai file addon, con estensione .node

c-ares, zlib, ecc

Ci sono anche librerie più piccole scritte in C/C++ che forniscono operazioni specifiche, come la compressione dei file, le operazioni DNS e altro ancora.

https://github.com/nodejs/node/tree/main/deps

Non finisce qui, ritorneremo su libuv più avanti in questo articolo, per ora proseguiamo sul resto.

Code degli Eventi

L’EventLoop è un meccanismo che gestisce continuamente gli eventi in un singolo thread, fino all’esaurimento di essi. Viene spesso chiamato “ciclo quasi infinito” perché viene eseguito indefinitamente finché non ci sono più eventi da gestire o si verifica un errore.

Rappresentazione dell’EventLoop #1

Come accennato in precedenza, l’EventLoop è composto da code differenti, ognuna con il proprio livello di priorità. Nelle prossime sezioni approfondiremo nel dettaglio questi livelli.

Una volta che lo Stack delle chiamate è vuoto, l’EventLoop verificherà le code, attendendo l’arrivo di un evento da eseguire; per prima cosa verificherà gli eventi legati ai timer.

Rappresentazione dell’EventLoop #2

Se una funzione setTimeout o setInterval ha completato l’esecuzione, il Demultiplexer inserirà un evento nella coda dei Timer.

Supponiamo di avere una funzione setTimeout programmata per essere eseguita tra un’ora; ciò significa che dopo un’ora il Demultiplexer inserirà un evento in questa specifica coda.

Quando la coda ha degli eventi, l’EventLoop eseguirà le funzioni di callback corrispondenti, fino a quando essa non sarà vuota, per poi passare al controllo delle altre.

Rappresentazione dell’EventLoop #3

In seconda posizione abbiamo la coda I/O che è responsabile della maggior parte delle operazioni asincrone come: interazioni con il file System, la rete e altro ancora. Subito dopo abbiamo la coda Immediate che è responsabile delle chiamate setImmediate; questa ci permette di pianificare operazioni che dovrebbero essere eseguite dopo le operazioni di I/O.

Rappresentazione dell’EventLoop #4

Infine abbiamo una coda specifica chiamata close events per gli eventi di chiusura.

Rappresentazione dell’EventLoop #5

Essa è responsabile di gestire tutte le connessioni che hanno un evento di interruzione, come le connessioni ai database e le connessioni TCP.

In ogni ciclo, l’EventLoop controlla queste code per determinare se ci sono eventi da eseguire. Di solito, l’EventLoop impiega solo pochi millisecondi per esaminarle e verificare la presenza di nuovi eventi. Tuttavia, se esso è occupato, potrebbe richiedere più tempo. Fortunatamente, ci sono molte librerie disponibili su npm che consentono di misurare la durata del ciclo dell’EventLoop.

Potresti pensare che abbiamo già coperto la maggior parte di questo argomento, invece mi dispiace comunicarti che è solo l’inizio.

Gli eventi inseriti nelle code discusse in precedenza sono anche chiamati Macrotasks.

Ci sono due tipi di attività nell’EventLoop: Macrotasks e Microtasks. Discuteremo più avanti dei microtasks.

💡 Suggerimento professionale: Macrotasks e Microtasks possono essere trasformati in piccoli componenti che puoi riutilizzare e condividere tra diversi progetti utilizzando uno strumento open-source come Bit. Ciò può ridurre significativamente la duplicazione del codice e migliorarne la qualità, rendendolo modulare e più mantenibile.

Approfondisci ulteriormente qui:

Extracting and Reusing Pre-existing Components using bit add

Ora diamo un’occhiata a come l’EventLoop determina quando è il momento di arrestare il processo Node.js perché non ci sono più eventi da gestire.

L’EventLoop monitora un contatore, che inizia da zero all’inizio del processo e ogni volta che è presente un’operazione asincrona viene incrementato di uno. Ad esempio, se avessimo un’operazione setTimeout o readFile, ognuna di queste funzioni incrementerebbe il contatore di uno

Rappresentazione dell’EventLoop #6

Quando un evento viene inserito nella coda e l’EventLoop esegue la callback per quello specifico Macrotask, verrà eseguito anche il decremento del contatore.

Rappresentazione dell’EventLoop con contatore

Dopo aver elaborato la coda dei Closed Events, l’EventLoop verificherà il contatore. Se è zero, significa che non ci sono operazioni in corso e il processo può terminare. Se, invece, il contatore non è zero, significa che ci sono ancora operazioni in corso. Di conseguenza, l’EventLoop continuerà il suo ciclo fino al completamento di tutte le operazioni, con conseguente azzeramento del contatore.

OK, basta teoria, facciamo un po’ di pratica!

const fs = require('fs');

setTimeout(() => {
console.log('hello');
}, 50);


fs.readFile(__filename, () => {
console.log('world');
});

Diamo un’occhiata a un semplice esempio con un timeout e una lettura del file system per capire meglio come funziona l’EventLoop nella pratica

  1. Quando viene avviato il processo Node, V8 analizza il codice JavaScript per poi eseguire la funzione setTimeout. Questo attiva una funzione C/C++ (C_TIMEOUT) eseguita all’interno di libuv, incrementando di conseguenza il contatore (refs++).
  2. Quando V8 arriva alla funzione readFile, esegue lo stesso flusso, Libuv avvia l’operazione di lettura dei file (C_FS), che aumenta nuovamente il contatore (refs++).
  3. Ora V8 non ha più nulla da eseguire e l’EventLoop assume il controllo. Inizia controllando ogni coda fino a quando il contatore raggiungerà lo zero.
  4. Alla conclusione di C_TIMEOUT, un evento viene registrato nella coda dei timer. Quando l’EventLoop controlla nuovamente la coda dei timer, rileva l’evento ed esegue la callback corrispondente, causando la visualizzazione del messaggio “hello” nella console. Il contatore viene quindi decrementato nuovamente e in questo modo l’EventLoop può proseguire nel suo lavoro.
  5. In un certo momento, a seconda delle dimensioni del file, l’operazione C_FS viene completata e un evento viene registrato nella coda delle operazioni di I/O. Ancora una volta, l’EventLoop rileva l’evento ed esegue la callback corrispondente, che stampa il messaggio “world” nella console. Il contatore viene nuovamente decrementato e l’EventLoop prosegue.
  6. Infine, dopo aver controllato la coda dei Close Events, l’EventLoop controlla il contatore, dal momento che è zero, il processo Node viene terminato.

La visualizzazione del diagramma consente di comprendere facilmente il funzionamento del codice asincrono così da non avere più dubbi sui risultati attesi.

Abbiamo discusso principalmente di Macrotasks, ora è il momento di parlare di un altro importante tema.

Polling I/O

Questo processo spesso confonde coloro che tentano di approfondire l’EventLoop. Molti articoli lo menzionano come parte di esso, ma pochi spiegano cosa fa nel dettaglio. Anche nella documentazione ufficiale è difficile capirne il ruolo.

Diamo un’occhiata a questo esempio.

const fs = require('fs')
const now = Date.now();

setTimeout(() => {
console.log('hello');
}, 50);

fs.readFile(__filename, () => {
console.log('world');
});

setImmediate(() => {
console.log('immediate');
});

while(Date.now() - now < 2000) {} // 2 second block

Abbiamo tre operazioni: setTimeout, readFile e setImmediate. Alla fine, utilizziamo un ciclo while che sospende il thread per due secondi. Durante questo intervallo, tutti e tre gli eventi corrispondenti devono essere inseriti nelle rispettive code. Ciò significa che al termine dell’esecuzione del while da parte di V8, l’EventLoop dovrebbe avere visibilità di tutti gli eventi nello stesso ciclo e, seguendo il diagramma, eseguire le callback nell’ordine seguente

hello
world
immediate

Tuttavia il risultato effettivo è questo:

hello
immediate
world

Questo accade perché esiste un processo aggiuntivo chiamato I/O Polling. A differenza degli altri tipi di eventi, quelli di I/O vengono inseriti nella coda solo in un punto specifico del ciclo. Questo è il motivo per cui la funzione di callback del setImmediate() viene eseguita prima rispetto quella del readFile(), anche se entrambe sono pronte al termine del ciclo while.

Il problema è che il controllo della coda degli eventi di I/O verifica solo le callback già presenti nella coda degli eventi; esse non vengono inserite automaticamente alla fine, ma al contrario vengono aggiunte solo successivamente, durante il polling di I/O.

Ecco cosa succederà dopo due secondi, al termine del ciclo while.

  1. L’EventLoop procederà all’esecuzione della callback del timer rilevando che è terminato e pronto per essere eseguito, di conseguenza la eseguirà.
    Nella console, verrà visualizzato “ciao”.
  2. Successivamente, l’EventLoop passerà alla fase di Callback I/O; a questo punto, il processo di lettura del file è terminato, ma la sua callback non è ancora contrassegnata per essere eseguita; ciò avverrà più avanti in questo ciclo. Di conseguenza, l’EventLoop continuerà ad attraversare le altre fasi, fino a raggiungere quella di polling I/O. A questo punto, la callback del readFile() viene aggiunta alla coda di I/O, senza essere però ancora eseguita. È pronta per l’esecuzione, ma EventLoop la eseguirà nel ciclo successivo.
  3. Passando alla fase successiva, l’EventLoop eseguirà la funzione di callback relativa alla funzione setImmediate().
    Nella console, vedremo “immediate”.
  4. L’EventLoop ricomincerà nuovamente il giro; poiché non ci sono timer da eseguire, passerà alla fase di callback I/O, dove finalmente troverà ed eseguirà la callback del readFile().
    Nella console, ci sarà il log “world”.

Questo esempio può essere un po’ difficile da capire, ma fornisce informazioni preziose sul processo di polling I/O. Se si rimuovesse il ciclo while di due secondi, si noterebbe un risultato diverso.

immediate
world
hello

setImmediate() verrebbe lanciato nel primo ciclo dell’EventLoop quando né il processo di setTimeout né quello del File Systems sono terminati. Dopo un certo lasso di tempo, il timeout terminerebbe e l’EventLoop eseguirebbe la callback corrispondente. In un secondo momento, alla conclusione della lettura del file, l’EventLoop eseguirebbe anche la sua callback.

Tutto dipende dal ritardo dei timeout e dalla dimensione del file; se il file è di grandi dimensioni il completamento del processo di lettura richiederà più tempo. Analogamente, se il delay del timeout è lungo, il processo di lettura del file potrebbe essere completato prima di esso. Tuttavia, la callback relativa al setImmediate() è fissa e verrà sempre registrata nella coda degli eventi durante la sua esecuzione da parte del motore V8.

Discutiamo altri esempi interessanti che ci aiuteranno a prendere confidenza con il flusso.

Diagramma coda degli eventi (Macrotasks)

setTimeout & setImmediate

In questo esempio, abbiamo un timeout con un ritardo di zero secondi e una funzione setImmediate. Si tratta di una domanda complessa, ma rispondendo correttamente puoi fare una buona impressione durante un colloquio.

setTimeout(() => {
console.log('setTimeout');
}, 0);

setImmediate(() => {
console.log('setImmediate');
});

In questo caso non è possibile stabilire quale evento verrà registrato per primo.

Il risultato nel terminale #1

Non è possibile stabilire l’ordine in cui verranno eseguiti perché a volte un processo può richiedere più tempo (si tratta di circa millisecondi) per essere eseguito, portando l’EventLoop a superare la coda dei timer mentre questa è ancora vuota. Un altro scenario potrebbe vedere l’EventLoop troppo rapido, portando il Demultiplexer a non riuscire a registrare l’evento nella coda degli eventi in tempo. Di conseguenza, eseguendo questo esempio più volte, è possibile ottenere risultati diversi ogni volta.

setTimeout e setImmediate all’interno di una callback fs

A differenza dell’esempio precedente, il risultato di questo codice è prevedibile. Prenditi un momento per esaminare il codice e considerare l’ordine in cui verranno visualizzati i logs, utilizzando il diagramma come riferimento.

const fs  = require('fs');

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);

setImmediate(() => {
console.log('setImmediate');
});
});

Poiché setTimeout e setImmediate e sono definite all’interno della funzione readFile, siamo consapevoli del fatto che quando la callback verrà eseguita significherà che l’EventLoop si trova nella fase di I/O, quindi, nell’ordine discusso, il prossimo è la coda dei setImmediate. Poiché il setImmediate viene immediatamente registrato in coda, non stupisce il fatto che i logs avranno sempre questo ordine

setImmediate
setTimeout

Ora che abbiamo acquisito una buona conoscenza dei Macrotasks e del flusso svolto dall’EventLoop, continuiamo ad approfondire ulteriormente l’argomento.

Innanzitutto, miglioriamo leggermente il nostro diagramma aggiungendo la rappresentazioni delle fasi inerenti alla run di codice JavaScript.

Diagramma coda degli eventi (Con l’inclusione delle fasi di codice js)

Abbiamo una singola esecuzione JavaScript quando avviamo il processo di Node.js. Successivamente, quando non c’è più nulla da eseguire, V8 attende che l’Event Loop riceva un evento e pianifichi l’esecuzione della callback corrispondente. Come avrai notato, c’è una fase di esecuzione JavaScript dopo ciascuna fase della coda. Ad esempio, nel diagramma, l’esecuzione della callback per il timeout avviene durante la seconda fase JavaScript.

Finora la nostra discussione si è concentrata sui Macrotasks, che non includevano alcuna informazione su Promise e process.nextTick. Questi prendono il nome di Microtasks.

Durante ogni fase JavaScript, viene eseguita un’elaborazione aggiuntiva.

Diagramma: Microtasks

Questi due tipi di microtasks hanno code dedicate; inoltre ci sono anche altri schedulatori di microtask, chiamati MutationObserver e queueMicrotask, tuttavia noi ci concentreremo su nextTick e Promise.

Una volta che V8 avrà eseguito interamente il codice JavaScript, procederà a controllare la coda dei microtask, proprio come avviene con quella dei macrotask. Se nella coda dei microtask è presente un evento, verrà elaborato e, di conseguenza, eseguita la callback corrispondente

Diagramma completo: Macrotasks e Microtasks

Nel diagramma, il colore grigio chiaro rappresenta la coda dei process.nextTick(), che detiene la priorità più alta tra le attività schedulate; Il colore grigio scuro invece, rappresenta la coda delle Promise, che segue in termini di priorità.

Vediamo alcuni esempi.

process.nextTick & Promise

Questo è un esempio base che mostra il flusso dei Microtasks.

console.log(1);

process.nextTick(() => {
console.log('nextTick');
});

Promise.resolve()
.then(() => {
console.log('Promise');
});

console.log(2);

Parlando della sequenza dell’output, le callback dei process.nextTick() verranno sempre eseguite prima delle callback relative alle Promise.

Durante l’esecuzione del processo, V8 inizierà con il primo console.log per poi procedere con l’esecuzione della funzione nextTick, che registrerà un evento nella coda corrispondente. Un processo simile si verifica con la Promise, dove la sua callback verrà inserita in una coda separata.

Dopo che V8 avrà completato l’esecuzione dell’ultima funzione, con conseguente output di due, passerà all’eseguire gli altri eventi nelle code.

process.nextTick() è una funzione che permette di eseguire una callback immediatamente dopo il completamento dell’operazione corrente, ma prima che l’EventLoop proceda alla fase successiva.

Quando process.nextTick() viene richiamato, la callback assegnata è aggiunta alla coda dei nextTick, che detiene la priorità più alta tra le attività schedulate all’interno dell’EventLoop; per questo motivo essa verrà eseguita prima di qualsiasi altro tipo di attività, incluse le Promise o altri microtask.

L’utilizzo principale di process.nextTick() è per operazioni ad alta priorità oppure che richiedono un’esecuzione rapida, evitando di doverne attenderne altre in sospeso. Tuttavia, è essenziale prestare molta attenzione con l’utilizzo di process.nextTick() al fine di evitare il blocco dell’EventLoop, che causerebbero di conseguenza problemi in termini di performance.

Finché rimarrà almeno un evento nella coda dei Microtasks, l’EventLoop continuerà a assegnargli la priorità rispetto alla coda dei timer.

Se eseguissimo ricorsivamente il process.nextTick(), l’EventLoop non raggiungerebbe mai la coda dei timer e le callback corrispondenti non verrebbero mai eseguite.

function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}

recursiveNextTick();

setTimeout(() => {
console.log('This will never be executed.');
}, 0);

Nel codice sopra, la funzione recursiveNextTick() viene invocata ricorsivamente utilizzando process.nextTick(). Questo porta l’EventLoop a processare continuamente la coda dei nextTick, senza mai consentirgli di raggiungere la coda dei timer.

Per questo motivo la callback passata al setTimeout non verrà mai eseguita e il console.log all’interno non verrà mai stampato.

Allo stesso modo, il medesimo scenario si verificherebbe se utilizzassimo in ricorsivamente altri Microtasks. L’EventLoop sarebbe costantemente impegnato nell’elaborazione della coda dei microtasks, senza la possibilità di accedere alla coda dei timer o di eseguire altre attività.

Perciò la callback del setTimeout non verrà eseguita e l’istruzione console al suo interno non verrà stampata.

function recursiveMicrotask() {
Promise.resolve().then(recursiveMicrotask);
}

recursiveMicrotask();

setTimeout(() => {
console.log('This will never be executed.');
}, 0);

Questo può portare ad un blocco dell’EventLoop, il quale potrebbe potrebbe portare a schedulazioni inaccurate in termini di tempo per i timeouts, oppure perfino ad evitarne completamente l’esecuzione.

setTimeout(() => {
console.log('setTimeout');
}, 0);

let count = 0;

function recursiveNextTick() {
count += 1;

if (count === 20000000)
return; // finish recursion

process.nextTick(recursiveNextTick);
}

recursiveNextTick();

Come puoi osservare, il timeout programmato per essere eseguito a 0 millisecondi è stato invece eseguito dopo 2 secondi.

Quindi fai attenzione con i Microtasks!

Come rapido esercizio, proviamo a prevedere l’output del seguente codice.

process.nextTick(() => {
console.log('nextTick 1');

process.nextTick(() => {
console.log('nextTick 2');

process.nextTick(() => console.log('nextTick 3'));
process.nextTick(() => console.log('nextTick 4'));
});

process.nextTick(() => {
console.log('nextTick 5');

process.nextTick(() => console.log('nextTick 6'));
process.nextTick(() => console.log('nextTick 7'));
});

});

Ecco la spiegazione:
Quando questo codice verrà eseguito verranno pianificate una serie di callback nidificate process.nextTick

  1. L’iniziale callback di process.nextTick è eseguita per prima, effettuando il log di ‘nextTick 1’ nella console.
  2. All’interno di questa callback, sono schedulate altre due callback: una che effettua il log di ‘nextTick 2’ e un’altra ‘nextTick 5’.
  3. La callback ‘nextTick 2’ verrà eseguita subito dopo, mostrando ‘nextTick 2’ nella console.
  4. All’interno di questa callback sono pianificate altre due process.nextTick, una che effettua il log di ‘nextTick 3’ e un’altra di ‘nextTick 4’.
  5. La callback assegnata come ‘nextTick 5’ verrà eseguita dopo ‘nextTick 2’, visualizzando ‘nextTick 5’ nella console.
  6. All’interno di questa callback, sono schedulate ulteriormente altre due process.nextTick; una che effettua il log di ‘nextTick 6’ e un’altra di ‘nextTick 7’.
  7. Infine, le rimanenti callback di process.nextTick, vengono eseguite nell’ordine in cui sono state pianificate, restituendo ‘nextTick 3’, ‘nextTick 4’, ‘nextTick 6’ e ‘nextTick 7’ nella console.

Ecco una panoramica di come verrà strutturata la coda durante l’esecuzione.

Proess started: [ nT1 ]
nT1 executed: [ nT2, nT5 ]
nT2 executed: [ nT5, nT3, nT4 ]
nT5 executed: [ nT3, nT4, nT6, nT7 ]
// ...

Nel frattempo, è utile sottolineare che fare riferimento ai diagrammi riportati in precedenza sarà di grande aiuto nella comprensione della logica sottostante.

Diagramma: Microtasks

Microtasks & Macrotasks nella pratica

Diagramma completo: Macrotasks e Microtasks

Per i prossimi esercizi, dovrai lavorare con il diagramma per capire interamente il concetto.

process.nextTick(() => {
console.log('nextTick');
});

Promise.resolve()
.then(() => {
console.log('Promise');
});

setTimeout(() => {
console.log('setTimeout');
}, 0);

setImmediate(() => {
console.log('setImmediate');
});

Questo dovrebbe essere facile!

Ecco una spiegazione del comportamento di ciascuna di queste funzioni:

  1. process.nextTick: questa funzione pianifica una callback immediatamente dopo il completamento dell’esecuzione in corso. Nel codice, la callback effettua il log di ‘nextTick’ nella console.
  2. Promise: Il promise.resolve crea una promise già risolta, e la funzione associata nel .then pianifica l’esecuzione di una callback. Nel codice, la callback all’interno del .then() effettua il log di Promise nella console.
  3. setTimeout: questa funzione schedula l’esecuzione di una callback come macrotask, dopo una quantità di tempo specificata. Nel codice, la callback visualizza ‘setTimeout’ nella console. Sebbene il Delay sia impostato a 0 millisecondi, verrà comunque accodata come macrotask e di conseguenza verrà eseguita dopo qualsiasi microtask in sospeso (nextTicks, Promises).
  4. setImmediate: Analogamente al timeout, anche questa funzione pianifica una callback che verrà eseguita come macrotask.

L’ordine di esecuzione seguirà questa sequenza:

  1. process.nextTick
  2. Promise
  3. setTimeout
  4. setImmediate

È importante sottolineare che l’EventLoop elabora i microtasks (come process.nexTick e le Promise ) prima dei macrotasks (come setTimeout e setImmediate), rispettando l’ordine di ciascuna categoria

Ok, ora vediamo qualcosa di più impegnativo.

const fs  = require('fs');

fs.readFile(__filename, () => {
process.nextTick(() => {
console.log('nextTick in fs');
});

setTimeout(() => {
console.log('setTimeout');

process.nextTick(() => {
console.log('nextTick in setTimeout');
});
}, 0);

setImmediate(() => {
console.log('setImmediate');

process.nextTick(() => {
console.log('nextTick in setImmediate');

Promise.resolve()
.then(() => {
console.log('Promise in setImmediate');
});
});
});
});

Sembra spaventoso, non è vero? Sarebbe ancora peggio se si trattasse di un colloquio…

Bene, ricorda solo il diagramma e tutto sarà molto più semplice.

Quando V8 eseguirà il codice, inizialmente sarà presente una sola operazione, ovvero fs.readFile(). Durante l’elaborazione di questa operazione, l’EventLoop inizia controllando ogni coda, e continuando fino a quando il contatore (spero che te lo ricordi) raggiungerà lo 0, a quel punto terminerà il processo.

Alla fine, l’operazione di lettura del File System verrà completata e l’EventLoop la rileverà durante il polling di I/O. All’interno della funzione di callback ci sono tre nuove operazioni: nextTick, setTimeout, e setImmediate

Ora, concentriamoci sulle priorità.

Dopo ogni coda di Macrotask, verranno eseguiti i nostri Microtask; ciò significa “nextTick in fs” verrà visualizzato. Dato che a questo punto le code dei Microtask saranno vuote, l’EventLoop proseguirà. Nella fase successiva c’è la coda degli immediate, di conseguenza “setImmediate” verrà mostrato e in aggiunta verrà anche registrato un evento nella coda nextTick.

Ora, quando non rimarranno eventi immediate, JavaScript inizierà a controllare le code dei Microtask; di conseguenza, verrà effettuato il log di “nextTick in setImmediate” e contemporaneamente verrà aggiunto un evento alla coda delle Promise. Poiché la coda dei nextTick è vuota, JavaScript procederà a controllare la coda delle Promise, dove l’evento appena registrato restituirà “Promise in setImmediate”.

In questa fase, tutte le code dei Microtask sono vuote, quindi l’EventLoop procederà al passaggio successivo, dove troverà un evento all’interno della coda dei timer.
Per concludere “setTimeout” e “nextTick in setTimeout” verranno visualizzati con la stessa logica discussa.

È possibile migliorare ulteriormente la comprensione dei microtasks e dei macrotasks impegnandosi in esercizi simili in modo indipendente. In questo modo è possibile ottenere informazioni dettagliate su come funzionano queste attività e migliorare la propria capacità di anticipare il risultato atteso.

È sufficiente usare questo diagramma e non dimenticare la fase di polling I/O (che è un caso specifico)!

Diagramma completo: Macrotasks e Microtasks

A proposito, se guardassi il codice sorgente di node.js in GitHub, noteresti che i microtask sono a livello del codice JavaScript, di conseguenza è facile capire e vedere tutte le logiche di cui stiamo discutendo… perché è JavaScript, non C++.

Cambiamenti da Node v11

setTimeout(() => console.log('Timeout 1'));
setTimeout(() => {
console.log('Timeout 2');
Promise.resolve().then(() => console.log('promise resolve'));
});
setTimeout(() => console.log('Timeout 3'));

Effettuando il run di questo esempio in un web browser, il risultato sarà il seguente

Timeout 1
Timeout 2
promise resolve
Timeout 3

Al contrario, nelle versioni Node precedenti alla 11.0.0, l’output ricevuto sarebbe stato questo:

Esempio usando Node V10

Con questa implementazione Node.js, process.nextTick, promise e le altre callback dei microtask vengono eseguiti durante le transizioni in ogni fase dell’EventLoop; di conseguenza, durante quella dei timers, tutte le callback relative ad essi verranno gestite prima dell’esecuzione delle callback delle Promise. Questo particolare ordine di esecuzione è ciò che produrrà l’output che è stato osservato prima.

All’interno della comunità Node.js si sono svolti ampi dibattiti riguardo alla necessità di affrontare questo problema e di adeguare il comportamento agli standard web, con l’obiettivo di garantire coerenza tra gli ambienti Node.js e web.

Esempio usando Node V20

In questo scenario, anziché fare uso di setTimeout, ho inserito un altro macrotask (setImmediate) per espandere l’esempio.

Il rilascio della versione 11 di Node.js, ha apportato modifiche degne di nota, consentendo l’esecuzione delle callback di nextTick e dei microtask tra ogni singolo setTimeout, setImmediate e altri macrotasks.

Questo aggiornamento armonizza il comportamento di Node.js con quello dei browser, migliorando la compatibilità e il riutilizzo del codice JavaScript in entrambi gli ambienti.

Le modifiche apportate dal team di Node.js possono avere un impatto sulla compatibilità delle applicazioni esistenti, quindi è di fondamentale importanza rimanere informati sugli aggiornamenti. Mantenere un’attenzione costante su questo aspetto è essenziale, poiché consente di essere al corrente di eventuali cambiamenti che potrebbero verificarsi. Essere aggiornati sulle modifiche apportate a Node.js offre la possibilità di affrontare in modo proattivo qualsiasi modifica che potrebbe influire sulle proprie applicazioni, garantendo così il loro corretto funzionamento di fronte all’evoluzione delle tecnologie e dei framework.

Moduli CommonJS vs ES

process.nextTick(()=>{
console.log('nextTick');
});

Promise.resolve().then(()=>{
console.log('promise resolve');
});

console.log('console.log');

Questo è un esempio piuttosto semplice che abbiamo discusso in precedenza.

console.log
nextTick
promise resolve

Tuttavia, quando si tenta di utilizzarlo con i moduli ES, si osserverà una notevole differenza.

process.nextTick vs Promises (Moduli ES)

Se conosci come funzionano i moduli ES e quali modifiche apportano, è altamente probabile che ne comprenderai il motivo.

Essi operano in modo asincrono, e confrontando l’utilizzo di require con imports, è evidente una notevole disparità nell’ordine di esecuzione. Il punto cruciale da comprendere è il loro comportamento asincrono, ciò significa che quando il programma inizia, non viene eseguito nel modo convenzionale stabilito da CommonJS.

Questa differenza è la ragione dietro le variazioni osservate nell’ordine dei microtasks.

setImmediate(() => {
process.nextTick(()=>{
console.log('nextTick');
});

Promise.resolve().then(()=>{
console.log('promise resolve');
});

console.log('console.log');
});

Eseguendo le stesse funzioni all’interno di un singolo macrotask, la sequenza rimarrà quella prevista.

process.nextTick vs Promises in Macrotask (Moduli ES)

L’ordine di esecuzione del programma dipende dalla posizione del puntatore, dalla coda in cui è attualmente posizionato, e dalla fase in cui si trova; di conseguenza, l’ordine può variare, il che tiene conto delle differenze osservate.

In CommonJS è possibile caricare moduli ES. Nota che ciò restituisce una promise.

Ora, presumo che tu comprenda che per quanto riguarda i moduli ES, il puntatore è posizionato in cima alla coda delle promise. Questo è il motivo per cui, durante l’avvio del programma, le code delle promise hanno una priorità più alta rispetto a nextTick.

Libuv

Nel contesto del sistema operativo, le operazioni possono essere suddivise in due categorie: bloccanti e non bloccanti. Le operazioni bloccanti richiedono l’allocazione di thread separati per consentire l’esecuzione simultanea di attività diverse. Al contrario, le operazioni non bloccanti permettono di ottenere lo stesso risultato senza la necessità di utilizzare thread aggiuntivi.

Rappresentazione di I/O Bloccante e non Bloccante

Le operazioni relative ai file e al DNS sono bloccanti, il che significa che fermano l’esecuzione del thread fino al loro completamento. Al contrario, le operazioni di rete sono non bloccanti, il che consente a un singolo thread di inviare più richieste contemporaneamente.

Diversi sistemi operativi forniscono meccanismi per l’I/O non bloccante. In Linux, questo meccanismo è chiamato “epoll”, mentre in Windows è noto come “IOCP”, e così via. Questi meccanismi ci consentono di aggiungere gestori e attendere il completamento delle operazioni; essi ci avvisano quando una di esse è stata completata.

Libuv li utilizza per consentire un’elaborazione asincrona delle operazioni di I/O rete, ma il comportamento è diverso quando si tratta di operazioni di I/O bloccanti.

Rifletti su questo: Node.js funziona su un singolo thread con l’EventLoop, che viene eseguito in un ciclo semi-infinito. Tuttavia, quando si tratta di operazioni di I/O bloccanti, Libuv non può gestirle all’interno dello stesso thread.

Pertanto, per questo motivo, Libuv fa uso di un thread pool.

Le attività che richiedono un intenso utilizzo della CPU e le operazioni di I/O bloccanti rappresentano sfide, poiché non possono essere gestite in modo asincrono. Fortunatamente, Libuv ha trovato una soluzione per questo, utilizzando i thread per affrontare tali situazioni in modo efficace.

Libuv: rappresentazione #1

Con una dimensione predefinita del thread pool di 4, Libuv gestisce le operazioni di lettura dei file eseguendole all’interno di uno di questi thread. Una volta completata l’operazione, il thread viene rilasciato e Libuv restituisce la risposta. Questo permette di gestire in modo efficiente le operazioni bloccanti di I/O in modo asincrono, sfruttando il thread pool.

Se tentassimo di eseguire 10 operazioni di lettura dei file, solo 4 di esse verrebbero inizializzate, mentre le altre 6 attenderebbero fino alla disponibilità dei thread per l’esecuzione.

Se dovessimo eseguire numerose operazioni bloccanti di I/O e ritenessimo che la dimensione predefinita del thread pool di 4 sia insufficiente, potremmo facilmente aumentarla.

Libuv: rappresentazione #2

Per farlo è possibile usare questa variabile di ambiente.

UV_THREADPOOL_SIZE=64 node script.js

Quindi in questo caso Libuv creerà un thread pool con 64 thread.

Si noti che la presenza di un numero eccessivo di thread nel pool può causare problemi di prestazioni, questo perché mantenere numerosi thread richiede risorse significative. Pertanto, è importante considerare attentamente le implicazioni prima di utilizzare questa variabile di ambiente.

UV_THREADPOOL_SIZE — Demo #1

Potresti chiederti perché ci siano 71 thread invece di 64. I thread aggiuntivi vengono utilizzati da V8 e da altri componenti per svolgere varie attività, come la garbage collection e l’ottimizzazione del codice. Queste operazioni richiedono risorse, motivo per cui il numero di thread supera i 64 previsti.

È importante sottolineare che se non si utilizzano operazioni bloccanti di I/O, il thread pool non verrà inizializzato. Si osserveranno thread multipli solo se il pool viene effettivamente avviato, il che avviene eseguendo almeno un’operazione bloccante di I/O.

require('fs').readFile(__filename, () => {}); // Blocking I/O

setInterval(() => {}, 3000);

Nel mio esempio, ho utilizzato questo snippet di codice. Basta rimuovere la prima riga e noterai che il numero di thread si riduce notevolmente.

UV_THREADPOOL_SIZE — Demo #2

Il motivo è che l’interval non è un’operazione bloccante di I/O, motivo per cui il thread pool non viene lanciato.

Il DNS è problematico in Node.js

In Node.js, la funzione dns.lookup è un’operazione di I/O bloccante quando si risolvono gli hostname. Se specifici un hostname nella tua richiesta, il processo di ricerca DNS introdurrà un’operazione bloccante, poiché l’implementazione sottostante può fare affidamento su operazioni sincrone.

Tuttavia, se lavori con indirizzi IP o utilizzi il tuo meccanismo di ricerca DNS, hai l’opportunità di rendere il processo completamente asincrono. In questo modo, puoi eliminare potenziali operazioni bloccanti e assicurare un flusso di esecuzione completamente non bloccante nel tuo applicativo Node.js.

http.get("https://github.com", {
lookup: yourCustomLookupFunction
});

Questo è un argomento piuttosto interessante, quindi ti suggerisco di leggere questo articolo.

Promise custom — Bluebird

Perché non native?

Potresti aver notato che le persone utilizzano spesso Promise con implementazioni custom, come Bluebird.js. Tuttavia esso offre molto più di una semplice raccolta di metodi utili; si distingue per le sue funzionalità avanzate che migliorano le prestazioni delle promise.

Rappresentazione delle Promise native

Questa è una semplice rappresentazione di come funzionano le promise native. In sostanza, ogni Microtask ha la sua callback corrispondente.

Con Bluebird, è possibile personalizzare il comportamento delle promise, con la possibilità di portare a prestazioni migliori a seconda dei diversi scenari.

Rappresentazione delle Bluebird Promises

Per impostazione predefinita, Bluebird raggruppa tutte le promesse risolte ed esegue le loro rispettive callback all’interno di un’unica attività, che è riconosciuta come Macrotask. Nel mio esempio, questo avviene all’interno della fase setImmediate, ma per impostazione predefinita in Bluebird è utilizzato setTimeout.

Questo approccio ci consente di evitare il blocco dei thread quando si affrontano molte chiamate di Promise.

Fondamentalmente, nella coda delle attività, ci sarà un evento e la relativa callback includerà tante callback interne quante sono le promise risolte.

Qualcosa del genere.

setImmediate(() => {
promiseResolve1();
promiseResolve2();
promiseResolve3();
promiseResolve4();
});

A proposito, è anche possibile configurare Bluebird in modo che le promise vengano eseguite in una fase diversa.

Promise.setScheduler(function(fn) {
process.nextTick(fn);
});

In questo caso, le promise avranno la massima priorità.

Bluebird utilizza setTimeout(fn, 0) come schedulatore di default, ciò significa che le promise verranno eseguite nella fase dei timer.

Prova da solo e vedrai quanto sia interessante il suo funzionamento.

Sommario

Node.js è in continua evoluzione, con nuovi aggiornamenti e funzionalità rilasciati regolarmente; pertanto è importante rimanere aggiornati seguendone il changelog. In questo modo puoi essere informato sulle ultime introduzioni, così da essere sempre allineato sulla sua architettura e sulle sue funzionalità.

Node.js

È un runtime JS che ci consente di creare applicazioni lato server, i quali possono interagire con il sistema operativo.

Libuv è stato inizialmente sviluppato per Node.js ed è la libreria su cui si basano le fondamenta dell’EventLoop, oltre a fornire altre funzionalità aggiuntive. È progettato per agevolare le operazioni di I/O asincrono su diverse piattaforme, tra cui Windows, Linux e altre, offrendo diverse soluzioni per una gestione efficiente e non bloccante delle operazioni di I/O.

Librerie usate in Node.js

Node.js incorpora una vasta gamma di librerie e componenti essenziali che svolgono un ruolo fondamentale in vari processi e operazioni; questi componenti migliorano notevolmente la funzionalità e le capacità della piattaforma.

EventLoop (Macrotasks and Microtasks)

Macrotasks

L’EventLoop, il nucleo di Node.js, è implementato in C e C ++ e svolge un ruolo fondamentale nell’esecuzione del codice JavaScript. Esso fornisce più code, note come macrotask, che corrispondono a diverse operazioni all’interno di Node.js. Queste code assicurano che le attività vengano eseguite in un ordine appropriato e gestiscano in modo efficiente gli eventi, operazioni di I/O e altre attività asincrone.

Microtasks

Oltre all’EventLoop, Node.js introduce anche il concetto di microtask, che agiscono a livello JavaScript/Node.js. Essi comprendono promise e nextTicks e forniscono un modo per eseguire callback in modo asincrono e con priorità più elevata. I microtask vengono elaborati all’interno dell’EventLoop dopo ogni macrotask, consentendo un controllo più granulare delle operazioni asincrone in Node.js.

Diagramma completo: Macrotasks e Microtasks

Una domanda impegnativa per un colloquio

In un recente colloquio di lavoro, mi è stata posta una bella domanda sull’Event Loop di Node.js. È stato interessante e mi ha fatto riflettere, tuttavia non ho potuto dare la risposta migliore durante il colloquio perché inizialmente non mi è stata chiara; se avessi avuto più tempo, penso che avrei potuto capirla meglio.

const http = require('http');

// Crea un semplice server HTTP
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, this is your Node.js server!');
});

server.listen(3000);

// Operazione Bloccante
async function block() {
for (let i = 0; i < 100; i++) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
console.log(`Waited ${i + 1} times`);

// SI PUÒ SOLO AGGIUNGERE CODICE, NON SI DEVE MODIFICARE NULLA
}
}

block();

Abbiamo un semplice server HTTP. Quando avviamo il server, eseguiamo anche un’operazione bloccante. La funzione Atomics.wait è solo una semplice operazione bloccante che mette in pausa il thread JavaScript per 100 ms.

Quando lo eseguirai, vedrai i log per ogni iterazione del ciclo. Se provassi ad interrogare il tuo server HTTP tramite curl, la richiesta si bloccherà. Questo perché l’Event Loop è occupato e non può gestire la richiesta HTTP.

Vista terminale #1 Bloccato

Al termine del ciclo, la richiesta HTTP verrà elaborata.

Vista terminale #1 Terminato

C’è un commento per te nel codice.

// SI PUÒ SOLO AGGIUNGERE CODICE, NON SI DEVE MODIFICARE NULLA.

È necessario aggiungere una soluzione che impedisca all’operazione di blocco di avere un impatto sull’Event Loop, consentendogli di gestire le richieste HTTP. Allo stesso tempo dovrebbe funzionare come previsto, bloccando il thread per 100 iterazioni, ognuna delle quali dura 100 ms.

Non pensare troppo, non usare worker_thread.

La soluzione è semplice, ma l’idea per me è geniale.

È sufficiente aggiungere alcuni snippet di codice. Ora pensaci.

Suggerimento

In caso ti servisse un suggerimento, nota che la funzione bloccante è asincrona.

Soluzione

Ta da da dam. Ecco che arriva!

Vista terminale #2 Soluzione
await new Promise(r => setTimeout(r, 0));

In precedenza, il nostro ciclo for non dipendeva dai macrotasks; L’Event Loop non aveva la possibilità di controllare le code. Con questo approccio, introduciamo una dipendenza minima dai macrotasks, in particolare da setTimeout. La nostra funzione asincrona attende una promise che si risolve in modo da facilitare il completamento di un ciclo da parte dell’Event Loop. All’interno di questo ciclo esso riesce a gestire la richiesta HTTP.

Spero che questo approfondimento sia stato di vostro interesse. Sono grato alla persona che mi ha posto questa domanda durante il colloquio.

Grazie per aver dedicato il tempo a leggere interamente questo articolo. Spero che tu l’abbia trovato informativo e abbia ottenuto preziose intuizioni da esso.

--

--