Async/Await expliqué via diagrammes et exemples

Introduction


La syntaxe async/await dans JavaScript ES7 facilite la coordination de promesses. Si vous avez de la data à récupérer de différentes bases de données ou d’API, de façon asynchrone et dans un certain ordre, vous pouvez vous retrouver avec des spaghettis de promesses et de callbacks. La structure async/await permet de décrire succinctement cette logique avec un code plus lisible et plus facile à maintenir.
Cet article explique la syntaxe JavaScript async/await et la sémantique avec des diagrammes et des exemples simples.
Mais avant, commençons par un court aperçu des promesses. N’hésitez pas à passer cette section si vous connaissez les promesses en JS.

Sommaire

Promesses

En JavaScript, une promesse représente une abstraction d’une exécution non-bloquante asynchrone. Les promesses JS sont similaires aux Futures de Java ou aux Task du C#, si vous les avez déjà rencontrées.
Les promesses sont typiquement utilisées pour des opérations de réseau et d’I/O : par exemple, lire un fichier ou faire un appel HTTP. Au lieu de bloquer le thread d’exécution actuel, nous créons une promesse asynchrone et nous utilisons la méthode then pour attacher une callback qui sera activé quand la promesse sera complète. La callback peut elle-même retourner une promesse, et ainsi nous pouvons chaîner des promesses efficacement.
Pour simplifier tous les exemples, nous admettrons que la librairie request-promise a déjà été installée et chargée comme :

const rp = require('request-promise');

Maintenant, nous pouvons faire une requête HTTP GET qui retourne un promesse comme :

const promesse = rp('http://example.com/');

Ensuite, regardons cet exemple:

console.log('Starting Execution');
const promesse = rq('http://example.com/');
promesse.then(result => console.log(result));
console.log("Can't know if promesse has finished yet...");

Nous avons créé une nouvelle promesse sur la ligne 3, et nous lui avons attaché une callback sur la ligne 4. La promesse est asynchrone donc, lorsque l’on arrive à la ligne 6, nous ne pouvons pas savoir si la promesse est complète. Si nous exécutons le code plusieurs fois, les résultats peuvent être différents à chaque fois.
De manière plus générale, le code après n’importe quelle promesse créée s’exécute de manière concurrente avec la promesse.
Il n’est pas possible de bloquer la séquence d’opération jusqu’à ce que la promesse soit terminée. Ce qui diffère du Java Future.get, qui permet de bloquer le thread courant jusqu’à ce qu’un Future soit fini. En JavaScript, on ne peut pas attendre facilement une promesse.
Le diagramme suivant décrit le comportement de l’exemple.
Diagramme du 1er example
Le processus d’une promesse.
La seule façon de programmer du code après une promesse est de spécifier une callback via la méthode then.
La callback attachée via then s’exécute seulement après que la promesse soit terminée. Si elle échoue (par exemple à cause d’une erreur de réseau) sa callback me sera pas exécutée. Pour traiter les promesses échouées, on peut attacher une autre callback via catch:

rp('http://example.com/');
    .then(()=> console.log('Success'))
    .catch(e =>console.log(`Failed. ${e}`));

Enfin, pour les tests on peut facilement créer des “dummy” promesses qui se terminent sans/avec erreur respectivement via les méthodes Promise.resolve et Promise.reject.

const success = Promise.resolve('Resolved');
//Printera "Successful result: Resolved"
success
    .then(result => console.log(`Successful result: ${result}`))
    .catch(e => console.log(`Failed with: ${e}`));
const fail = Promise.reject('Err');
//Printera "Failed with: Err"
success
    .then(result => console.log(`Successful result: ${result}`))
    .catch(e => console.log(`Failed with: ${e}`));

Pour un tutoriel plus détaillé à propos des promesses, je vous invite à lire cet article

Le problème – Composer des promesses

Utiliser une seule promesse est assez simple. Cependant, lorsque l’on a besoin de coder une logique asynchrone complexe, on peut réussir à combiner plusieurs promesses.
Écrire toutes les clauses then et les callback asynchrones peut vite dégénérer.
Par exemple, imaginons que l’on doive coder un programme qui :

  1. Fait un appel HTTP, attend qu’il soit terminé, et imprime le résultat;
  2. Et passe deux autres appels HTTP en parallèle;
  3. Dès que les deux appels se terminent, imprime leur résultat.

Le morceau de code suivant montre une façon de faire cela:

