Des fonctions asynchrones et des promesses plus rapides
Le traitement asynchrone en JavaScript avait traditionnellement la réputation de ne pas être particulièrement rapide. Pour aggraver les choses, le débogage d'applications JavaScript en direct — en particulier des serveurs Node.js — n'est pas chose aisée, surtout lorsqu'il s'agit de programmation asynchrone. Heureusement, les temps changent. Cet article explore comment nous avons optimisé les fonctions asynchrones et les promesses dans V8 (et dans une certaine mesure dans d'autres moteurs JavaScript également), et décrit comment nous avons amélioré l'expérience de débogage de code asynchrone.
Note : Si vous préférez regarder une présentation plutôt que lire des articles, profitez de la vidéo ci-dessous ! Sinon, ignorez la vidéo et continuez à lire.
Une nouvelle approche de la programmation asynchrone
Des callbacks aux promesses, puis aux fonctions asynchrones
Avant que les promesses ne fassent partie du langage JavaScript, les API basées sur des callbacks étaient couramment utilisées pour le code asynchrone, en particulier dans Node.js. Voici un exemple :
function handler(done) {
validateParams((error) => {
if (error) return done(error);
dbQuery((error, dbResults) => {
if (error) return done(error);
serviceCall(dbResults, (error, serviceResults) => {
console.log(result);
done(error, serviceResults);
});
});
});
}
Le modèle spécifique qui consiste à utiliser des callbacks profondément imbriqués de cette manière est communément appelé « l'enfer des callbacks », car il rend le code moins lisible et difficile à maintenir.
Heureusement, maintenant que les promesses font partie du langage JavaScript, le même code peut être écrit de manière plus élégante et facile à maintenir :
function handler() {
return validateParams()
.then(dbQuery)
.then(serviceCall)
.then(result => {
console.log(result);
return result;
});
}
Plus récemment encore, JavaScript a introduit la prise en charge des fonctions asynchrones. Le code asynchrone ci-dessus peut désormais être écrit d'une manière qui ressemble beaucoup au code synchrone :
async function handler() {
await validateParams();
const dbResults = await dbQuery();
const results = await serviceCall(dbResults);
console.log(results);
return results;
}
Avec les fonctions asynchrones, le code devient plus succinct, et le flux de contrôle et de données est beaucoup plus facile à suivre, malgré le fait que l'exécution reste asynchrone. (Notez que l'exécution de JavaScript se fait toujours dans un seul thread, ce qui signifie que les fonctions asynchrones n'entraînent pas la création de threads physiques.)
Des callbacks d’écouteurs d’événements à l’itération asynchrone
Un autre paradigme asynchrone, particulièrement fréquent dans Node.js, est celui des ReadableStream
s. Voici un exemple :
const http = require('http');
http.createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
res.write(body);
res.end();
});
}).listen(1337);
Ce code peut être un peu difficile à suivre : les données entrantes sont traitées par morceaux accessibles uniquement au sein des callbacks, et le signal de fin de flux se produit également dans un callback. Il est facile d’introduire des bugs ici quand on ne réalise pas que la fonction se termine immédiatement et que le traitement réel doit se dérouler dans les callbacks.
Heureusement, une fonctionnalité ES2018 innovante appelée itération asynchrone peut simplifier ce code :
const http = require('http');
http.createServer(async (req, res) => {
try {
let body = '';
req.setEncoding('utf8');
for await (const chunk of req) {
body += chunk;
}
res.write(body);
res.end();
} catch {
res.statusCode = 500;
res.end();
}
}).listen(1337);
Au lieu de placer la logique de traitement des requêtes dans deux callbacks différents — celui de 'data'
et celui de 'end'
— nous pouvons désormais tout insérer dans une seule fonction asynchrone, et utiliser la nouvelle boucle for await…of
pour itérer à travers les morceaux de manière asynchrone. Nous avons également ajouté un bloc try-catch
pour éviter le problème de unhandledRejection
1.
Vous pouvez déjà utiliser ces nouvelles fonctionnalités en production dès aujourd'hui ! Les fonctions asynchrones sont entièrement prises en charge à partir de Node.js 8 (V8 v6.2 / Chrome 62), et les itérateurs et générateurs asynchrones sont entièrement pris en charge à partir de Node.js 10 (V8 v6.8 / Chrome 68) !
Améliorations de performances asynchrones
Nous avons réussi à améliorer significativement les performances du code asynchrone entre V8 v5.5 (Chrome 55 & Node.js 7) et V8 v6.8 (Chrome 68 & Node.js 10). Nous avons atteint un niveau de performance où les développeurs peuvent utiliser ces nouveaux paradigmes de programmation en toute confiance sans se soucier de la vitesse.
Le graphique ci-dessus montre le benchmark doxbee, qui mesure les performances du code fortement dépendant des promesses. Notez que les graphiques visualisent le temps d'exécution, c'est-à-dire que plus c'est bas, mieux c'est.
Les résultats sur le benchmark parallèle, qui évalue spécifiquement les performances de Promise.all()
, sont encore plus impressionnants :
Nous avons réussi à améliorer les performances de Promise.all
par un facteur de 8×.
Cependant, les benchmarks ci-dessus sont des micro-benchmarks synthétiques. L'équipe V8 est plus intéressée par l'impact de nos optimisations sur les performances réelles du code utilisateur.
Le graphique ci-dessus visualise les performances de certains frameworks middleware HTTP populaires qui utilisent abondamment les promesses et les fonctions async
. Notez que ce graphique montre le nombre de requêtes/seconde, donc contrairement aux graphiques précédents, plus c'est haut, mieux c'est. Les performances de ces frameworks ont considérablement augmenté entre Node.js 7 (V8 v5.5) et Node.js 10 (V8 v6.8).
Ces améliorations de performances sont le résultat de trois réalisations clés :
- TurboFan, le nouveau compilateur d'optimisation 🎉
- Orinoco, le nouveau ramasse-miettes 🚛
- un bug dans Node.js 8 provoquant le saut des microticks par
await
🐛
Lorsque nous avons lancé TurboFan dans Node.js 8, cela a donné un énorme coup de boost aux performances.
Nous avons également travaillé sur un nouveau ramasse-miettes, appelé Orinoco, qui déplace le travail de collecte des déchets hors du thread principal, améliorant ainsi considérablement le traitement des requêtes.
Et enfin, il y avait un bug pratique dans Node.js 8 qui faisait que await
sautait les microticks dans certains cas, entraînant de meilleures performances. Ce bug a commencé comme une violation involontaire de la spécification, mais il nous a ensuite donné l'idée d'une optimisation. Commençons par expliquer le comportement buggué :
Note : Le comportement suivant était correct selon la spécification JavaScript au moment de l'écriture. Depuis, notre proposition de spécification a été acceptée, et le comportement "buggué" suivant est maintenant correct.
const p = Promise.resolve();
(async () => {
await p; console.log('après:await');
})();
p.then(() => console.log('tick:a'))
.then(() => console.log('tick:b'));
Le programme ci-dessus crée une promesse p
remplie, et await
son résultat, mais aussi enchaîne deux gestionnaires dessus. Dans quel ordre vous attendriez-vous à ce que les appels console.log
s'exécutent ?
Étant donné que p
est remplie, vous pourriez vous attendre à ce qu'il imprime 'après:await'
en premier, puis les 'tick'
. En fait, c'est le comportement que vous obtiendriez dans Node.js 8 :
Bien que ce comportement semble intuitif, il n'est pas correct selon la spécification. Node.js 10 implémente le comportement correct, qui consiste à exécuter d'abord les gestionnaires enchaînés, puis seulement ensuite à continuer avec la fonction asynchrone.
Ce « comportement correct » n'est pas toujours immédiatement évident, et était en fait surprenant pour les développeurs JavaScript, donc cela mérite quelques explications. Avant de plonger dans le monde magique des promesses et fonctions asynchrones, commençons par quelques fondations.
Tâches vs micro-tâches
À un niveau élevé, il y a tâches et micro-tâches en JavaScript. Les tâches gèrent des événements comme les E/S et les temporisateurs, et s'exécutent une par une. Les micro-tâches implémentent une exécution différée pour async
/await
et les promesses, et s'exécutent à la fin de chaque tâche. La file de micro-tâches est toujours vidée avant que l'exécution ne retourne au boucle d'événements.
Pour plus de détails, consultez l'explication de Jake Archibald sur les tâches, micro-tâches, files d'attente et plannings dans le navigateur. Le modèle des tâches dans Node.js est très similaire.
Fonctions asynchrones
Selon MDN, une fonction asynchrone est une fonction qui s'exécute de manière asynchrone en utilisant une promesse implicite pour retourner son résultat. Les fonctions asynchrones sont destinées à rendre le code asynchrone semblable à du code synchrone, en cachant une partie de la complexité du traitement asynchrone au développeur.
La fonction asynchrone la plus simple possible ressemble à ceci :
async function computeAnswer() {
return 42;
}
Lorsqu'elle est appelée, elle retourne une promesse, et vous pouvez accéder à sa valeur comme avec toute autre promesse.
const p = computeAnswer();
// → Promesse
p.then(console.log);
// imprime 42 au tour suivant
Vous accédez à la valeur de cette promesse p
uniquement lors du prochain passage de micro-tâches. En d'autres termes, le programme ci-dessus est sémantiquement équivalent à l'utilisation de Promise.resolve
avec la valeur :
function computeAnswer() {
return Promise.resolve(42);
}
La véritable puissance des fonctions asynchrones provient des expressions await
, qui provoquent la suspension de l'exécution de la fonction jusqu'à ce qu'une promesse soit résolue, et la reprise après son accomplissement. La valeur d'await
est celle de la promesse accomplie. Voici un exemple montrant ce que cela signifie :
async function fetchStatus(url) {
const response = await fetch(url);
return response.status;
}
L'exécution de fetchStatus
est suspendue sur l'await
, et reprend plus tard lorsque la promesse fetch
est accomplie. Cela est à peu près équivalent à enchaîner un gestionnaire sur la promesse retournée par fetch
.
function fetchStatus(url) {
return fetch(url).then(response => response.status);
}
Ce gestionnaire contient le code suivant l'await
dans la fonction asynchrone.
Normalement, vous passeriez une Promesse
à await
, mais vous pouvez en fait attendre n'importe quelle valeur JavaScript arbitraire. Si la valeur de l'expression suivant l'await
n'est pas une promesse, elle est convertie en promesse. Cela signifie que vous pouvez await 42
si cela vous tente :
async function foo() {
const v = await 42;
return v;
}
const p = foo();
// → Promesse
p.then(console.log);
// imprime `42` finalement
Plus intéressant encore, await
fonctionne avec tout élément « thenable », c'est-à-dire tout objet possédant une méthode then
, même s'il ne s'agit pas d'une vraie promesse. Vous pouvez donc implémenter des choses amusantes comme un sommeil asynchrone qui mesure le temps réel passé à dormir :
class Sleep {
constructor(timeout) {
this.timeout = timeout;
}
then(resolve, reject) {
const startTime = Date.now();
setTimeout(() => resolve(Date.now() - startTime),
this.timeout);
}
}
(async () => {
const actualTime = await new Sleep(1000);
console.log(actualTime);
})();
Voyons ce que V8 fait pour await
sous le capot, en suivant la spécification. Voici une fonction asynchrone simple foo
:
async function foo(v) {
const w = await v;
return w;
}
Lorsqu'elle est appelée, elle enveloppe le paramètre v
dans une promesse et suspend l'exécution de la fonction asynchrone jusqu'à ce que cette promesse soit résolue. Une fois cela accompli, l'exécution de la fonction reprend et w
reçoit la valeur de la promesse accomplie. Cette valeur est ensuite renvoyée par la fonction asynchrone.
await
sous le capot
Tout d'abord, V8 marque cette fonction comme résumable, ce qui signifie que l'exécution peut être suspendue et reprise plus tard (aux points await
). Ensuite, elle crée la soi-disant implicit_promise
, qui est la promesse retournée lorsque vous invoquez la fonction asynchrone, et qui finit par se résoudre à la valeur produite par la fonction asynchrone.
Ensuite vient la partie intéressante : le await
proprement dit. Tout d'abord, la valeur passée à await
est enveloppée dans une promesse. Ensuite, des gestionnaires sont attachés à cette promesse enveloppée pour reprendre la fonction une fois la promesse accomplie, et l'exécution de la fonction asynchrone est suspendue, retournant la implicit_promise
à l'appelant. Une fois que la promesse
est accomplie, l'exécution de la fonction asynchrone reprend avec la valeur w
obtenue à partir de cette promesse
, et la implicit_promise
est résolue avec w
.
En résumé, les étapes initiales pour await v
sont :
- Envelopper
v
— la valeur passée àawait
— dans une promesse. - Attacher des gestionnaires pour reprendre la fonction asynchrone plus tard.
- Suspendre la fonction asynchrone et retourner la
implicit_promise
à l'appelant.
Parcourons les opérations individuelles étape par étape. Supposons que l'élément sur lequel on await
est déjà une promesse, qui a été accomplie avec la valeur 42
. Ensuite, le moteur crée une nouvelle promesse
et la résout avec ce qui est await
. Cela fait un chaînage différé de ces promesses au tour suivant, exprimé via ce que la spécification appelle un PromiseResolveThenableJob
.
Ensuite, le moteur crée une autre promesse dite jetable
. Elle est appelée jetable car rien ne lui est jamais enchaîné — elle est entièrement interne au moteur. Cette promesse jetable
est ensuite enchaînée à la promesse
, avec les gestionnaires appropriés pour reprendre l'exécution de la fonction asynchrone. Cette opération performPromiseThen
est essentiellement ce que fait Promise.prototype.then()
en coulisses. Enfin, l'exécution de la fonction asynchrone est suspendue et le contrôle retourne à l'appelant.
L'exécution continue chez l'appelant, et finalement la pile d'appels devient vide. Ensuite, le moteur JavaScript commence à exécuter les micro-tâches : il exécute le PromiseResolveThenableJob
précédemment planifié, qui planifie un nouveau PromiseReactionJob
pour enchaîner la promesse
à la valeur passée à await
. Le moteur retourne ensuite au traitement de la file de micro-tâches, car celle-ci doit être vidée avant de continuer avec la boucle d'événements principale.
Ensuite, voici le PromiseReactionJob
, qui remplit la promesse
avec la valeur de la promesse que nous attendons
— 42
dans ce cas — et planifie la réaction sur la promesse jetable
. Le moteur revient alors à la boucle des micro-tâches, qui contient une dernière micro-tâche à traiter.
Maintenant, ce second PromiseReactionJob
propage la résolution à la promesse jetable
et reprend l'exécution suspendue de la fonction asynchrone, en renvoyant la valeur 42
à partir du await
.
Pour résumer ce que nous avons appris, pour chaque await
, le moteur doit créer deux promesses supplémentaires (même si le côté droit est déjà une promesse) et il nécessite au moins trois ticks de la file des micro-tâches. Qui aurait pensé qu'une seule expression await
entraînerait autant de surcharge ?!
Regardons d'où vient cette surcharge. La première ligne est responsable de la création de la promesse enveloppée. La seconde ligne résout immédiatement cette promesse enveloppée avec la valeur v
attendue. Ces deux lignes sont responsables d'une promesse supplémentaire et de deux des trois ticks des micro-tâches. Cela devient assez coûteux si v
est déjà une promesse (ce qui est le cas le plus courant car les applications attendent normalement des promesses). Dans le cas improbable où un développeur attend par exemple 42
, le moteur doit néanmoins l'envelopper dans une promesse.
Il s'avère qu'il existe déjà une opération promiseResolve
dans la spécification qui effectue uniquement le processus d'enveloppement si nécessaire :
Cette opération retourne les promesses inchangées et ne fait l'enveloppement des autres valeurs en promesses que si nécessaire. De cette manière, vous économisez une des promesses supplémentaires ainsi que deux ticks dans la file des micro-tâches, dans le cas courant où la valeur passée à await
est déjà une promesse. Ce nouveau comportement est déjà activé par défaut dans V8 v7.2. Pour V8 v7.1, le nouveau comportement peut être activé à l'aide du flag --harmony-await-optimization
. Nous avons proposé ce changement à la spécification ECMAScript également.
Voici comment le await
amélioré fonctionne en coulisses, étape par étape :
Supposons à nouveau que nous attendons une promesse qui a été remplie avec 42
. Grâce à la magie de promiseResolve
, la promesse
fait maintenant simplement référence à la même promesse v
, donc rien n'est à faire dans cette étape. Ensuite, le moteur continue exactement comme avant, en créant la promesse jetable
, en planifiant un PromiseReactionJob
pour reprendre la fonction asynchrone au tick suivant de la file des micro-tâches, en suspendant l'exécution de la fonction et en retournant à l'appelant.
Ensuite, lorsque toutes les exécutions JavaScript sont terminées, le moteur commence à exécuter les micro-tâches et donc exécute le PromiseReactionJob
. Ce travail propage la résolution de la promesse
à la jetable
et reprend l'exécution de la fonction asynchrone, renvoyant 42
depuis le await
.
Cette optimisation évite d'avoir à créer une promesse enveloppée si la valeur passée à await
est déjà une promesse, et dans ce cas, nous passons d'un minimum de trois ticks de micro-tâches à seulement un tick. Ce comportement est similaire à ce que fait Node.js 8, sauf que ce n'est plus un bug — c'est maintenant une optimisation qui est en cours de standardisation !
Il semble néanmoins toujours désagréable que le moteur doive créer cette promesse jetable
, bien qu'elle soit totalement interne au moteur. Il s'avère que la promesse jetable
n'était là que pour satisfaire les contraintes API de l'opération interne performPromiseThen
dans la spécification.
Cela a été récemment abordé dans un changement éditorial de la spécification ECMAScript. Les moteurs n'ont plus besoin de créer la promesse jetable
pour await
— la plupart du temps2.
La comparaison de await
dans Node.js 10 avec le await
optimisé qui sera probablement intégré dans Node.js 12 montre l'impact de cette modification sur les performances :
async
/await
surpasse désormais le code de promesse écrit à la main. L'élément clé ici est que nous avons considérablement réduit les frais généraux des fonctions asynchrones — non seulement dans V8, mais également dans tous les moteurs JavaScript, en corrigeant la spécification.
Mise à jour : À partir de V8 v7.2 et Chrome 72, --harmony-await-optimization
est activé par défaut. Le correctif de la spécification ECMAScript a été intégré.
Expérience développeur améliorée
Outre les performances, les développeurs JavaScript se soucient également de la capacité à diagnostiquer et à résoudre les problèmes, ce qui n'est pas toujours facile lorsqu'il s'agit de code asynchrone. Chrome DevTools prend en charge les traces de pile asynchrones, c'est-à-dire des traces de pile qui incluent non seulement la partie synchronisée actuelle de la pile, mais également la partie asynchrone :
Il s'agit d'une fonctionnalité extrêmement utile lors du développement local. Cependant, cette approche ne vous aide pas vraiment une fois que l'application est déployée. Lors du débogage post-mortem, vous ne verrez que la sortie Error#stack
dans vos fichiers journaux, et cela ne vous dit rien sur les parties asynchrones.
Nous avons récemment travaillé sur les traces de pile asynchrones sans frais qui enrichissent la propriété Error#stack
avec des appels de fonction asynchrone. « Sans frais » semble excitant, non ? Comment cela peut-il être sans frais, alors que la fonctionnalité de Chrome DevTools entraîne des frais généraux importants ? Prenez cet exemple où foo
appelle bar
de manière asynchrone, et bar
lève une exception après avoir await
une promesse :
async function foo() {
await bar();
return 42;
}
async function bar() {
await Promise.resolve();
throw new Error('BEEP BEEP');
}
foo().catch(error => console.log(error.stack));
L'exécution de ce code dans Node.js 8 ou Node.js 10 donne le résultat suivant :
$ node index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
Notez que bien que l'appel à foo()
cause l'erreur, foo
ne fait pas du tout partie de la trace de pile. Cela rend difficile pour les développeurs JavaScript de réaliser un débogage post-mortem, que votre code soit déployé dans une application web ou dans un conteneur cloud.
Le point intéressant ici est que le moteur sait où il doit continuer lorsque bar
est terminé : juste après le await
dans la fonction foo
. Par coïncidence, c'est également l'endroit où la fonction foo
a été suspendue. Le moteur peut utiliser cette information pour reconstruire des parties de la trace de pile asynchrone, à savoir les emplacements de await
. Avec cette modification, la sortie devient :
$ node --async-stack-traces index.js
Error: BEEP BEEP
at bar (index.js:8:9)
at process._tickCallback (internal/process/next_tick.js:68:7)
at Function.Module.runMain (internal/modules/cjs/loader.js:745:11)
at startup (internal/bootstrap/node.js:266:19)
at bootstrapNodeJSCore (internal/bootstrap/node.js:595:3)
at async foo (index.js:2:3)
Dans la trace de pile, la fonction la plus haute dans la hiérarchie apparaît en premier, suivie du reste de la trace de pile synchronisée, suivie de l'appel asynchrone à bar
dans la fonction foo
. Ce changement est implémenté dans V8 derrière le nouveau drapeau --async-stack-traces
. Mise à jour : À partir de V8 v7.3, --async-stack-traces
est activé par défaut.
Cependant, si vous comparez cela à la trace de pile asynchrone dans Chrome DevTools ci-dessus, vous remarquerez que le point d'appel réel vers foo
est absent de la partie asynchrone de la trace de pile. Comme mentionné précédemment, cette approche utilise le fait que pour await
, les emplacements de reprise et de suspension sont les mêmes — mais pour les appels réguliers Promise#then()
ou Promise#catch()
, ce n'est pas le cas. Pour plus de contexte, voir l'explication de Mathias Bynens sur pourquoi await
dépasse Promise#then()
.
Conclusion
Nous avons rendu les fonctions asynchrones plus rapides grâce à deux optimisations significatives :
- la suppression de deux microticks supplémentaires, et
- la suppression de la promesse
throwaway
.
En plus de cela, nous avons amélioré l'expérience des développeurs grâce à des traces de pile asynchrones à coût nul, qui fonctionnent avec await
dans les fonctions asynchrones et Promise.all()
.
Et nous avons aussi quelques conseils intéressants sur les performances pour les développeurs JavaScript :
- privilégiez les fonctions
async
etawait
par rapport au code de promesse écrit manuellement, et - restez fidèle à l'implémentation native des promesses offerte par le moteur JavaScript pour profiter des raccourcis, c'est-à-dire éviter deux microticks pour
await
.
Footnotes
-
Merci à Matteo Collina de nous avoir signalé ce problème. ↩
-
V8 doit toujours créer la promesse
jetable
siasync_hooks
sont utilisés dans Node.js, car les hooksbefore
etafter
sont exécutés dans le contexte de la promessejetable
. ↩