👻

V8エンジンによる内部変換コードでasync/awaitの挙動を理解する

2022/05/08に公開

はじめに

JavaScript の「非同期処理」ってやっぱりかなり難しくないですか?

自分も色々試行錯誤しましたが、結局「完全に理解した🤓」→「やっぱり何も分からん😭」っていうループの中で泥臭く理解を深めていくしかないようです。

さて、非同期処理の制御をある程度予測できるようになるには、非同期 API を提供する環境のことやイベントループ、マイクロタスクなどの仕組みについて理解する必要があります。

そして環境に埋め込まれた JavaScript Engine のことも理解する必要があります。

今回の記事では、JavaScript Engine の1つである V8 が内部で変換するコードから async/await の挙動を理解するための解説を試みたいと思います。V8 エンジンからアプローチすることで async/await の分かりづらい挙動を掌握して非同期処理を打倒します。

今回の記事は Zenn の Book の方で公開している『イベントループとプロミスチェーンで学ぶ JavaScript の非同期処理』で収録する予定の前記事となります。非同期処理の理解に欠かすことのできない Promise チェーンとイベントループについて解説しているので興味がある方は是非確認してみてください。この記事を理解する上でも役立つと思います。

https://zenn.dev/estra/books/js-async-promise-chain-event-loop

ChageLog
  • 2022-12-19
    • チャプター最新版と同期
  • 2022-05-11
    • 全体的に微修正
    • PromiseReactionJob が抜けていたので追加
    • 「await Promise.reject(new Error("reason")) の場合」の項目を追加
  • 2022-05-10
    • 一部表現のおかしいところがあったので修正
    • 「Promise インスタンスで resolve するということ」の項目を追加

参考文献

今回記事を書くにあたって参照したメインテーマに関する文献になります。

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

https://zenn.dev/azukiazusa/articles/difference-between-return-and-return-await

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

V8 エンジンとは

まずは V8 エンジンが何かを確認しておきましょう。

https://v8.dev

V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others. It implements ECMAScript and WebAssembly, and runs on Windows 7 or later, macOS 10.12+, and Linux systems that use x64, IA-32, ARM, or MIPS processors. V8 can run standalone, or can be embedded into any C++ application.
(上記公式ページ より引用)

V8 は Google が提供するオープンソースの JavaScript エンジンかつ WebAssembly エンジンでもあります。つまり、ECMAScript と WebAssembly を実装しています。V8 自体は C++ で書かれており、主に Chrome ブラウザ (正確には Chrome のオープンソース部分である Chromium) で利用されています。

JavaScript Engine は他にもいくつかあります。他の JavaScript エンジンとブラウザ環境との関係性は『サバイバル TypeScript』で分かりやすい図と共に解説されているので参考にしてください。

https://typescriptbook.jp/overview/ecmascript#ecmascriptとブラウザの関係性

V8 の凄いところは、Chrome だけでなく、Node, Deno といったランタイム環境やクロスプラットフォームのデスクトップアプリケーションを開発するための Electron などで利用されている JavaScript Engine であるということです。

JavaScript の実行環境において JavaScript Engine である V8 が担当している役割は以下のように様々です。

  • JavaScript コードをコンパイルして実行: コンパイラ
  • 関数呼び出しの特定順序で実行できるようにする: コールスタック
  • オブジェクトのメモリアロケーションの管理: メモリヒープ
  • 使用されなくなったオブジェクトのメモリ解放: ガベージコレクタ
  • JavaScript におけるすべてのデータ型、演算子、オブジェクト、関数の提供

参考文献
https://hackernoon.com/javascript-v8-engine-explained-3f940148d4ef

V8 自体は JavaScript Engine なので DOM については一切感知しませんし、Web API も (ごく一部を覗いて) 提供しませんので、それらは V8 を埋め込む環境によって実装されて提供される必要があります。V8 はデフォルトのイベントループとタスクキュー/マイクロタスクキューを保有しています が、環境は独自のイベントループを実装し、複数のタスクキューを設けて、マイクロタスクのチェックポイント (いつマイクロタスクを処理するか) を定めることができます。

V8 エンジンは GoogleChromeLabs が提供する jsvu(JavaScript engine Version Updater) を使ってインストールでき、ソースからビルドすることなく利用できます。V8 エンジンはローカル環境においてスタンドアロンで実行できるため、ECMAScript の実装について簡単にローカルでテストできます。

https://github.com/GoogleChromeLabs/jsvu

V8 を単独で使うことにより、Chrome, Node, Deno による環境実装の API や独自のイベントループ、タスクキューの優先度などを気にすることなく、ECMAScript についての実装のみを考えることができます。この記事内で使用する v8 のバージョンは次のものとなります。

❯ v8
V8 version 10.3.125
d8>

v8 コマンド単体をシェルで実行すると、d8 という REPL が立ち上がります。v8 コマンドでスクリプト名を引数にすることで Node や Deno のように JavaScript ファイルを実行できます。

さて、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 インスタンスを組み合わせて実現しています。

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

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

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

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

この記事では 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 を理解できるようになるには、Promise とイベントループ、マイクロタスクの知識が必要不可欠です。await 式によって非同期関数内の実行フローが分割され制御が行ったり来たりしますが、それは Promise チェーンでの連鎖的なマイクロタスク発行による逐次実行と同じです。非同期関数では処理再開を告げるマイクロタスクとして PromiseReactionJob がコールスタックに積まれ、非同期関数の関数実行コンテキストが再びプッシュされてコールスタックのトップになることで実行再開となります。

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

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

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

ここまで見てきたように ECMAScript の仕様だけではなく、V8 エンジンで何が起きているかを知ることで理解できることがあったり、パフォーマンス上でいいことがありそうです。

V8 のドキュメントでは ECMAScript の新機能の解説などが載っているので、V8 のドキュメントも参考に色々学習してみるのもよさそうです👇

https://v8.dev/features/promise-combinators

https://v8.dev/features/top-level-await

ECMAScript の仕様の読み方なども載っていて面白いです。

https://v8.dev/blog/understanding-ecmascript-part-1

GitHubで編集を提案

Discussion