Chapter 27

V8 エンジンによる async/await の内部変換

PADAone🐕
PADAone🐕
2023.01.23に更新

このチャプターについて

このチャプターでは、V8 エンジンによる async/await の内部変換コードから async/await の舞台裏を探索し、その挙動について理解するための解説を行います。

仕様を直接見るよりも、V8 エンジンでどうなっているかを見た方が分かりやすいので V8 からアプローチします。前のチャプターで見たとおり、async/await では若干謎の挙動が存在しています。V8 エンジンの内部変換コードを見ることでその謎は解決できます。

さて、『V8 エンジンについて』のチャプターで V8 エンジンについての予備知識はいれておきましたね。このチャプターでは、V8 公式のブログ記事とプレゼン動画を元に解説していきます。

V8 エンジンによる内部変換コード

それでは、V8 開発チームの Maya Lekova 氏と Benedikt Meurer 氏によるプレゼン動画『Holding on to your Performance Promises』と、それに基づく V8 エンジン公式サイトのブログ記事『Faster async functions and promises』を元にして async/await の V8 エンジンでの内部変換コードを見ていきます。

ブログ記事だけだと分かりづらい部分があると感じたので、動画も一緒に視聴することをおすすめします。平易な英語なので比較的聞きやすいと思います。

https://v8.dev/blog/fast-async#await-under-the-hood

https://youtu.be/DFP5DKDQfOc

内部変換後のコード

では結論として、V8 エンジンでは次のような async/await を内部的に変換しています。

シンプルな async 関数
async function foo(v) {
  const w = await v;
  return w;
}

変換後は以下のようになります。実際に公式ブログ記事に示されているものですが、これは疑似コードです。

V8エンジンによる変換コード
resumable function foo(v) {
  implicit_promise = createPromise();
  promise = promiseResolve(v);
  performPromiseThen(
    promise,
    res => resume(«foo», res),
    err => throw(«foo», err));
  w = suspend(«foo», implicit_promise);
  resolvePromise(implicit_promise, w);
}

