Sommario
< Home
Stampa

Javascript asincrono

Monothread ed eventi asincroni

Nei moderni linguaggi di programmazione (come C++, Java o C#) l’applicazione in esecuzione prevede flussi di lavoro multithread. In altre parole sono presenti più flussi di esecuzione in contemporanea nello stesso programma, per svolgere azioni diverse (ad esempio gestire l’interfaccia utente e contemporaneamente scaricare dati da Internet) o suddividere la stessa azione (per esempio per velocizzare la ricerca su sottoinsiemi di dati), grazie alle moderne cpu multi-core.

Javascript è invece un linguaggio di programmazione monothread, ovvero è presente un solo flusso di esecuzione alla volta. Questo costituisce, specie per le applicazioni web, un problema perché il rischio è che l’applicazione non sia sufficientemente reattiva di fronte all’interazione con l’utente, perché magari il programma è “bloccato” da un’altra operazione.

Tuttavia questo non succede perché Javascript è asincrono. Non può direttamente fare due cose in contemporanea, ma può far eseguire al runtime (il browser) delle operazioni in background mentre continua ad essere pronto per interagire con l’utente. Una volta completata l’operazione in background il browser farà eseguire di nuovo Javascript mediante un meccanismo detto di callback.

Abbiamo già visto una funzione che fa questa cosa, il setTimeout.

const callback = () => {
  console.log("A");
}
setTimeout(callback, 1000);
console.log("B");
...

Se si esegue questo codice si osserverà che prima viene stampata la stringa “B” e poi dopo un secondo “A”.

Osserviamo bene il comportamento:

  1. setTimeout indica al runtime di schedulare la funzione callback dopo 1000ms. Questa operazione viene fatta in background.
  2. Javascript prosegue e infatti viene eseguita l’istruzione console.log("B").
  3. Il runtime poi prosegue con eventuali istruzioni successive, FINO al termine dello script.
  4. A questo punto Javascript si ferma e rimane inattivo ma pronto per eseguire nuove funzioni.
  5. Dopo 1000ms, il runtime attiva la funzione callback.
  6. La callback viene eseguita e viene stampato “A”.

Vediamo ora setInterval:

const callback = () => {
  console.log("A");
}
setInterval(callback, 500);
console.log("B");
...

In questo caso:

  1. setInterval indica al runtime di schedulare la funzione callback ogni 1000ms.
  2. Javascript prosegue e infatti viene eseguita l’istruzione console.log("B")..
  3. Il runtime prosegue con eventuali istruzioni successive FINO al termine dello script.
  4. A questo punto Javascript rimane inattivo ma pronto.
  5. Dopo 1000ms, il runtime mette in esecuzione la funzione callback.
  6. La callback viene eseguita.
  7. Al termine Javascript torna in stato pronto.
  8. Ogni 1000ms, il runtime mette in esecuzione la funzione callback.
  9. Al termine Javascript torna pronto.

In altri termini con setTimeout/setInterval il runtime schedula l’esecuzione della funzione f in un tempo futuro, senza fermare l’esecuzione della funzione corrente, che viene eseguita fino al suo termine. Solo quando scade il tempo previsto, Javascript viene di nuovo eseguito.

Ora ipotizziamo che dopo un setTimeout continuiamo ad impegnare Javascript con una elaborazione molto lunga ad esempio:

const f = (() => {
  console.log("1");
};
setTimeout(f, 10);
for (let i=0; i<100000000; i++) { 
  console.log(i); 
}

E’ probabile che il ciclo for ci metta ben di più di 10 millisecondi, e quindi sicuramente non riesce a terminare prima che parti l’esecuzione della funzione f. Quindi cosa succede? Che il runtime comunque termina lo script/funzione corrente, e soltanto dopo che ha finito va a vedere se c’è una funzione schedulata per il futuro.

Quindi setTimeout non garantisce che venga eseguita esattamente dopo 10ms, ma solo che venga eseguita non prima di 10 ms. Questo perchè in JS:

  • viene eseguita una sola funzione alla volta;
  • esiste una coda di funzioni che verrà eseguita (una funzione alla volta) al termine della esecuzione della funzione corrente.

Ipotizziamo infine che sia presente anche un pulsante con un listener.

const button = document.querySelector("#button");
button.onclick = () => {
  console.log("B");
});
const callback = () => {
  console.log("A");
}
setTimeout(callback, 2000);

Cosa succede se l’utente clicca sul pulsante mentre è in esecuzione il setTimeout?

Il runtime schedula la funzione listener per essere eseguita. Se il pulsante viene cliccato prima dei due secondi, verrà stampato B, altrimenti A. In ogni caso saranno eseguite in sequenza.

Event loop

In pratica il runtime (che è una applicazione mutithread) ogni volta che si esegue un setTimeout, o un setInterval, o riceve un evento, o comunque esegue una qualsiasi callback, inserisce queste funzioni in una coda di esecuzione.

Quando Javascript ha finito di eseguire la funzione, controlla nella coda se è presente una nuova funzione da eseguire, e se c’è la esegue. Se ci sono più funzioni in coda (si pensi a diversi setTimeout o click di pulsanti o altri eventi) vengono eseguite una alla volta.

Questo algoritmo, che funziona come un ciclo infinito, viene chiamato “event loop” ed è alla base del funzionamento di Javascript. [1].

Promise

Le promise sono oggetti che vengono utilizzati per eseguire attività asincrone. Sono uno strumento per permettere al programmatore di:

  • lanciare una certa operazione asincrona (setTimeout, ecc.);
  • indicare sia l’attività da svolgere come callback SE l’attività si conclude correttamente, chiamata resolve;
  • indicare l’attività da svolgere in caso di errore (callback di errore), chiamata reject. Questa è facoltativa.

La promise però non indica, in fase di creazione, le due callback da eseguire. Semplicemente crea l’azione pronta per essere eseguita.

Solo quando in una istruzione successiva viene eseguita allora verranno indicate le callback.

La promise viene creata passando la funzione asincrona da svolgere.

Facciamo un esempio:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
     console.log("ciao");
     resolve();
  }, 1000);
}