// Fait le premier appel
const call1promesse = rp('http://example.com/');
call1promesse.then(
    result => {
    // Fait après que le premier appel soit terminé
    console.log(result);
    const call2promesse = rp('http://example.com/');
    const call3promesse = rp('http://example.com/');
    return Promise.all([call2promesse,call3promesse]);
    }
).then(
    arr =>{
        // Fait après que les deux appels soient terminés
        console.log(arr[0]);
        console.log(arr[1]);
    }
);

On commence par faire le premier appel HTTP et on programme une callback pour traiter le résultat de la promesse.
Dans la callback, on crée deux autres promesses pour les requêtes HTTP à venir.
Ces deux promesses sont concurrentes et nécessitent de mettre en place une callback lorsque les deux se terminent. On doit alors les combiner en une seule promesse via Promise.all, qui crée une promesse se terminant dès que toutes les promesses concurrentes sont terminées.
Le résultat de la première callback est une promesse, donc on doit les chaîner encore une fois avec une callback then qui affichera les résultats.
Le diagramme suivant montre le flow d’exécution:
Diagramme example 2
Flow d’exécution de la combination de promesses.
Pour un exemple aussi simple, on termine avec deux callback then et l’on doit utiliser Promise.all pour synchroniser les promesses concurrentes.

Mais si l’on avait plus d’opérations asynchrones ou bien si nous devions ajouter le traitement d’erreur?

Avec cette approche, on peut facilement finir avec du code spaghetti composé de then, Promise.all et de callback.

Fonction Async

Une fonction async est un raccourci pour définir une fonction qui retourne une promesse.
Par exemple, les definitions suivantes sont équivalentes :

function f(){
    return Promise.resolve('TEST');
}
//asyncF est équivalent à f !
async function asyncF(){
    return 'TEST';
}

De la même façon, les fonctions async qui génèrent des exceptions sont équivalentes aux fonctions qui retournent des promesses rejetées :

function f(){
    return Promise.reject('Error');
}
//asyncF est équivalent à f
async function asyncF(){
    return 'Error';
}

Await

Quand on crée une promesse, on ne peut attendre qu’elle se termine de façon asynchrone qu’avec une callback via then. En effet, attendre une promesse n’est pas possible pour encourager le développement de code non bloquant.
Sinon, les développeurs seraient tentés d’exécuter des opérations bloquantes car c’est plus simple que de travailler avec des promesses et des callback.
Cependant, pour synchroniser des promesses on a besoin d’avoir la possibilité d’attendre. En d’autres termes, si une opération est asynchrone (c’est à dire encapsulée dans une promesse) elle devrait pouvoir attendre la fin d’une autre opération asynchrone.

Mais comment l’interprète JavaScript pourrait savoir si une opération tourne dans une promesse ou pas ?

La réponse se trouve dans le terme async. L’interprète JavaScript sait que toutes les opérations dans les fonctions async seront encapsulées dans des promesses et seront asynchrones. Par conséquent, il est possible de permettre d’attendre la fin d’autres promesses.
Découvrons maintenant await : Il peut uniquement être utilisé avec des fonctions async, et permet d’attendre de façon synchrone une promesse. Si on utilise une promesse en dehors d’une fonction async on doit utiliser les callback then.

async function f(){
    // response will evaluate as the resolved value of the promesse
    const response = await rp('http://example.com/');
    console.log(response);
}
// We can't use await outside of async function.
// We need to use then callbacks ....
f().then(() => console.log('Finished'));

Maintenant, revenons au problème de la section précédente:

// Encapsulate the solution in an async function
async function solution() {
    // Wait for the first HTTP call and print the result
    console.log(await rp('http://example.com/'));
    // Spawn the HTTP calls without waiting for them - run them concurrently
    const call2promesse = rp('http://example.com/');  // Does not wait!
    const call3promesse = rp('http://example.com/');  // Does not wait!
    // After they are both spawn - wait for both of them
    const response2 = await call2promesse;
    const response3 = await call3promesse;
    console.log(response2);
    console.log(response3);
}
// Call the async function
solution().then(() => console.log('Finished'));

Dans ce code, on encapsule la solution dans une fonction async. Cela nous permet d’utiliser await pour les promesses, évitant ainsi les callback then. Pour après invoquer la fonction async qui crée une promesse pour encapsuler la logique d’invoquer d’autres promesses.
En effet, dans le premier exemple (sans async/await), les promesses étaient exécutées en parallèle et ce sera identique ici. Remarquez que nous n’utilisons le await qu’à ligne 11-12, on bloque ici l’exécution jusqu’à ce que les deux promesses soient terminées.
Après ces lignes, nous savons que les promesses sont terminées comme avec le Promise.all(...).then(...) ) de l’exemple précédent.
Sous le capot, await/async sont traduits avec des promesses et callbacks then. En gros, ce n’est que du sucre syntaxique pour travailler avec des promesses. A chaque fois que nous utilisons await, l’interprète crée une promesse et encapsule le reste des operations du async dans une callback then.
Considérons l’exemple suivant :