function promiseResolve(v) {
  if (v is Promise) return v;
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

分かりやすくするために変換コードに補足のコメントを追加しておきます。

V8エンジンによる変換コード
// 途中で一次中断できる関数として resumable (再開可能) のマーキング
resumable function foo(v) {
  implicit_promise = createPromise();
  // (0) async 関数の返り値となる Promise インスタンスを作成

  // (1) v が Promise インスタンスでないならラッピングする
  promise = promiseResolve(v);
  // (2) async 関数 foo を再開またはスローするハンドラのアタッチ
  performPromiseThen(
    promise,
    res => resume(«foo», res),
    err => throw(«foo», err));

  // (3) async 関数 foo を一次中断して implicit_promise を呼び出し元へと返す
  w = suspend(«foo», implicit_promise);
  // (4) w = のところから async 関数の処理再開となる

  // (5) async 関数で return していた値である w で最終的に implict_promise を解決する
  resolvePromise(implicit_promise, w);
}

// 内部で使う関数
function promiseResolve(v) {
  // v が Promise ならそのまま返す
  if (v is Promise) return v;
  // v が Promise でないならラッピングして返す
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

基本的なステップはコメントに書いた通りです。

  • (0) V8 エンジンによって async 関数自体が実行を一次中断して後から再開できる関数として、resumable(再開可能) のマーキングをし、async 関数自体の返り値となる Promise インスタンスとして implicit_promise を作成します
  • (1) await 式の評価対象について Promise インスタンスでないならラッピングして promise に代入します
  • (2) promise が Settled になったときのハンドラを同期的にアタッチします
  • (3) async 関数の処理を suspend() で一次中断して、Promise インスタンスである implicit_promise を呼び出し元へと返却します
  • (4) promise が Settled となり次第、async 関数の処理を再開し、await 式の評価結果を w に代入するところから処理再開となります
  • (5) 最終的に async 関数内部で return していた値で implicit_promise を resolve することで呼び出し元に返されていた Promise インスタンスが Settled となります

変換後のコードで普通の return が存在していないのは、suspend() の時点で呼び出し元である Caller へと Promise インスタンスとして implicit_promise を返してるからです。async 関数はどんなときでも、Promise インスタンスを返します。async 関数の処理が一次中断して、呼び出し元に制御が戻った時にすでに返り値として Promise インスタンスを用意していなければいけません。ただし、その時に返り値の Promise インスタンスが履行されている必要はなく、Pending 状態のままでいいのです。

仕様解説

この implictPromise という async 関数から返される暗黙的な Promise オブジェクトが作成されているのは、EvaluateAsyncFunctionBodyEvaluateAsyncConcisebody 構文指向操作から呼び出される NewPromiseCapability 抽象操作です。ここから更に起動される Promise コンストラクタ関数で実際に Promise インスタンスが作成されています。

再び、async 関数の処理が再開し、最終的に async 関数で return w としていた値 wimplicit_promise が解決されることで、呼び出し元に返ってきていた Promise インスタンスが Settled になり、その値 w を Promise chain などで利用できるようになります。

implicit_promise = createPromise() は後から解決される Promise インスタンス implicit_promise を作成し、resolvePromise(implicit_promise, w) では作成したその Promise インスタンスを後から w で解決しています。細かい実装を無視してここではそういうものだと考えてください。

ということで、上記コードの説明としてもう少しコメントを追加しておきたいと思います。vv = Promise.resolve(42) というように値 42 で既に履行されている Promise インスタンスとして想定します。

V8エンジンによる変換コード
// 途中で一次中断できる関数として resumable (再開可能) のマーキング
// async 関数からは、susupend のところまで行った時点で処理を中断して Pending 状態の Promise インスタンス(implicit_promise)が呼び出し元に返される
// 通常の return は意味がない(generator の yield と同じ)
resumable function foo(v) {
  implicit_promise = createPromise();
  // async 関数の返り値となる promise インスタンスを作成
  // 非同期処理を一次中断(susupend)したときもこれが呼び出し元に返ってきている

  // 1つの await 式 (必ず1つはマイクロタスクが生成される)
  // (1). v を promise でラップする
  promise = promiseResolve(v); // v がプロミスでないならラッピング
  // (2). foo を再開するハンドラのアタッチ
      // Promise.prototype.then() が裏側で行っていることと同じ
      // promise が Settled になったらマイクロタスクを発行
      // マイクロタスクは PromiseReactionJob で async 関数の処理再開を告げる
  performPromiseThen(
    promise,
    res => resume(«foo», res),
    err => throw(«foo», err));
    // アタッチしているだけでとりあえず次に進む
  // (3). foo (async 関数)を一次中断して implicit_promise を caller へと返す
  w = suspend(«foo», implicit_promise);
  // ここまでが1つの await で、foo のコンテキストを一旦ポップする
  // w には await 式の評価結果の値が代入される(yields 42 from the await)
  // w = のところに値が入り実行再開する(w には promise の履行値 42 が入る)

  resolvePromise(implicit_promise, w); // return する値 w (= 42)で resolve する
  // caller へ返していた Promise インスタンスが Settled になる
}

// 使う関数
function promiseResolve(v) {
  // v が promise ならそのまま返す
  if (v is Promise) return v;
  // そうでないならプロミスでラップして返す
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

基本的に w = await v; のように各 await 式ごとに次の部分が必要となります。w = のように代入しないなら単に suspend(«foo», implicit_promise); となり、そのポイントから処理再開となることは変わりません。

  // (1) v が Promise インスタンスでないならラッピングする
  promise = promiseResolve(v);
  // (2) async 関数 foo を再開するハンドラのアタッチ
  performPromiseThen(
    promise,
    res => resume(«foo», res),
    err => throw(«foo», err));
  // (3) async 関数 foo を一次中断して implicit_promise を呼び出し元へと返す
  w = suspend(«foo», implicit_promise);

別のプレゼンの前資料である次のドキュメントから借用したコードで考えると次のようにもできます。

Zero-cost async stack traces - Google ドキュメント

別の書き方
const .promise = @promiseResolve(x);
@performPromiseThen(.promise,
  res => @resume(.generator_object, res),
  err => @throw(.generator_object, err));
@yield(.generator_object, .outer_promise);
ジェネレータ関数の yield

上のコードの書き方で yield というキーワードがでてきましたが、async 関数と yield キーワードが内部で利用できるジェネレータ関数には関係性があります。

まず、ジェネレータ関数では yield の数だけ関数の処理を一次中断して値を生み出すことができます。

yieldSample.js
// ジェネレータ関数の定義
function* generatorFn(n) {
  n++;
  yield n;
  n *= 5;
  yield n;
  n = 0;
  yield n;
}
// ジェネレータオブジェクトをジェネレータ関数から取得
const generator = generatorFn(5);

// ジェネレータオブジェクトの next メソッドでイテレータリザルトを返す
console.log(generator.next()); // => { value: 6, done: false }
console.log("関数を一次中断してなにか別の処理");

console.log(generator.next()); // => { value: 30, done: false }
console.log("関数を一次中断してなにか別の処理");

console.log(generator.next()); // => { value: 0, done: false }
console.log("関数を一次中断してなにか別の処理");

console.log(generator.next()); // => { value: undefined, done: true }
console.log("ジェネレータ関数内のすべての処理を終了");

このスクリプトを実行すると次のような出力を得ます。

❯ deno run yieldSample.js
{ value: 6, done: false }
関数を一次中断してなにか別の処理
{ value: 30, done: false }
関数を一次中断してなにか別の処理
{ value: 0, done: false }
関数を一次中断してなにか別の処理
{ value: undefined, done: true }
ジェネレータ関数内のすべての処理を終了

async/await では最初の await 式でのみ暗黙的に async 関数から返される Promise インスタンスを yield していると考えることができます。それ以降は await 式による評価のたびに一次中断しますが、呼び出し元に値を返しません。最終的に async 関数内の処理がすべて完了すると async 関数内で return されている値で最初に返した Promise インスタンスを履行します。

あるいは async 関数内部でジェネレータが使われているとも考えることができます。実際、async/await が ECMAScript に導入されるまではこのジェネレータ関数と Promise インスタンスを組み合わせて async 関数のようなものつくっていたそうです。async 関数を使ったコードを Babel や TypeScript で古い JavaScript にトランスパイルする際にはジェネレータ関数と Promise インスタンスを組み合わせて実現しています。

ジェネレータ関数について知らなければこのことについてはとりあえずは無視してもよいです。

ジェネレータ関数や yield については第4章の「イテレータとイテラブルとジェネレータ関数」で解説します。

await 式は確実にマイクロタスクを1つ発行する

performPromiseThen() の箇所に注目してほしいのですが、これは Promise.prototype.then() が舞台裏でやっていることと本質的に同じことです。

仕様解説

実際、performPromiseThen という関数は ECMAScript 仕様に存在している PerformPromiseThen という抽象操作であり、以下のように Promise.prototype.then メソッドの仕様から呼び出されています。

algorithm-stepshttps://tc39.es/ecma262/#sec-promise.prototype.then より

以下のような操作で promise の状態が履行状態であれば、コールバックとして登録してある再開関数がマイクロタスクとしてエンキューされて実行されます。

performPromiseThen(
  promise,
  res => resume(«foo», res), // onFulfilled (再開)
  err => throw(«foo», err)); // onRejected (throw)

peformPromiseThen() に渡す引数である promise が Settled になることで、then() メソッドのコールバックのようにマイクロタスクが発行されます。このマイクロタスクは PromiseReactionJob と呼ばれています。仕様的には NewPromiseReactionJob という抽象操作から作成されます。

この PromiseReactionJob というマイクロタスクがマイクロタスクキューからコールスタックへと送られます。そのマイクロタスクによって更にコールスタック上で async 関数の関数実行コンテキストが再度プッシュされて積まれることで処理を再開できるようになっています。await 式ごとにこの performPromiseThen() の実行が必要となります。つまり、then() メソッドのようにマイクロタスクが発行されるので、Promise chain で考えれば理解できるはずです。

await 式が2個ある場合

それでは、今までの内容を踏まえて、今度は await 式が2個ある場合を考えてみます。

await式が2個ある async 関数
async function foo2(v, x) {
  await v;
  console.log("Microtask1");
  await x;
  console.log("Microtask2");
  return 42;
}

V8 エンジンによる変換として考えられるコードは以下のようになります。

V8エンジンによる変換コード
resumable function foo2(v, x) {
  implicit_promise = createPromise();
  // async 関数の返り値となる promise インスタンスを作成

  // <<await v>>
  promise1 = promiseResolve(v);
  performPromiseThen(promise1,
    res => resume(«foo2», res),
    err => throw(«foo2», err));
  suspend(«foo2», implicit_promise);
  // 呼び出し元に implicit_promise を返す
  // 中断かつ処理再開のポイント

  console.log("Microtask1");

  // <<await x>>
  promise2 = promiseResolve(x);
  performPromiseThen(promise2,
    res => resume(«foo2», res),
    err => throw(«foo2», err));
  suspend(«foo2», implicit_promise);
  // implicit_promise はすでに返されているのでここでは一次中断するだけ
  // 中断かつ処理再開のポイント

  console.log("Microtask2");

  resolvePromise(implicit_promise, 42);
  // 最終的に return する値 42 で resolve する
}

色々なパターン

さて、基本的な変換が分かったので、もう少し深く潜ってみたいと思います。この変換を基本系に色々な async/await を考えてみます。

こちらの uhyo さんの記事で紹介されているような色々なパターンと、その速度 (マイクロタスクをいくつ発行するか) についても考えてみましょう。

https://zenn.dev/uhyo/articles/return-await-promise

通常の関数で Promise を返す場合

まずは、比較対象として Promise インスタンスを返す通常の関数を考えてみましょう。

// asyncSpeedY.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve()
  .then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

// 通常の関数で即時実行
(function returnPromise() {
  // return new Promise(resolve => {
  //   resolve();
  // });
  // どっちでも同じ
  return Promise.resolve();
  // マイクロタスクは発生しない
})().then(() => console.log("👦 [4] <2-Sync> MICRO: then after function"));

Promise.resolve()
  .then(() => console.log("👦 [5] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [6] <4-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

通常の関数なので V8 エンジンによる async/await の変換はありません。

Promise.resolve().then() によって同期的に (直ちに) マイクロタスクキューへコールバックがマイクロタスクとして発行されます。また、即時実行関数の中でも履行状態で作成される Promise インスタンスが返されるため、次の then() メソッドのコールバックが同期的に (直ちに) マイクロタスクキューへマイクロタスクとして発行されます。ということで、関数内部で余計なマイクロタスクは発生しません。

これを V8 エンジンで実行すると次のように予測が簡単な出力を得ます。Chrome、Node、Deno でやっても全部同じです。

# v8 コマンドで JavaScript ファイルを実行
❯ v8 asyncSpeedY.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👦 [4] <2-Sync> MICRO: then after function
👦 [5] <3-Sync> MICRO: then
👦 [6] <4-Async> MICRO: then

await も return も無い場合

それでは次に、await 式も return も無い async 関数を考えてみましょう。次のようなシンプルに何もしない async 関数の変換はどうなるでしょうか?

何もしない async 関数
async function empty() {}

V8 エンジンは次のように内部的に変換すると想定されます。

V8_Converting
resumable function empty() {
  implicit_promise = createPromise();

  // await 式はないので中断しない

  resolvePromise(implicit_promise, undefined);
  // return する値はないので undefined で resolve する
  // 返される Promise インスタンスは直ちに履行状態となる(マイクロタスクは発生しない)
}

await がないので、各 await 式に必要ないつものコードはありません。そして、return している値も無いので、return する値は undefined となり、async 関数から返される Promise インスタンスは undefined で解決されます。

そして peformPromiseThen() が無いのでマイクロタスクは1つも発行されず、async 関数から返ってくる Promise インスタンスはただちに履行状態となります。

それでは、次のコードの実行順番を予測します。

// asyncSpeed1.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

// async 関数を即時実行
(async function empty() {})().then(() => console.log("👦 [4] <2-Sync> MICRO: then after async function"));

Promise.resolve()
  .then(() => console.log("👦 [5] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [6] <4-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

今回も即時実行で関数を実行します。async 関数からは Promise インスタンスが必ず返ってくるので、then() メソッドで Promise chain を構築できます。

それではマイクロタスクについて考えてみましょう。

まずは、Promise.resolve().then() で同期的にマイクロタスクが発行されて、その次の肝心の async function の即時実行でも、上の変換で見たように関数から返えされる Promise インスタンス自体は直ちに履行状態となるので、then() メソッドのコールバックがマイクロタスクとしてマイクロタスクキューに送られます。次の Promise.resolve.then() メソッドのコールバックも同期的にマイクロタスクを発行してキューへ送られます。

スクリプト評価による同期処理がすべて終わり、コールスタックからグローバルコンテキストがポップして破棄されることで、コールスタックが空になるので、マイクロタスクのチェックポイントとなります。マイクロタスクキューの先頭にあるものから順番にすべて処理されていきます。

3番目にマイクロタスクキューへ送られたコールバック () => console.log("👦 [5] <3-Sync> MICRO: then") が実行された時点で、元々の Promise.resolve().then() で返ってくる Promise インスタンスが履行状態となるので、Promise.resolve().then().then() のコールバックがマイクロタスクキューに送られて直ちにコールスタックへと積まれて実行されます。

ということで、実行結果は次のようになります。

❯ v8 asyncSpeed1.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👦 [4] <2-Sync> MICRO: then after async function
👦 [5] <3-Sync> MICRO: then
👦 [6] <4-Async> MICRO: then

await 42 の場合

次は、async 関数内で await 42 だけをする場合を考えてみます。

foo4
async function foo4() {
  await 42;
}

↓ V8 エンジンによる内部変換として想定されるコード。

V8_Converting
resumable function foo4() {
  implicit_promise = createPromise();

  // <- await 式
  promise = promiseResolve(42); // プロミスでないのでラップする
  // promise が Settled になったら処理再開を告げるマイクロタスクを発行
  // すでに Settled となるので直ちにマイクロタスクを発行
  performPromiseThen(
    promise,
    res => resume(«foo4», res),
    err => throw(«foo4», err));
  // async 関数を一次中断して、呼び出し元に implicit_promise を返す
  suspend(«foo4», implicit_promise);
  // await 式 -> (再開処理だが特にやることはない)

  resolvePromise(implicit_promise, undefined);
  // 呼び出し元への返り値である implicit_promise に対して
  // return したものはなにもないので undefined で resolve する

  // 発生するマイクロタスクは合計1つ
  // (つまりthenのコールバックを起動できるまでマイクロタスク一個分)
}
function promiseResolve(v) {
  if (v is Promise) return v;
  // promise ではないのでラッピングする
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

await 式というのは通常は Promise インスタンスを評価し、Promise インスタンスの評価結果としてその履行値を返すという使い方をしますが、Promise インスタンスでないものも評価できます。

その場合は、promise = promiseResolve(42) であるように、Promise インスタンスでない場合として新しい Promise でラッピングされます (await 式で評価する値自体で解決する Promise インスタンス)。

いずれにせよ performPromiseThen() を行うため、作成された Promise インスタンスが Settled になるまで待ち、Settled になった時点で async 関数の処理再開を告げるマイクロタスクを発行します。この場合は Promise インスタンスがすぐに履行状態になるので、同期的にマイクロタスクを直ちに発行します。

ということで、async 関数から返ってくる Promise インスタンスにチェーンする then() メソッドのコールバックの実行はマイクロタスク1回が実行されるまで待つ必要があります。

// asyncSpeed8.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

// async function から返る Promise はマイクロタスク一個の実行で履行状態で then でマイクロタスク発行
(async function foo4() {
  await 42;
  // マイクロタスク一個だけ発行する
  // <2-Sync>
})().then(() =>
  console.log("👻 [5] <4-Async> MICRO: then after async function")
);

Promise.resolve()
  .then(() => console.log("👦 [4] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [6] <5-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

ということで、今までの場合と違い async 関数の内部でマイクロタスクが一個だけ発行されるので、async 関数から返される Promise インスタンスが履行状態になるにはそのマイクロタスクが処理される必要があります。従って、chain している then() メソッドのコールバックがマイクロタスクとして発行されるタイミングが今までのようにすぐにではなく、ずれることになります。

従って、実行順番は次のようになります。

❯ v8 asyncSpeed8.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👦 [4] <3-Sync> MICRO: then
👻 [5] <4-Async> MICRO: then after async function
👦 [6] <5-Async> MICRO: then

await Promise.resolve(42) の場合

今度は、すでに履行状態の Promise インスタンスを await してみましょう。

fooZ
async function fooZ() {
  await Promise.resolve(42);
}

↓ V8 エンジンによる内部変換コードは次のようになると想定されます。

V8_Converting
resumable function fooZ() {
  implicit_promise = createPromise();

  // <- await 式
  promise = promiseResolve(Promise.resolve(42)); // プロミスなのでそのまま返す
  // promise が Settled になったら処理再開を告げるマイクロタスクを発行
  // 最初から fulfillled(Settled) となるので直ちにマイクロタスクを発行
  performPromiseThen(
    promise,
    res => resume(«fooZ», res),
    err => throw(«fooZ», err));
  // async 関数を一次中断して、呼び出し元に implicit_promise を返す
  suspend(«fooZ», implicit_promise); // await 式 ->
  // 再開処理だが特にやることはない

  resolvePromise(implicit_promise, undefined);
  // 呼び出し元への返り値である implicit_promise に対して
  // return したものはなにもないので undefined で resolve する

  // 発生するマイクロタスクは合計1つ
  // (つまりthenのコールバックを起動できるまでマイクロタスク一個分)
}
function promiseResolve(v) {
  // プロミスなのでそのまま返す
  if (v is Promise) return v;
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

この場合は実は await 42 と同じで、内部的にマイクロタスクを1つ発行することになります。そういう訳で、await何を評価しようが少なくともマイクロタスク1つが発行される ことになります。つまり、各 await 式において最低でも1つマイクロタスクが発行されます。

ということで、次のように Math.random() < 0.5 で 50% ずつの確率で分岐するコードでは実行結果は同じになります。

// awaitPlainValue.js
const returnPromise = () => Promise.resolve();
console.log("🦖 [1] MAINLINE: Start");

(async () => {
  console.log("🦖 [2] MAINLINE: In async function");
  // どちらの場合でも同じ
  if (Math.random() < 0.5) {
    await 1;
    console.log("👦 [4] <1> MICRO: after await");
    await 2;
    console.log("👦 [6] <3> MICRO: after await");
    await 3;
    console.log("👦 [8] <5> MICRO: after await");
  } else {
    await returnPromise();
    console.log("👦 [4] <1> MICRO: after await");
    await returnPromise();
    console.log("👦 [6] <3> MICRO: after await");
    await returnPromise();
    console.log("👦 [8] <5> MICRO: after await");
  }
  return 4;
})().then(() => console.log("👦 [10] <7> MICRO: then cb after async func"));

Promise.resolve()
  .then(() => console.log("👦 [5] <2> MICRO: then cb"))
  .then(() => console.log("👦 [7] <4> MICRO: then cb"))
  .then(() => console.log("👦 [9] <6> MICRO: then cb"));

console.log("🦖 [3] MAINLINE: End");

実行順番は次のようになります (どちらの場合でも同じ)。

❯ v8 awaitPlainValu.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: In async function
🦖 [3] MAINLINE: End
👦 [4] <1> MICRO: after await
👦 [5] <2> MICRO: then cb
👦 [6] <3> MICRO: after await
👦 [7] <4> MICRO: then cb
👦 [8] <5> MICRO: after await
👦 [9] <6> MICRO: then cb
👦 [10] <7> MICRO: then cb after async func

await promise chain の場合

次は、既に履行状態の Promise インスタンスではなく、履行するまで1つマイクロタスクが必要な Promise chain を await してみましょう。

foo9
async function foo9() {
  await Promise.resolve("😭").then(value => console.log(value));
}

↓ V8 エンジンによる内部変換。

V8_Converting
resumable function foo9() {
  implicit_promise = createPromise();

  // <- await 式
  promise = promiseResolve(Promise.resolve("😭").then(value => console.log(value)));
  // promise インスタンスなのでそのまま返す
  // promise が Settled になったら処理再開を告げるマイクロタスクを発行
  // その前に一回はマイクロタスクが必要
  performPromiseThen(
    promise,
    res => resume(«foo9», res),
    err => throw(«foo9», err));
  suspend(«foo9», implicit_promise);
  // await 式 ->
  // ここまでで二回はマイクロタスクを使用している
  // 再開処理だが特にやることはない

  resolvePromise(implicit_promise, undefined);
  // 呼び出し元への返り値である implicit_promise に対して
  // return したものはなにもないので undefined で履行

  // 発生するマイクロタスクは合計2つ
  // (つまりthenのコールバックを起動できるまでマイクロタスク2個分)
}
function promiseResolve(v) {
  // promise インスタンスなのでそのまま返す
  if (v is Promise) return v;
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

何を await しようがマイクロタスクは確実に1個発行されますが、今回のケースでは、promise が Settled になるまでに1つマイクロタスクが必要となります。それが実行されてから、promise が Settled になりマイクロタスクが再び発行されるので、マイクロタスクは合計2つ必要となります (今までの場合よりも一個多い)。

実際のコードを考えてみましょう。ここまで来ると非常に予測が難しくなります。

// asyncSpeed9.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

(async function foo9() {
  await Promise.resolve("😭").then((value) =>
    console.log("🦄 [4] <2-Sync> MICRO: then inside", value) // これが一回分
  );
  // async 関数から返される Promise インスタンスが履行するまで合計マイクロタスク2回分必要
  // <4-Async>
})().then(() => console.log("👻 [7] <6-Async> MICRO: then after async function"));

Promise.resolve()
  .then(() => console.log("👦 [5] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [6] <5-Async> MICRO: then"))
  .then(() => console.log("👦 [8] <7-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

<> で囲んである数字はマイクロタスクキューに追加される順番で、Sync は同期的にマイクロタスクキューに送られて、Async はその前のマイクロタスク実行後に非同期的にマイクロタスクキューに送られる場合となっています。

async 関数から返される Promise インスタンスが履行状態になるまでに内部発生するマイクロタスク2個分が実行される必要があるため、実行結果は次のようになります。

❯ v8 asyncSpeed9.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
🦄 [4] <2-Sync> MICRO: then inside 😭
👦 [5] <3-Sync> MICRO: then
👦 [6] <5-Async> MICRO: then
👻 [7] <6-Async> MICRO: then after async function
👦 [8] <7-Async> MICRO: then

というわけで、Promise chain を await するとチェーンの数だけマイクロタスクが必要となります。

return 42 の場合

今度は、async 関数の中で何も await せずに単なる数値 42 を返す async 関数を考えてみます。

foo0
async function foo0() {
  return 42;
}

↓ V8 エンジンで内部変換されるコードは次のようになると想定されます。

V8_Converting
resumable function foo0() {
  implicit_promise = createPromise();
  // <- await 式 が無いので中断しない ->
  resolvePromise(implicit_promise, 42);
  // 最終的に return する値 42 で resolve する
  // 内部ではマイクロタスクは1つも生成されない
}

await も return も無い場合と同じく、この場合はマイクロタスクが1つも発生しません。ということは、この async 関数から返される Promise インスタンスは同期的に (直ちに) 履行状態となります。

// asyncSpeed0.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

// async function から返る Promise は直ちに履行状態で then でマイクロタスク発行
(async function foo0() { return 42; })().then(() =>
  console.log("👻 [4] <2-Sync> MICRO: then after async function")
);

Promise.resolve()
  .then(() => console.log("👦 [5] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [6] <4-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

このコードの実行結果は次のようになります。前のケースと同じです。

❯ v8 asyncSpeed0.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👻 [4] <2-Sync> MICRO: then after async function
👦 [5] <3-Sync> MICRO: then
👦 [6] <4-Async> MICRO: then

return await Promise.resolve(42) の場合

さて、そろそろ問題のケースに突入します。実際に問題となるのは、return Promise.resolve(42) の場合なのですが、その前に簡単な return await Promise.resolve(42) を考えてみます。

次のようなシンプルな async 関数を再び考えてみます。

foo4
async function foo4() {
  return await Promise.resolve(42);
}

こままだと V8 の変換がしづらいので分解して考えてみましょう。次のコードは上と同じです。

foo4
async function foo4() {
  const value = await Promise.resolve(42);
  return value;
}

この V8 エンジンによる内部変換コードは次のようになると想定されます。

V8_Converting
resumable function foo4() {
  implicit_promise = createPromise();

  // <- await 式
  promise = promiseResolve(Promise.resolve(42)); // プロミスならそのまま返す
  // promise が Settled になったら処理再開を告げるマイクロタスクを発行
  // すでに Settled なので直ちにマイクロタスクを発行
  performPromiseThen(
    promise,
    res => resume(«foo4», res),
    err => throw(«foo4», err));
  // async 関数を一次中断して、呼び出し元に implicit_promise を返す
  value = suspend(«foo4», implicit_promise); // await 式 ->
  // <- value = に await 式の評価結果が入るところから再開処理 ->
  // value には Promiseから取り出された値(この場合は 42)が入る

  resolvePromise(implicit_promise, value);
  // 呼び出し元への返り値である implicit_promise に対して
  // 元々 return していた値 value で resolve する
  // value は await 式で取り出された 42

  // 発生するマイクロタスクは合計1つ
  // (つまりthenのコールバックを起動できるまでマイクロタスク一個分)
}
// 使う関数
function promiseResolve(v) {
  // プロミスならそのまま返す
  if (v is Promise) return v;
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

この場合はマイクロタスクが1つで済みます。後で説明しますが、実は return Promise.resolve(42) では1つとなりません。

実際のコードで再び考えてみましょう。

// asyncSpeed4.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

(async function foo4() {
  return await Promise.resolve();
  // 内部的にマイクロタスクが1つだけ生成される <2-Sync>
})().then(() => console.log("👻 [5] <4-ASync> MICRO: then after async function"));

Promise.resolve()
  .then(() => console.log("👦 [4] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [6] <5-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

これを実行すると以下の結果を得ます。

❯ v8 asyncSpeed4.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👦 [4] <3-Sync> MICRO: then
👻 [5] <4-ASync> MICRO: then after async function
👦 [6] <5-Async> MICRO: then

return Promise.resolve(42) の場合

さて、実はこれが一番やっかいなパターンです。結論から言うと、return await Promise.resolve(42) の場合はマイクロタスクが1つで済んだのに、return Promise.resolve(42) の場合にはマイクロタスクが2つ発生します。

再び単純な async 関数を考えてみます。

foo3
async function foo3() {
  return Promise.resolve(42);
}

V8 エンジンによる内部変換コードとして想定されるコードは以下となります。

V8_Converting
resumable function foo3() {
  implicit_promise = createPromise();

  // <- await 式 なし ->

  resolvePromise(implicit_promise, Promise.resolve(42));
  // return する値 Promise.resolve(42) で implicit_promise を resolve する
  // この時に内部ではマイクロタスクが2つ生成される(resolve関数にPromiseを渡すから)
}

resolvePromise() の部分に注目してください。implicit_promisePromise.resolve(42) という Promise インスタンスで resolve を試みています。resolvePromise() 自体は resolve() 関数とやっていることは同じなので、resolvePromise() は resolve する対象を引数にとって外部から Promise 解決ができる resolve() 関数として考えてください。

ECMAScript の仕様では「resolve() 関数に渡された Promise の then メソッドを呼ぶという処理をマイクロタスクとして実行する」と決まっています。

こちらについては、uhyo さんの記事で詳しく解説されています。

https://zenn.dev/uhyo/articles/return-await-promise

具体的な仕様は以下の NewPromiseResolveThenableJob 抽象操作の step.1-b です。

  • b. Let thenCallResult be Completion(HostCallJobCallback(then, thenable, « resolvingFunctions.[[Resolve]], resolvingFunctions.[[Reject]] »)).

これについては『Promise.prototype.then の仕様挙動』のチャプターでも新しく解説しています。

この記事では V8 エンジンの内部変換で考えるので、上の記事にように通常の関数に戻して考えるのではなく、async 関数の内部変換後に起きることでそのまま考えてみます。

resolvePromise() という操作は以前に作成した Promise インスタンスに対して、コンストラクタ外部から resolve を起動して第二引数の値によって解決を試みるという操作ですが、基本的にはコンストラクタで resolve() するのと変わりません。

V8 内部変換で実際にどのようにしているかは分かりませんが、通常は Promise コンストラクタで resolve するところですが、コンストラクタの外部から resolve するということが実は可能です。

https://stackoverflow.com/questions/26150232/resolve-javascript-promise-outside-the-promise-constructor-scope

let promiseResolve, promiseReject;

const promise = new Promise((resolve, reject) => {
  promiseResolve = resolve;
  promiseReject = reject;
}).then(() => console.log("resolve完了"));

setTimeout(() => {
  console.log("Start");
  promiseResolve();
  console.log("End");
}, 1000);

/* 出力結果
Start
End
resolve完了
*/

V8 での変換後のコードにある resolvePromise() を再度考えます。

  resolvePromise(implicit_promise, Promise.resolve(42));

コンストラクタ外部からの resolve ができることを踏まると、結局このコードは以下のようなことを行っています。実際には Promise インスタンスは以前に作成したもので、外部から resolve していますが、分かりやすいようにあえてコンストラクタ関数で考えています。

V8_convertingで考える
// Promise.resolve(42) で implicit_proise を resolve する
const implicit_promise = new Promise(resolve => {
  resolve(Promise.resolve(42));
});

このとき、「resolve() 関数に渡された Promise の then() メソッドを呼ぶマイクロタスクを発行する」というように仕様で決まっているわけですから上のコードは次のよう変形できます。文字通り resolve() 関数に渡されている Promise.resolve(42)then() メソッドを呼び出します。分かりづらいですが、マイクロタスクを発行するために、Promise.resolve().then() が上から包んでいます。

const implicit_promise = new Promise(resolve => {
  Promise.resolve().then(() => {
    Promise.resolve(42).then(resolve);
  });
});

少し分かりやすくするために、あえて queueMicrotask() API を使って書き直すと次のようになります。

const implicit_promise = new Promise(resolve => {
  // Promise.resolve(42) の then メソッドを呼び出すマイクロタスクを発行する
  queueMicrotask(() => {
    // このコールバックが1つ目のマイクロタスク
    Promise.resolve(42).then(resolve);
    //                       ^^^^^^^ このコールバックが2つ目のマイクロタスク
  });
  // 合計二個のマイクロタスクが必要
});

ということで、implicit_promise が Settled になるまでに、上のコードではあきらかにマイクロタスクを2つ必要としています (then() メソッドのコールバック関数がマイクロタスクとして2回発行されます)。

実際のコードで実行順番を考えてみましょう。

// asyncSpeed3.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

(async function foo3() {
  return Promise.resolve(42);
  // 内部的にマイクロタスクが2つ必要となる
  // <2-Sync>
  // <4-Async>
})().then((data) => console.log("👻 [6] <6-Async> MICRO: then after async function", data));

Promise.resolve()
  .then(() => console.log("👦 [4] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [5] <5-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

<> で数字を囲んである部分がマイクロタスクとして発行されるのでマーキングしてあります。<> 内の数字がマイクロタスクとして発行される順番です。

❯ v8 asyncSpeed3.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👦 [4] <3-Sync> MICRO: then
👦 [5] <5-Async> MICRO: then
👻 [6] <6-Async> MICRO: then after async function 42

この場合、return await Promise.resolve() に比べて1つマイクロタスクが多く発生するので、このような結果となっています。42 という値が Promise chain で値として繋げていることも分かりますね。

ここで注目すべきは、return await Promise.resolve(42) の場合と return Promise.resolve(42) の場合の違いです。

foo4
async function foo4() {
  // return await Promise.resolve(42); // 同じ
  // マイクロタスクは内部で1つだけ発生
  const value = await Promise.resolve(42);
  return value; // 42 という値
}

async function foo3() {
  // マイクロタスクは内部で2つ発生
  return Promise.resolve(42);
}

V8 での async/await の内部変換では await 式ごとに次の箇所が必要となりました (以下は foo4 の場合)。

foo4におけるawait式の変換
  promise = promiseResolve(Promise.resolve()); // プロミスならそのまま返す
  // promise が Settled になったら処理再開を告げるマイクロタスクを発行
  // すでに Settled なので直ちにマイクロタスクを発行
  performPromiseThen(
    promise,
    res => resume(«foo4», res),
    err => throw(«foo4», err));
  value = suspend(«foo4», implicit_promise);

そして、promiseResolve() という操作は次の内部で使用される関数でした。

function promiseResolve(v) {
  // プロミスならそのまま返す
  if (v is Promise) return v;
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

お分かりだと思いますが、promiseResolve() では引数が Promise インスタンスならそのまま返します。いずれにせよ await 式では確実にマイクロタスクが1つ発生します。

foo4foo3 の V8 による変換コードを比較して考えると、いずれにせよ最初に async 関数の返り値となる implicit_Promise は作成します。await 式の有無によって、foo4 の方にマイクロタスクが1つ発生して、foo3 の方では await 式が無いのでマイクロタスクが発生していないため、この時点では foo3 の方が優れているように見えます。

問題となるポイントは、最後の resolvePromise() の場所です。Promise の resolve に Promise インスタンスを使用しているかしていないかです。

仕様上、Promise インスタンスで resolve を試みるとマイクロタスクが2個発生します。

Promise インスタンス以外で resolve を試みるとマイクロタスクは発生せずに Promise インスタンスの状態が直ちに遷移します。ということで、途中までは foo3 の方が余計なマイクロタスクを生成していないように思えましたが、最終的に async 関数の返り値となる implicit_promise を解決する際に余計なマイクロタスクが2つ生成されていまったので、foo4 の方がマイクロタスクが少なく済みます。

Promise インスタンスで resolve するということ

ある Promise インスタンスのコンストラクタで resolve() 関数や Promise.resolve() の引数として、Promise インスタンスを渡すと Unwrapping という現象がおき、引数として渡した Promise インスタンスの状態や履行値、拒否理由などを自身の状態と値として同化できます。

ただし、この Promise.resolve() と Executor 関数の resolve() の2つには注意すべき違いがあります。

上で見たようにまずは Executor 関数の resolve() 関数に Promise インスタンスを渡した場合は注意が必要です。次のようなコードで Promise インスタンスで resolve を試みることでコードの実行順番が直感的に予測しずらくなります。

// コードの参照元 : https://twitter.com/ferdaber/status/1098318363305099264?s=20&t=Qu2h-Aa0IhI5Lh-bxPkcOw

new Promise(resolve => {
  resolve('a');
}).then(console.log);

new Promise(resolve => {
  resolve(Promise.resolve('b'));
}).then(console.log);

Promise.resolve(Promise.resolve('c')).then(console.log);

このコードの実行の順番は次のようになります。

a
c
b

Promise() コンストラクタに引数として渡すコールバックである Executor 関数自体は同期的に実行されるので、直感的にはすべてのマイクロタスクが直ちにマイクロタスクキューへ送られて順番に処理されると考えて a → b → c の順番であると予測してしまいます。ですが、2 番目の Promise() コンストラクタ内部では Promise.resolve('B') という Promise インスタンスで resolve を試みているため、このような結果となります。

new Promise(resolve => {
  resolve(Promise.resolve('b'));
}).then(console.log);

このコードは上で見たように ECMAScript の仕様において resolve() 関数に渡された Promise の then() メソッドを呼ぶというマイクロタスクを発行するというように決まっているわけですから、次のように変換できます。

new Promise(resolve => {
  Promise.resolve().then(() => {
    Promise.resolve('b').then(resolve);
  });
}).then(console.log);

分かりやすく queueMicrotask() API を使って書き直すと次のようになります。

new Promise(resolve => {
  queueMicrotask(() => { // このコールバックが1つ目のマイクロタスク
    Promise.resolve('b').then(resolve);
    //                        ^^^^^^^ このコールバックが2つ目のマイクロタスク
  });
  // この Promise が解決されるまで2つのマイクロタスクが必要
}).then(console.log);

そういう訳で、この Promise インスタンスが履行状態となるまでにマイクロタスクが2個必要となり、出力順番は a → c → b となるわけです。

それでは、次の場合はどうなるでしょうか?

// resolveWithPromise2.js
new Promise((resolve) => {
  resolve("Q");
}).then(value => console.log("[1]", value)); // <1-Sync>

Promise.resolve(Promise.resolve(Promise.resolve("I")))
  .then(value => console.log("[2]", value)); // <2-Sync>

new Promise((resolve) => {
  resolve(Promise.resolve("S")); // <3-Sync> <5-Async>
}).then(value => console.log("[5]", value)); // <7-Async>

Promise.resolve(Promise.resolve("U"))
  .then(value => console.log("[3]", value)) // <4-Sync>
  .then(() => console.log("[4]", "V")); // <6-Async>

Promise.resolve(Promise.resolve(42)) の場合と resolve(Promise.resolve(42)) の場合では話が違うので注意してください。

Promise.resolve() の引数に Promise インスタンスを渡すと マイクロタスクは発生せずにそのまま引数の Promise インスタンスが返ってきます。ということで、上のように Promise.resolve() 自体をいくらネストしようが内部でマイクロタスクは発生せずに直ちに履行状態となります。

従って、実行結果は次のようになります。

❯ v8 resolveWithPromise2.js
[1] Q
[2] I
[3] U
[4] V
[5] S

Promise() コンストラクタに渡す Executor 関数の引数である resolve() 関数が特殊ですので注意してください。

どっちを使うべき?

スタックトレースの比較では return await Promise.resolve(42) (つまり foo4) の方が詳細に情報が表示されます。

これについては、azukiazusa さんの記事で解説されています。

https://zenn.dev/azukiazusa/articles/difference-between-return-and-return-await#スタックトレースの出力

また、async stack trace については Masaki Hara さんの次の記事で詳細に解説されています。

https://zenn.dev/qnighy/articles/3a999fdecc3e81#非同期スタックトレース

具体的にどちらが優れているかというのは、それぞれ意見があると思いますが、マイクロタスクの発生が増加することで直感的に処理予測がしづらくなるので return await の方が個人的にはいいかなと思います。

他にも、Deno のビルトインリンターでは async 関数内部に await 式が無いことで怒られてしまう上に、そもそも async 関数と await 式の両者があることで非同期の振る舞いを記述することが基本です。そして、どちらを使うべきか分からないようなことになるくらいなら、Promise は await 式で常に評価するというようにすべて同じように扱った方が迷わずに済みます。

await async function の場合

基本形はすべてわかったので、少し応用を考えてみたいと思います。今度は await 式で async function (の返り値) を評価してみます。

fooW
async function fooPrevious() {
  console.log("👍 MAINLINE: Sync process in async function!!");
  return await Promise.reslve(42);
}

async function fooNext() {
  let value = await fooPrevious();
  value++;
  return value;
}

await 式は基本的には Promise インスタンスを評価し履行値を取り出します。そして、async 関数はどんなときでも Promise インスタンスを返します。結局のところは await Promise.resolve(42) の場合や await promise chain の場合と同じです。

ということで、V8 エンジンによる内部変換として考えられるコードは以下のものとなります。

V8_Converting
resumable function fooPrevious() {
  implicit_promise = createPromise();

  // 同期処理
  console.log("👍 MAINLINE: Sync process in async function!!");

  // < const value = await Promise.resolve(42); >
  promise = promiseResolve(Promise.resolve(42)); // Promise インスタンスをそのまま帰す
  performPromiseThen(promise,
    res => resume(«fooPrevious», res),
    err => throw(«fooPrevious», err)); // マイクロタスク1つ発生
  value = suspend(«fooPrevious», implicit_promise); // 履行値 42 が代入される
  // 処理再開ポイント
  resolvePromise(implicit_promise, value); // 42 で resolve
}
resumable function fooNext() {
  implicit_promise = createPromise();

  // < const value = await fooPrevious(); >
  promise = promiseResolve(fooPrevious()); // Promise インスタンスをそのまま返す
  performPromiseThen(promise,
    res => resume(«fooNext», res),
    err => throw(«fooNext», err)); // マイクロタスク1つ発生
  value = suspend(«fooNext», implicit_promise); // 履行値 42 が代入される
  // 処理再開ポイント
  value++;

  resolvePromise(implicit_promise, value); // 43 で resolve
}
function promiseResolve(v) {
  if (v is Promise) return v;
  promise = createPromise();
  resolvePromise(promise, v);
  return promise;
}

変換の原理自体は既に分かったので、上のように変換コードで一々考える必要もありません。単純にマイクロタスクがいくつ発生するかで考えます。

fooW
async function fooPrevious() {
  console.log("👍 MAINLINE: Sync process in async function!!");
  return await Promise.reslve(42);
  // await 式ごとに確実にマイクロタスクが1つ発生するが、
  // 評価対象の Promise インスタンスにチェーンはないので1つですむ
  // microtask = 1
  // return Promise.resolve(42) ならマイクロタスク2つが発生する
}

async function fooNext() {
  // async 関数の返り値となる Promise インスタンスを評価して履行値を取り出す
  let value = await fooPrevious();
  // await 式ごとに確実にマイクロタスクが1つ発生する
  // micortask++
  value++;
  return value;
}
// 合計マイクロタスクが2つ発生する

ということで、マイクロタスクは2つ発生します。fooNext() をチェーンした場合には then() メソッドのコールバックがマイクロタスクキューへと発行されるのは async 関数の内部で生成されるマイクロタスク合計2個を実行した後になります。

また実際のコードで考えてみます。

// asyncSpeedW.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [4] <1-Sync> MICRO: then"));

async function fooPrevious() {
  console.log("👍 [2] MAINLINE: Sync process in async function!!");
  return await Promise.resolve(42); // <2-Sync>
  // await 式ごとに確実にマイクロタスクが1つ発生するが、
  // 評価対象の Promise インスタンスにチェーンはないので1つですむ
  // microtask = 1
}

// 即時実行
(async function fooNext() {
  // async 関数の返り値となる Promise インスタンスを評価して履行値を取り出す
  let value = await fooPrevious();
  // await 式ごとに確実にマイクロタスクが1つ発生する
  // micortask++
  console.log("🦄 [6] <4-Async> MICRO: after await in async function");
  value++;
  return value;
})().then((value) =>
  console.log("👻 [8] <5-Async> MICRO: then after async function:", value)
);

Promise.resolve()
  .then(() => console.log("👦 [5] <3-Sync> MICRO: then"))
  .then(() => console.log("👦 [7] <6-Async> MICRO: then"));

console.log("🦖 [3] MAINLINE: End");

実行結果は次のようになります。

❯ v8 asyncSpeedW.js
🦖 [1] MAINLINE: Start
👍 [2] MAINLINE: Sync process in async function!!
🦖 [3] MAINLINE: End
👦 [4] <1-Sync> MICRO: then
👦 [5] <3-Sync> MICRO: then
🦄 [6] <4-Async> MICRO: after await in async function
👦 [7] <6-Async> MICRO: then
👻 [8] <5-Async> MICRO: then after async function: 43

await Promise.reject(new Error("reason")) の場合

await 式で Rejected 状態の Promise インスタンスを評価すると、例外が throw されます。

try/catch で補足しない場合は async 関数内の処理がそこで終わり、以降の処理は実行されません。さらに、async 関数自体から返ってくる Promise インスタンスも Rejected 状態となるので、次のように chaining した場合は、catch() で例外が補足されます。

(async function fooR() {
  await Promise.reject(new Error("reason"));
  console.log("これは実行されない");
})()
  .then(() => console.log("これは実行されないがマイクロタスクを発行"))
  .catch(err => console.log("これは実行される", err))
  .finally(() => console.log("これは実行される"));

await Promise.reject(new Error("reason")); は V8 によって次のように内部変換されます。

  // Promise インスタンスならそのまま返す
  promise = promiseResolve(Promise.reject(new Error("reason")));
  // async 関数を再開またはスローするハンドラのアタッチ
  // Settled になったら throw を告げるマイクロタスクを発行
  performPromiseThen(
    promise,
    res => resume(«fooR», res),
    err => throw(«fooR», err)); // throw される
  suspend(«fooR», implicit_promise);

peformPromiseThen()promise に対して Rejected 状態となったときのハンドラもアタッチしていたので、Rejected なら resume(再開) ではなく、throw を告げるマイクロタスクを発行します。

実際のコードでまた考えてみます。

// promiseRejectionR.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

(async function fooR() {
  // await 式は確実にマイクロタスク1つ発生
  await Promise.reject(new Error("reason"));
  console.log("これは実行されない");
  // <2-Sync>
})()
  .then(() => console.log("👻 [(5)] <4-Async> 実行されないがマイクロタスクを発行 MICRO: [Fulfilled]"))
  .catch((err) => console.log("😭 [7] <6-Async> MICRO: [Rejected]", err.stack))
  .finally(() => console.log("👍 [9] <8-Async> MICRO: [Finally]"))

Promise.resolve()
  .then(() => console.log("👦 [4] <3-Sync> MICRO: then"))
  .then(() => console.log("🤪 [6] <5-Async> MICRO: then"))
  .then(() => console.log("🤪 [8] <7-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

Rejected 状態の Promise インスタンスにチェーンされている then() メソッドの コールバック関数は実行されませんが、マイクロタスク自体は発行します。ということで、実行順番は次のようになります。

❯ v8 promiseRejectionR.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👦 [4] <3-Sync> MICRO: then
🤪 [6] <5-Async> MICRO: then
😭 [7] <6-Async> MICRO: [Rejected] Error: reason
    at fooR (promiseRejectionR.js:7:24)
    at promiseRejectionR.js:10:3
🤪 [8] <7-Async> MICRO: then
👍 [9] <8-Async> MICRO: [Finally]

async 関数では、try/catch/finally の構文が使用できるので、async 関数内で await Promise.reject(new Error("reason")) 以降の処理もできます。

(async function fooRX() {
  try {
    await Promise.reject(new Error("reason"));
    console.log("これは実行されない");
    await Promise.resolve(42);
  } catch (err) {
    console.log("例外発生", err.stack);
  } finally {
    cosnole.log("最後に実行できる");
  }
})()
  .then(() => console.log("これは実行される"))
  .catch(err => console.log("これは実行されないがマイクロタスクを発行", err.stack))
  .finally(() => console.log("これは実行される"));

上のようなコードの場合、try/catch で例外は補足されており、async 関数自体から返ってくる Promise インスタンスは履行状態となるため、チェーンした then() メソッドのコールバックは実行されて、catch() メソッドのコールバックは実行されないことに注意してください。

V8 の内部変換で考えてみるとこんな感じでしょうか。

V8_Converting
  try {
    // Promise インスタンスならそのまま返す
    promise = promiseResolve(Promise.reject(new Error("reason")));
    // async 関数を再開またはスローするハンドラのアタッチ
    // Settled になったら throw を告げるマイクロタスクを発行
    performPromiseThen(
      promise,
      res => resume(«fooRX», res),
      err => throw(«fooRX», err)); // throw される
    suspend(«fooRX», implicit_promise);
    // async 関数を一次中断して、呼び出し元に implicit_promise を返す

    // 実行されない
    console.log("これは実行されない");
    promise = promiseResolve(Promise.resolve(42));
    performPromiseThen(
      promise,
      res => resume(«fooRX», res),
      err => throw(«fooRX», err));
    suspend(«fooRX», implicit_promise);

  } catch (err) {
    // throw された例外を補足するところから再開
    console.log("例外発生", err.stack);
  } finally {
     cosnole.log("最後に実行できる");
  }

基本はすべて同じです。resume(再開) ではなく throw を告げるマイクロタスクが発行されることで、処理再開となるポイントでは throw された例外が補足されるところからとなります。

では実際のコードで実行順番を考えてみます。

// promiseRejectionRX.js
console.log("🦖 [1] MAINLINE: Start");
Promise.resolve().then(() => console.log("👦 [3] <1-Sync> MICRO: then"));

(async function fooRX() {
  try {
    await Promise.reject(new Error("reason"));
    // マイクロタスク1つ発生
    console.log("これは実行されない");
    await Promise.resolve(42);
  } catch (err) {
    // <2-Sync>
    console.log("👹 [4] <2-Sync> MICRO: 例外発生", err.stack);
  } finally {
    console.log("👹 [5] <2-Sync> MICRO: 最後に実行");
  }
})()
  .then(() => console.log("👻 [6] <4-Async> MICRO: 実行される [Fulfilled]"))
  .catch((err) => console.log("😭 [(8)] <6-Async> MICRO: 実行されないがマイクロタスクを発行 [Rejected]", err.stack))
  .finally(() => console.log("👍 [10] <8-Async> MICRO: 最後に実行 [Finally]"));

Promise.resolve()
  .then(() => console.log("🤪 [6] <3-Sync> MICRO: then"))
  .then(() => console.log("🤪 [4] <5-Async> MICRO: then"))
  .then(() => console.log("🤪 [9] <7-Async> MICRO: then"));

console.log("🦖 [2] MAINLINE: End");

今回は、async 関数内の try/catch によって例外補足されているため、async 関数から返ってくる Promise インスタンス自体は Fulfilled であり、チェーンされた then() メソッドのコールバックも実行されます。catch() メソッドのコールバックは実行されませんが、マイクロタスクは発行されるので注意してください。

実際に実行すると次の出力を得ます。

❯ v8 promiseRejectionRX.js
🦖 [1] MAINLINE: Start
🦖 [2] MAINLINE: End
👦 [3] <1-Sync> MICRO: then
👹 [4] <2-Sync> MICRO: 例外発生 Error: reason
    at fooRX (promiseRejectionRX.js:7:26)
    at promiseRejectionRX.js:17:3
👹 [5] <2-Sync> MICRO: 最後に実行
🤪 [6] <3-Sync> MICRO: then
👻 [6] <4-Async> MICRO: 実行される [Fulfilled]
🤪 [4] <5-Async> MICRO: then
🤪 [9] <7-Async> MICRO: then
👍 [10] <8-Async> MICRO: 最後に実行 [Finally]

async/await の最適化

以上、async/await の挙動について、V8 エンジンの内部変換コードから解説を試みてみました。

最初に述べたよう ECMAScript の仕様自体が async/await の最適化 (かつては V8 において --harmony-await-optimization というフラグで使用されていた機能) をマージしました。

https://github.com/tc39/ecma262/pull/1250

2017 年時点での async/await の仕様では、1つの await 式に2つの追加の Promise インスタンスと少なくとも3つのマイクロタスクが必要だったため非常に無駄が多かったですが、ECMAScript の仕様自体が最適化されたため、それを実装する 他の JavaScript エンジンでも同様に async/await の高速化をできるようになった そうです。

このように、async/await のオーバーヘッド (余計な Promise インスタンスとマイクロタスクの生成) を削減し最適化したことで async/await は高速化し、async stack trace による Debuggability(デバッグのしやすさ) の向上も伴って、async/await の機能は手書きの Promise に勝るようになったとのことです。

async/await outperforms hand-written promise code now. The key takeaway here is that we significantly reduced the overhead of async functions — not just in V8, but across all JavaScript engines, by patching the spec.
(Faster async functions and promises · V8 より引用、太字は筆者強調)

そして、開発者にも手書きの Promise よりも async/await の使用と V8 がネイティブに提供する Promise 実装を使用するように勧めています。

And we also have some nice performance advice for JavaScript developers:

  • favor async functions and await over hand-written promise code, and
  • stick to the native promise implementation offered by the JavaScript engine to benefit from the shortcuts, i.e. avoiding two microticks for await.

(Faster async functions and promises · V8 より引用、太字は筆者強調)

async/await のまとめ

V8 の舞台裏を見ることで async/await の挙動が理解できたと思います。

もちろん async/await を理解できるようになるには、今までの知識として Promise とイベントループ、マイクロタスクの概念が必要不可欠です。ここまで学習してきたことによって async/await が理解できるようになったことを忘れないでください。

await 式によって async 関数内の実行フローが分割され制御が行ったり来たりしますが、それは Promise chain での連鎖的なマイクロタスク発行による逐次実行と同じです。async 関数では処理再開を告げるマイクロタスクとして PromiseReactionJob がコールスタックに積まれ、async 関数の関数実行コンテキストが再びプッシュされてコールスタックのトップになることで実行再開となります。

非同期処理の本質的なメカニズムは イベントループにおけるタスクとマイクロタスクの処理 です。

Promise chain も async/await も本質的には イベントループにおけるマイクロタスクの連鎖的な処理 です。言うなれば マイクロタスク連鎖 (Microtask chain) です。

V8 エンジンでは async/await の内部変換が行われており、これによって 最適化されたマイクロタスクの連鎖的処理 を実現しています (仕様自体の最適化のおかげで他のエンジンでも同様)。