La promise si crea passando una funzione che ha come parametro le due callback resolve e reject. Quando l’operazione è conclusa viene eseguita la resolve.

La promise NON viene eseguita subito, ma crea un oggetto pronto per essere eseguito.

Proviamo ad eseguirla.

promise.then(() => { 
  console.log("ho finito");
});

Il methodo then lancia la promise e riceve come parametro la funzione resolve.

Se bisogna gestire un errore allora si usa il metodo catch, che riceve come parametro la funzione reject.

Qui vediamo la forma completa:

const promise = new Promise((resolve, reject) => {  
    ... istruzioni da svolgere asincrone
    if (!error) {
      resolve(...);
    } else {
      reject(...);
    }
  }
);

Vediamo un altro esempio che usa sia resolve che reject.

const timeoutPromise = new Promise((resolve, reject) => {
  try {
    setTimeout(() => {
      const value = Math.rand();
      resolve(value);
  } catch(exception) {
    reject();
  }
);

timeoutPromise
.then((value) => console.log(value))
.catch(console.error);

Quando si esegue il then, la promise esegue il codice previsto, e poi schedula la funzione resolve, con il catch invece viene indicata la funzione reject.

Async await

La notazione async…await è un sistema per rendere più leggibili le promise. In pratica è “zucchero sintattico”, ovvero non introduce un vero e proprio nuovo costrutto, ma semplicemente quando viene eseguito viene riscritto nella forma vista sopra.

Vediamo un esempio di codice.

let load = async () => {
  try {
    const data = await timeoutPromise();
    console.log(data);
  } catch (e) {
    console.error(e);
  }
}

La await in pratica esegue la promise e salva in data l’argomento passato della resolve(), mentre la try catch gestisce eventuali errori (reject). Per funzionare tuttavia la funzione che esegue questa operazione deve essere marcata come async. Non è possibile usare async nello script principale quindi gli script async-await vanno eseguiti sempre in funzioni autoeseguenti async.

(async () => {
  try {
    const data = await timeoutPromise();
    console.log(data);
  } catch (e) {
    console.error(e);
  }
})();

Il grosso vantaggio di async await è la leggibilità: “sembra” che il codice asincrono diventi sincrono e quindi che lo script si fermi ad aspettare (await) che la fetch si concluda. In realtà c’è sempre una promise, quindi Javascript resta comunque pronto durante una await per eseguire altre funzioni. Bisogna quindi stare attenti e cauti in questo utilizzo.


[1] (si può verificare che effettivamente nel codice sorgente C++ di V8 è presente proprio un ciclo while).