async function f() {
    console.log('Starting F');
    const result = await rp('http://example.com/');
    console.log(result);
}

Le flow d’exécution de f est décrit en bas. Comme f est async, elle tourne en “parallèle” de la fonction qui l’a appelée.
Diagramme example 3
La fonction f s’exécute et crée une promesse. À ce moment le reste de la fonction est encapsulé dans une callback, puis exécuté à la fin de la promesse.

Traitement d’erreur

Dans la majorité des exemples présentés, nous supposions que la promesse se soit bien passée et que donc, l’await d’une promesse retournait une valeur.
Mais il faut savoir que si une promesse est rejetée dans une fonction async et “attendue” via await, elle lèvera une exception. Il faut alors utiliser le standard try/catch pour traiter ces exceptions.

async function f() {
    try {
        const promesseResult = await Promise.reject('Error');
    } catch (e){
        console.log(e);
    }
}

Si la fonction async ne traite pas l’exception, qui peut être causée par l’échec d’une promesse ou un autre bug, elle retournera une promesse rejetée.

async function f() {
    // Throws une exception
    const promesseResult = await Promise.reject('Error');
}
// Printera "Error"
f()
    .then(() => console.log('Success'))
    .catch(err => console.log(err));
async function g() {
    throw "Error";
}
// Printera "Error"
g()
    .then(() => console.log('Success'))
    .catch(err => console.log(err));

Cela nous permet de traiter les promesses rejetées via le mécanisme de traitement d’erreur standard.

Discussion

Async/await est une structure du language qui vient compléter les promesses, elle nous permet de travailler avec moins de boilerplate. Par contre, async/await ne replace pas les promesses.
Par exemple, si on appelle une fonction async d’une fonction normale ou du scope global, on ne pourra pas utiliser le await et on devra utiliser les promesses.

async function fAsync() {
    // retourne promesse.resolve(5)
    return 5;
}
// On ne peut pas utiliser await fAsync(). Utiliser then/catch
fAsync()
    .then(r => console.log(`result is ${r}`));

Normalement, j’essaye d’encapsuler la majorité de ma logique asynchrone dans une ou plusieurs fonctions async, et je les appelle depuis mon code non-asynchrone. Ce qui minimise la quantité de then/catch callbacks à écrire.
Le async/await pattern est du sucre syntaxique pour être plus concis avec les promesses. N’importe quel code avec le async/await pattern peut être réécrit avec de simples promesses. En fin de compte, c’est une question de style.
Les académiques aiment noter que concurrence et parallélisme sont différents. Je vous laisse visionner le talk de Rob Pike sur le sujet.
La concurrence est la capacité de composer des processus indépendants (définition générale du terme processus) pour travailler ensemble. Quant au parallélisme, c’est être capable d’exécuter plusieurs processus simultanément.
La concurrence se concentre sur le design et la structure de l’application tandis que le parallélisme est un mode d’exécution.
Par exemple, prenons une application multi-processus. La séparation de l’application en plusieurs threads définit son modèle de concurrence. Le mapping de ces processus dans les cœurs disponibles définit son niveau de parallélisme.
Un système concurrent peut être performant sur un seul processeur et dans ce cas, il n’est pas parallèle.

Concurrence vs parallelisme
Source : http://nikgrozev.com/2017/10/01/async-await/
Avec ces definitions en tête, les promesses nous permettent de couper notre programme en plusieurs modèles concurrents qui peuvent s’exécuter en parallèle ou pas. Le fait que l’exécution du JavaScript soit parallèle ou pas dépend de son implémentation. Par exemple, Node JS est single threaded et si une promesse est CPU-bound, il n’y aura pas beaucoup de parallélisme. Par contre, si l’on compile notre code en Java Byte code via Nashorn par exemple, nous sommes capables en théorie de mapper les promesses CPU-bound dans de différents core et avoir du parallélisme. Je pense donc que les promesses, (écrites à la main ou en async/await) représentent un modèle de concurrence d’application JavaScript.
Source : Await and Async explained with Diagrams and examples, Nikolay Grozev
Traduction réalisée par Yoan Ribeiro