Sia il flusso di esecuzione di Javascript, che quello di Node.js, sono basati sullâ event loop.
Comprendere come funziona un event loop è importante sia per una questione di ottimizzazione dellâesecuzione del codice, ma a volte, anche per creare delle architetture software migliori.
In questo capitolo affronteremo i dettagli teorici sul funzionamento, dopodiché prenderemo in esame alcune applicazioni pratiche.
Event Loop
Il concetto di event loop è molto semplice. Esiste un loop infinito, nel quale il motore di Javascript rimane in attesa di un task (compito o operazione da eseguire), lo esegue, quindi si mette in attesa per altri tasks (rimane in sleep, inattivo o dormiente, ma pronto per essere di nuovo richiamato).
A grandi linee, lâalgoritmo del motore è così:
- Fino a quando ci sono task:
- eseguili, cominciando da quello meno recente.
- Rimani in attesa fino a quando non câè un altro task da eseguire, quindi vai al passo 1.
Questa è una trasposizione di quello che vediamo mentre navighiamo in una pagina web. Il motore di Javascript non fa nulla per la maggior parte del tempo, e va in esecuzione quando si attiva uno script/handler/evento.
Esempio di tasks:
- Quando viene caricato uno script esterno
<script src="...">(load), il task è quello di eseguirlo. - Quando un utente sposta il puntatore del mouse, il task è quello di lanciare il dispatch dellâevento
mousemoveed eseguirne eventuali handlers (gestori). - Quando è scaduto il tempo per
setTimeoutgià schedulato, il task è quello di eseguirne la callback. - â¦e così via.
I task vengono impostati â il motore li gestisce â quindi rimane in attesa per altri tasks (nel frattempo rimane in sleep, consumando risorse CPU prossime allo zero).
Però potrebbe succedere che mentre il motore è occupato, arrivi un task, in questo caso, questo viene messo in coda.
I task formano una coda, la cosiddetta âmacrotask queueâ (termine mutuato da V8, il motore Javascript di Chrome e di Node.js):
Ad esempio se, mentre il motore è occupato nellâesecuzione di uno script, lâutente muove il mouse generando un mousemove, e magari nello stesso istante è scaduto il tempo di un setTimeout, questi task formano una queue (una coda di esecuzione) come illustrato nella figura appena sopra.
I tasks dalla coda vengono processati sulla base del âfirst come â first servedâ, cioè secondo lâordine per cui il primo arrivato sarà il primo ad essere servito (FIFO).
Quando il motore del browser avrà terminato con lo script, gestirà lâevento mousemove, quindi si occuperà del gestore del setTimeout (la callback), e via dicendo.
Fino a qui abbastanza semplice, giusto?
Ancora due dettagli:
- Il rendering non avviene mai quando il motore sta eseguendo un task. Non importa se questo impiega molto tempo. I cambiamenti al DOM vengono renderizzati (ridisegnati sul browser) solo dopo che il task viene completato.
- Se un task impiega troppo tempo, il browser non può eseguire altri tasks, processare altri eventi utente, e così dopo un certo periodo di tempo viene scaturito un alert di âPagina bloccataâ (Page Unresponsive) che ci suggerisce di terminare il task e lâintera pagina. Questo succede in concomitanza di una serie di calcoli complessi, o in seguito ad errori di programmazione che portano loop infiniti.
Ok, questa era la teoria, ma vediamo come mettere in pratica questi concetti.
Caso dâuso 1: Spezzettamento di task affamati di CPU (processi intensivi)
Poniamo il caso che abbiamo un task affamato di CPU (CPU-hungry process).
Per esempio, la syntax-highlighting (usata per colorare ed evidenziare gli esempi del codice in questa pagina) è abbastanza pesante per la CPU. Per evidenziare il codice, compie delle analisi, crea molti elementi colorati, e li aggiunge al documento â un testo di grosse dimensioni può impiegare molto tempo.
Mentre il motore è occupato con lâevidenziazione, non può fare altre cose relative al DOM, processare gli eventi dellâutente, etc. può persino causare rallentamenti al pc o addirittura bloccarlo, la qual cosa è inaccettabile.
Possiamo quindi tirarci fuori da questo tipo di problemi, spezzettando i task grossi in piccoli pezzi da eseguire. Evidenzia le prime 100 righe, quindi schedula un setTimeout (con zero-delay) con altre 100 righe, e così via fino alla fine.
Per dimostrare questo tipo di approccio, e per amore della semplicità , anziché evidenziare una sintassi, prendiamo una funzione che conti i numeri da 1 a 1000000000
Se esegui il codice sotto, il motore si inchioderà per qualche istante. Per il JS server-side (lato server) questo è chiaramente visibile, ma se lo stai eseguendo nella finestra del browser, provando a cliccare gli altri pulsanti â potrai notare che non verrà gestito nessun altro evento fino a quando il conteggio dei numeri non sarà terminato.
let i = 0;
let start = Date.now();
function count() {
//un lavoro pesante!
for (let j = 0; j < 1e9; j++) {
i++;
}
alert(`Completato in ${(Date.now() - start)} millisecondi`);
}
count();
Il browser potrebbe anche mostrare lâavviso âlo script sta impiegando troppo tempoâ the script takes too long".
Ora invece, dividiamo lâoperazione con lâausilio di un setTimeout annidato:
let i = 0;
let start = Date.now();
function count() {
//fai una parte del lavoro pesante :-) (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert(`Completato in ${(Date.now() - start)} ms`);
} else {
setTimeout(count);//schedula la nuova chiamata a count (**)
}
}
count();
Adesso lâinterfaccia del browser è pienamente funzionante, anche durante il processo di âconteggioâ.
Una singola esecuzione di count fa una parte dellâoperazione (*), e rischedula se stessa (**) se necessario:
- La prima esecuzione conta:
i=1...1000000. - La seconda esecuzione conta:
i=1000001..2000000. - â¦e così via.
Ora, se arriva un nuovo task da eseguire mentre il motore è occupato ad eseguire il passo 1, poniamo il caso ad esempio che venga sollevato un evento onclick, questâultimo viene messo in coda ed eseguito subito dopo il completamento del passo 1, ma subito prima del passo successivo. Questi periodici âritorniâ allâevent loop tra una esecuzione di count e lâaltra, fornisce abbastanza ârespiroâ al motore Javascript per occuparsi di qualcosâaltro, ad esempio per reagire alle azioni degli utenti.
Per renderli un poâ più comparabili, facciamo un miglioramento.
let i = 0;
let start = Date.now();
function count() {
//posizioniamo la schedulazione all'inizio
if (i < 1e9 - 1e6) {
setTimeout(count);//scheduliamo la chiamata successiva
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert(`Completato in ${(Date.now() - start)} ms`);
}
}
count();
Adesso, quando cominciamo con count() e vediamo che abbiamo bisogno di richiamarlo più count(), lo scheduliamo subito prima di fare il lavoro.
Se lo esegui, è facile notare che impiega significativamente meno tempo.
Perché?
Semplice: come saprai, câè un ritardo minimo di 4ms allâinterno del browser per tantissime chiamate annidate di setTimeout. Anche se noi lo abbiamo impostato a 0, sarà di 4ms (o qualcosa in più). Quindi, prima lo scheduliamo, più veloce sarà lâesecuzione.
Alla fine, abbiamo diviso un task affamato di CPU in porzioni â che adesso non bloccherà più lâinterfaccia utente. Inoltre, il suo tempo di esecuzione complessivo non è tanto più lungo.
Caso dâuso 2: Indicazione dei progressi di una operazione
Un altro beneficio nel dividere task pesanti per gli script del browser è che possiamo mostrare i progressi di completamento.
Solitamente il browser renderizza dopo che il codice in esecuzione viene completato. Non importa se il task impiega tanto tempo. Le modifiche al DOM vengono mostrate solo dopo che il task è terminato.
Da una parte, questo è grandioso, perché la nostra funzione può creare molti elementi, aggiungerli uno alla volta al documento e cambiarne gli stili â il visitatore, dâaltra parte, non vorrebbe mai vedere uno stadio âintermedioâ ed incompleto. Una cosa importante, giusto?
Con lâesempio qui sotto abbiamo una dimostrazione, le modifiche allâelemento che rappresenta i valori di i non verranno mostrati fino a quando la funzione non termina, così vedremo solamente il valore definitivo:
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
â¦Tuttavia; potremmo voler mostrare qualcosa durante il task, ad esempio una barra di progresso.
Se andiamo a dividere il task pesante in pezzi usando setTimeout, allora tra ognuno di essi, verranno mostrate delle variazioni.
Questo sembra più carino:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// fai un pezzo di lavoro pesante (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
Adesso il <div> mostra valori sempre crescenti di i, come se fosse una sorta di barra di caricamento.
Caso dâuso 3: fare qualcosa dopo lâevento
In un gestore di evento, potremmo decidere di postporre alcune azioni, fino a che lâevento non risalga i vari livelli dello stack (bubbling up) e non venga gestito su tutti questi livelli.
Possiamo farlo, avvolgendo (wrapping) il codice allâinterno di istruzioni setTimeout a ritardo zero.
Nel capitolo Dispatching di eventi personalizzati abbiamo visto un esempio: dellâevento custom menu-open, viene fatto il dispatch dentro setTimeout, così che esso viene richiamato dopo che lâevento click è stato del tutto gestito.
menu.onclick = function() {
// ...
//crea un evento custom con l'elemento dati cliccato sul menu'
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
//dispatch dell'evento custom in maniera asincrona
setTimeout(() => menu.dispatchEvent(customEvent));
};
Macrotasks e Microtasks
Insieme ai macrotasks, descritti in questo capitolo, esistono i microtasks, menzionati nel capitolo Microtasks.
I microtasks provengono esclusivamente dal nostro codice. Solitamente vengono creati dalle promises: una esecuzione di un gestore .then/catch/finally diventa un microtask. I microtasks vengono usati anche âsotto coperturaâ dagli await, dato che anche questi non sono altro che unâaltra forma di gestione di promises.
Câè anche una funzione speciale queueMicrotask(func) che accoda func per lâesecuzione nella coda dei microtask.
Immediatamente dopo ogni macrotask, il motore esegue tutti i task dalla coda microtask, prima di ricominciare a eseguire ogni altro macrotask o renderizzare o qualunque altra cosa.
Per esempio, guardate questo:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
Cosa succederà allâordine delle operazioni in questo script?
codeviene mostrato per primo, dato che è un chiamata regolare e sincrona.promiseviene mostrato per secondo, perché.thenpassa attraverso la coda di microtask, e viene eseguito dopo il codice corrente.timeoutviene mostrato come ultimo perché è anche questo un microtask.
Lâimmagine più esaustiva di un event loop è questa:
Tutti i microtasks vengono completati prima di ogni altra gestione degli eventi o rendering o qualunque altro macrotask che prende parte nellâesecuzione
Questo è importante perché garantisce che lâambiente applicativo rimanga intatto (nessuna modifica alle coordinate del puntatore del mouse, nessun dato dalle reti, etc) tra i vari microtasks.
Se volessimo eseguire una funzione in maniera asincrona (dopo il codice in esecuzione), ma prima che avvengano cambiamenti nella finestra del browser, o che nuovi eventi vengano gestiti, potremmo schedularla con queueMicrotask.
Questo qui è un esempio della funzione âconteggio barra di progressoâ, del tutto simile alla precedente, ma vengono usati queueMicrotask invece di setTimeout.
Come puoi notare, renderizza il valore del conteggio alla fine. Esattamente come se fosse del codice sincrono:
<div id="progress"></div>
<script>
let i = 0;
function count() {
//faccio un pezzetto di lavoro pesante (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e6) {
queueMicrotask(count);
}
}
count();
</script>
Conclusioni
Lâimmagine più esaustiva di un event loop è questa:
Questo è il più dettagliato algoritmo dellâevent loop: (sebbene ancora semplicistico rispetto alla specification):
- Rimuovi dalla coda ed esegui il task meno recente dalla coda dei macrotask (ad esempio âscriptâ).
- Esegui tutti i microtasks:
- Se la cosa dei microtask non è vuota:
- Rimuovi dalla coda ed esegui il meno recente dei microtask.
- Se la cosa dei microtask non è vuota:
- Renderizza le modifiche se ve ne sono.
- Se la coda dei macrotask è vuota, vai in sleep fino al prossimo macrotask.
- Vai al passo 1.
Per schedulare un nuovo macrotask:
- Usa un
setTimeout(f)ritardo zero.
Questo potrebbe essere usato per dividere task di calcolo pesante in pezzi più piccoli, di modo che nello spazio tra questi, il browser possa eseguire altre operazioni.
Inoltre, vengono usati nei gestori degli eventi per pianificare una azione dopo che lâevento è stato del tutto gestito (bubbling completato)
Per schedulare un nuovo microtask
- Usa
queueMicrotask(f). - Anche i gestori promise passando attraverso la coda dei microtask.
Non ci possono essere gestioni di UI o di networking tra i microtask, perché i microtasks vengono eseguiti immediatamente uno dopo lâaltro.
Ma cosa succederebbe se uno volesse che la coda queueMicrotask eseguisse una funzione in maniera asincrona, mantenendo però il contesto dellâambiente.
Per lunghi calcoli pesanti che non possono bloccare lâevent loop, possiamo usare i Web Workers.
I Web Workers sono un modo per eseguire del codice in un altro thread parallelo.
I Web Workers possono scambiare messaggi con il processo principale, ma hanno le loro variabili ed i loro event loop.
Web Workers do not have access to DOM, so they are useful, mainly, for calculations, to use multiple CPU cores simultaneously.
Commenti
<code>, per molte righe â includile nel tag<pre>, per più di 10 righe â utilizza una sandbox (plnkr, jsbin, codepenâ¦)