JavaScript asincrono

Sommario

Nelle due principali implementazioni di JavaScript, i browser e Node.js, le operazioni di input / output (I/O) sono offerte attraverso API asincrone. Per molti sviluppatori il modo di lavorare che ne consegue risulta poco intuitivo e/o poco pratico, ma in realtà ha i suoi vantaggi e può essere reso decisamente più agevole attraverso l'uso di appositi pattern e di sintassi dedicata.

In questa guida introdurrò brevemente le motivazioni dietro l'uso di API asincrone in JavaScript, il modo classico di utilizzarle ed alcune "tecniche avanzate".

Indice

Background

Prima di poter lavorare efficacemente con API asincrone è ovviamente necessario capire cosa sono e a che servono.

Un'API asincrona è essenzialmente un meccanismo per richiedere operazioni e specificare come reagire al loro completamento, lasciando però il thread chiamante libero di proseguire mentre tali operazioni vengono svolte in parallelo. Le API asincrone possono essere quindi implementate mediante thread ausiliari. Nel caso di operazioni di I/O (che poi sono quelle per le quali le API asincrone vengono impiegate più spesso) esistono anche meccanismi più efficienti che richiedono però un supporto da parte del sistema operativo all'I/O non bloccante.

Ma a cosa serve avere API asincrone nel browser e in Node.js? Per capirlo occorre partire dal fatto che il browser e Node.js sono essenzialmente dei gestori di eventi, come ad esempio il click su un bottone (browser) o la ricezione di una richiesta HTTP (Node.js). La gestione di questi eventi avviene eseguendo il codice JavaScript specificato dalla pagina (o dal programma nel caso di Node.js). Un aspetto cruciale è che la gestione degli eventi viene sempre effettuata utilizzando solo un thread dunque gli eventi vengono gestiti uno per volta. In quest'ottica le API asincrone servono ad avviare operazioni anche molto lunghe senza impattare sulla durata della gestione di un evento corrente e, quindi, senza ritardare troppo la gestione del prossimo evento. Senza API asincrone il browser non potrebbe rimanere al passo con le azioni dell'utente e Node.js non potrebbe gestire richieste concorrenti.

Per certi versi le API asincrone sono dunque il prezzo da pagare per l'uso di un solo thread per la gestione di eventi. D'altro canto l'utilizzo di un solo thread comporta degli importanti benefici: gestire un solo evento per volta vuol dire che la gestione di un evento non interferirà mai con quella di un altro. Inoltre, nel caso di Node.js, non dover istanziare un thread per ogni richiesta permette di una maggiore scalabilità ed una minore latenza.

Callback

Come anticipato, browser e Node.js funzionano ad eventi. Esistono meccanismi di sottoscrizione che permettono di definire sotto forma di funzione, detta "callback", il codice da eseguire per effettuare la gestione di un dato evento. Ad esempio in una pagina web la definizione di come debba essere gestito il click su un certo bottone può essere effettuata come segue:

bottone.addEventListener('click', function () {
  console.log('Pulsante premuto!');
});

Poiché il completamento di un operazione asincrona è considerato un evento (cioè il browser e Node.js lo trattano come tale, ovvero eseguendo codice JavaScript in risposta ad esso), anche le API asincrone sono basate sull'uso di callback:

fs.readFile('/home/marco/Desktop/esempio.txt', 'utf8', function () {
  console.log('Operazione completata!');
});

In questo esempio la funzione readFile del modulo fs di Node.js, che permette di leggere un file, prende una callback come ultimo parametro (che per altro è una convenzione piuttosto comune a fra le API asincrone JavaScript).

La callback passata ad un'API asincrona può avere a sua volta dei parametri che rappresentano l'esito dell'operazione:

fs.readFile('/home/marco/Desktop/esempio.txt', 'utf8', function (errore, dati) {
  if (errore) {
    console.log('Ops: si è verificato un errore!');
  } else {
    console.log(dati);
  }
});

Convenzionalmete il primo parametro è quello che rappresenta l'eventuale errore che ha fatto fallire l'operazione (se è fallita) mentre i parametri successivi rappresentano gli eventuali valori restituiti dall'operazione in caso di successo.

Nel lavorare con API asincrone bisogna sempre ricordare che JavaScript gestisce un solo evento per volta e che perciò le callback non saranno mai eseguite prima del termine della gestone dell'evento corrente. Ad esempio, supponiamo che durante la gestione di un evento venga eseguito il seguente codice:

var errore = null;
var dati = null;
fs.readFile('/home/marco/Desktop/esempio.txt', 'utf8', function (e, d) {
  errore = e;
  dati = d;
});
if (errore) {
  console.log('Ops: si è verificato un errore!');
} else {
  console.log(dati);
}

Tale codice è errato e mostrerà sempre il messaggio di errore, anche se la lettura del file avrà successo. Il motivo è semplice: la parte if-else che determina la selezione del messaggio corretto, nonostante appaia per ultima nel codice, viene eseguita prima della callback passata a readFile. Il codice all'interno di tale callback, infatti, viene sì definito durante la gestione dell'evento corrente, ma verrà eseguito durante la gestione di un altro evento, ovvero il completamento della lettura del file.

Il codice che gestisce l'esito di un'operazione asincrona, quindi, va sempre scritto tutto all'interno della rispettiva callback. Nel caso esso stesso contenga chiamate asincrone si avrà quindi una callback nella callback e in generale una sequenza di operazioni asincrone può assumere un aspetto simile al seguente:

operazioneUno(function () {
  operazioneDue(function () {
    operazioneTre(function () {
      operazioneQuattro(function () {
        operazioneCinque(function () {
          operazioneSei(function () {
            operazioneSette(function () {
              // ecc.
            });
          });
        });
      });
    });
  });
});

Nella comunità JavaScript questo genere di struttura è ironicamente chiamata "Pyramid of Doom" ed è evidente come possa diventare rapidamente difficile da gestire in termini di indentazione e di scope. Quando poi si passa da una semplice sequenza prefissata a flussi più complessi scrivere codice manutenibile in questo modo diventa un'impresa, perciò sono emersi pattern specifici per la semplificazione del codice asincrono e fra questi il pattern delle promesse è il più popolare.

Promesse

L'idea alla base delle promesse è relativamente semplice. Al momento di invocare un'API asincrona, invece di passare una callback, ci si aspetta semmai di ricevere un oggetto, chiamato appunto "promessa", dotato di un metodo next che permette di specificare due distinte callback: una per gestire il successo dell'operazione e l'altra per gestirne il fallimento.

Ad esempio un'operazione per la lettura di un file potrebbe avvenire in modo simile al seguente:

var promessa = leggiFile('/home/marco/Desktop/esempio.txt', 'utf8');
promessa.then(function (dati) {
  console.log(dati);
}, function (errore) {
  console.log('Si è verificato un errore!');
});

Rispetto all'approccio "tradizionale", sono già evidenti due miglioramenti:

Le promesse, poi, permettono di risolvere la "Pyramid of Doom" attraverso un meccanismo di concatenazione basato su singola callback e in cui ogni callback restituisce la promessa associata all'operazione asincrona successiva:

operazioneUno().then(function () {
  return operazioneDue();
}).then(function () {
  return operazioneTre();
}).then(function () {
  return operazioneQuattro();
}).then(function () {
  return operazioneCinque();
}).then(function () {
  return operazioneSei();
}).then(function () {
  return operazioneSette();
}); // ecc.

Se le callback si limitano a restituire una promessa, è anche possibile utilizzare la seguente forma, ancora più concisa:

operazioneUno().
  then(operazioneDue).
  then(operazioneTre).
  then(operazioneQuattro).
  then(operazioneCinque).
  then(operazioneSei).
  then(operazioneSette); // ecc.

Il risultato della concatenazione di promesse è a sua volta una promessa rappresentante l'intera sequenza e permette di gestire in maniera centralizzata il fallimento di una qualunque delle operazioni nella sequenza:

operazioneUno().
  then(operazioneDue).
  then(operazioneTre).
  then(function () {
    console.log('Le tre operazioni hanno avuto successo');
  }, function () {
    console.log('Una delle tre operazioni è fallita');
  });

Una trattazione più esaustiva delle promesse richiederebbe una guida a parte, ma gli esempi mostrati sono già sufficienti a muovere i primi passi con le promesse. A questo indirizzo sono poi disponibili delle specifiche complete che possono essere usate per capire meglio tutti gli effettivi requisiti del pattern. Come si potrà facilmente verificare, si tratta di un pattern in realtà piuttosto complesso, perciò invece che implementarlo autonomamente gli sviluppatori JavaScript utilizzano librerie di supporto che permettono di creare promesse conformi alle specifiche. Nella versione ECMAScript 6 di JavaScript le funzioni offerte da queste librerie sono persino diventate parte delle API standard delle linguaggio e dunque non è necessaria alcuna libreria. Ecco quindi un esempio di come trasformare un'API asincrona tradizionale in una basata su promesse utilizzando ECMAScript 6:

function apiConPromessa(x) {
  return new Promise(function (resolve, reject) {
    apiAsincronaTradizionale(x, function (errore, y) {
      if (errore) return reject(errore);
      resolve(y);
    });
  };
}

Generator

Oltre al supporto alle promesse, ECMAScript 6 introduce i generator, ovvero funzioni che possono essere sospese e riprese (anche più volte) prima di terminare la propria esecuzione. Come vedremo nella prossima sezione, i generator permettono di gestire il codice asincrono in nuovi modi. In questa sezione, però, cerchiamo prima di capire il loro funzionamento generale.

Come detto, un generator può sospendere la propria esecuzione. In particolare ECMAScript 6 introduce a tal proposito la parola chiave yield:

function* esempioDiGenerator() {
  yield;
}

Nota: Per dichiarare una generator occorre aggiungere un asterisco alla parola chiave function.

Uno dei modi in cui i generator si distinguono dalle normali funzioni è il fatto che non è sufficiente invocarli per avviarne l'esecuzione. Piuttosto quando una generator viene invocato, esso restituisce immediatamente un oggetto chiamato "iterator" con un metodo next che permette finalmente di avviare l'esecuzione del generator:

function* esempioDiGenerator() {
  console.log('Esecuzione iniziata');
  yield;
}
var iteratore = esempioDiGenerator();
console.log('Esecuzione non ancora iniziata');
iteratore.next(); // avvia l'esecuzione

Una volta avviata, l'esecuzione prosegue fino a quando non viene incontrata la parola chiave yield:

function* esempioDiGenerator() {
  console.log('Questo messaggio sarà mostrato');
  yield;
  console.log('Questo no');
}
var iteratore = esempioDiGenerator();
iteratore.next();

Chiamando nuovamente next sarà possibile riprendere l'eseuzione del generator:

function* esempioDiGenerator() {
  console.log('Questo messaggio sarà mostrato');
  yield;
  console.log('E anche questo');
}
var iteratore = esempioDiGenerator();
iteratore.next();
iteratore.next();

Il metodo next e yield permettono quindi di alternare l'esecuzione della funzione chiamante e quella del generator:

function* esempioDiGenerator() {
  console.log('b');
  yield;
  console.log('d');
  yield;
  console.log('f');
  yield;
  // ecc.
}
var iteratore = esempioDiGenerator();
console.log('a');
iteratore.next();
console.log('c');
iteratore.next();
console.log('e');
iteratore.next();
console.log('g');
// ecc.

Inoltre next e yield permettono di scambiare valori fra le due funzioni. In particolare, next restituisce ogggetti basati sui valori inviati da yield:

function* esempioDiGenerator() {
  yield 1;
  yield 2;
  yield 3;
}
var iteratore = esempioDiGenerator();
var n = iteratore.next();
console.log(n); // { value: 1, done: false }
n = iteratore.next();
console.log(n); // { value: 2, done: false }
n = iteratore.next();
console.log(n); // { value: 3, done: false }
n = iteratore.next();
console.log(n); // { value: undefined, done: true }

Nota: La proprietà done permette di sapere se l'esecuzione del generator è terminata.

A sua volta yield fornisce accesso agli eventuali valori passati tramite next:

function* esempioDiGenerator() {
  var x;
  x = yield;
  console.log(x);
  x = yield;
  console.log(x);
  x = yield;
  console.log(x);
}
var iteratore = esempioDiGenerator();
iteratore.next();
iteratore.next(1);
iteratore.next(2);
iteratore.next(3);

Nota: La prima invocazione di next non passa alcun valore. In altre parole, il primo yield riceve il valore passato dal secondo next, il secondo yield riceve dal terzo next, e così via.

L'iterator permette anche di provocare un'eccezione all'interno del corrispondente generator:

function* esempioDiGenerator() {
  try {
    yield;
  } catch (errore) {
    console.log(errore);
  }
}
var iteratore = esempioDiGenerator();
iteratore.next();
iteratore.throw('finto errore');

Async await

Ok: cosa c'entrano i generator con le API asincrone?

Per capirlo osserviamo anzitutto che una normale funzione JavaScript viene interamente eseguita nel corso della gestione di un singolo evento, mentre l'esecuzione di un generator può iniziare durante la gestione di un evento, essere sospesa e poi riprendere durante la gestione di un altro evento: è sufficiente che le callback che gestiscono i due eventi abbiano entrambe accesso all'iterator che controlla il generator. Ad esempio, dati due bottoni su una pagina web, il secondo bottone può far riprendere l'esecuzione di un generator avviato dal primo bottone:

var iteratore;
b1.addEventListener('click', function () {
  iteratore = (function* () {
    console.log('esecuzione avviata da b1...');
    yield;
    console.log('...e ripresa da b2');
  })();
  iteratore.next();
});
b2.addEventListener('click', function () {
  iteratore.next();
});

I generator possono quindi essere utilizzati per scrivere codice in grado di "attendere" il risultato di un'operazione asincrona: l'attesa inizia all'interno del generator facendo lo yield di una promessa e termina quando da una delle callback della promessa viene chiamato next o throw. Esempio:

function funzioneMagica(unGenerator) {
  var iteratore = unGenerator();
  var promessa = iteratore.next();
  promessa.then(function (risultato) {
    iteratore.next(risultato);
  }, function (errore) {
    iteratore.throw(errore);
  });
}

funzioneMagica(function* () {
  try {
    var risultato = yield fammiUnaPromessa();
    console.log(risultato);
  } catch (errore) {
    console.log(errore);
  }
});

Estendendo opportunamente funzioneMagica possiamo arrivare poi a scrivere generator che "attendono" più promesse:

function funzioneMagica(unGenerator) {
  var iteratore = unGenerator();
  var promessa = iteratore.next();
  promessa.then(function (risultato) {
    iteratore.next(risultato);
  }, function (errore) {
    iteratore.throw(errore);
  });
}

funzioneMagica(function* () {
  try {
    var risultato = yield fammiUnaPromessa();
    console.log(risultato);
  } catch (errore) {
    console.log(errore);
  }
});

Estendendo opportunamente funzioneMagica possiamo arrivare a scrivere generator che "attendono" più promesse:

funzioneMagica(function* () {
  try {
    var x = yield promessaUno();
    var y = yield promessaDue();
    var z = yield promessaTre();
    console.log(x + y + z);
  } catch (errore) {
    console.log(errore);
  }
});

Ora, se focalizziamo l'attenzione sul corpo del generator ci rendiamo conto che questo modo di scrivere il codice fa sembrare sincrono il codice asincrono. In particolare dal punto di vista del generator i "valori asincroni" attesi con lo yield di una promessa sono del tutto equivalenti a quelli sincroni / ottenuti immediatamente con una normale funzione. Di conseguenza non serve scrivere più alcuna callback né sbizzarrirsi nella composizione di promesse: è sufficiente scrivere codice lo stesso codice che si sarebbe scritto con API sincrone, avendo cura di premettere yield alle invocazioni che restituiscono promesse.

Naturalmente queste affermazioni presuppongono la disponibilità di codice in grado di gestire correttamente le promesse su cui viene invocato yield. Negli esempi precedenti abbiamo visto un'implementazione (funzioneMagica) che andava bene per uno scenario estremamente semplificato, ma con un po' di fantasia e bravura è possibile scriverne una versione in grado di coprire un numero non prefissato di promesse e di supportare la parametrizzazione del generator. Si veda ad esempio l'implementazione disponibile a questo indirizzo.

Ora, il pattern descritto in questa sezione si chiama "async / await" e in ECMAScript 2017 è implementato nativamente, per altro anche attraverso sintassi dedicata. In particolare una funzione può essere marcata come async e attendere un numero generico di promesse tramite parola chiave await:

async function esempio() {
  try {
    var x = await promessaUno();
    var y = await promessaDue();
    var z = await promessaTre();
    console.log(x + y + z);
  } catch (errore) {
    console.log(errore);
  }
}

Tale codice sarà internamente convertito in qualcosa di simile a:

async(function* () {
  try {
    var x = yield promessaUno();
    var y = yield promessaDue();
    var z = yield promessaTre();
    console.log(x + y + z);
  } catch (errore) {
    console.log(errore);
  }
});

... e con questo si chiude la nostra panoramica sulla programmazione asincrona in JavaScript! ;)