🚏

JavaScriptの非同期処理をじっくり理解する (3) async/await

2021/10/10に公開
4

対象読者と目的

  • 非同期処理の実装方法は知っているが、仕組みを詳しく知らないのでベストプラクティスがわからないときがある
  • 実行順序の保証がよくわからないので自信をもってデプロイできない変更がある
  • より詳しい仕組みを理解することでより計画的な実装をできるようになりたい

という動機で書かれた記事です。同様の課題を抱える人を対象読者として想定しています。

目次

  1. 実行モデルとタスクキュー
  2. Promise
  3. async/await
  4. AbortSignal, Event, Async Context
  5. WHATWG Streams / Node.js Streams (執筆中)
  6. 未定

用語に関する注意

前々回定義した以下の用語を今回も使います。

  • 1 tick ... タスクキューが1周すること。
  • 1 microtick ... マイクロタスクキューが1周すること。

これらの単位は非同期処理の間の相対的な優先順位を決めるものであり、実時間と直接対応するものではありません (1 microtick削減することでパフォーマンスが上がるとは限らない)。

async/await

Promiseの then チェインは逐次処理のネスト削減を可能にしましたが、条件分岐が入ると then チェインが使えず、結局Promise以前のコールバック地獄に逆戻りです。

fetch("https://example.com/something.json")
  .then((resp) => {
    if (resp.ok) {
      resp.json()
        .then((json) => {
	  console.log("Got json:", json);
	});
    } else {
      fetch("https://example.com/fallback.json")
        .then((resp) => {
	  // ...
	});
    }
  })

またループが関係すると状態管理を手動で行わなければならず面倒なだけではなくコードの構造が大きく損なわれます。この問題を解消するのがasync/awaitです。

async functionを使うと、上記の処理を以下のように書くことができます。

async function f() {
  const resp = await fetch("https://example.com/something.json");
  if (resp.ok) {
    const json = resp.json();
    console.log("Got json:", json);
  } else {
    const resp = await fetch("https://example.com/fallback.json");
    // ...
  }
}

ℹ️ 正確には const y = await x;x.then((y) => { ... }) ではなく Promise.resolve(x).then((y) => { ... }) と対応します。

awaitが使える箇所

以下のような箇所でawaitが使えます。

  • async function の本体
  • async function* の本体
  • async アロー関数 (async () => { ... }) の本体
  • async メソッド定義 (async foo() { ... }) の本体
  • ES Modulesのモジュールトップレベル (Top-level await)

逆に、以下のような箇所ではawaitは使えません。

  • asyncではない function の本体
  • asyncではない function* の本体
  • asyncではないアロー関数 (() => { ... }) の本体
  • asyncではないメソッド定義 (foo() { ... }) の本体
  • CommonJS Modulesのモジュールトップレベル
  • 関数・メソッドの引数リスト (async, 非asyncにかかわらず)

awaitの亜種

通常のawait式 const x = await someValue; 以外にも、以下の言語機能が暗黙的にawaitを使っています。

  • for-await-of文 for await (const x of asyncIterable) { ... }
    • 非同期イテレーターの next 呼び出しの解決時
    • 非同期イテレーターの return 呼び出しの解決時
  • asyncジェネレーター関数内の yield
    • yield 引数に対して暗黙的に呼ばれる
    • yield が引数を取らない場合は undefined に対してawaitする
  • asyncジェネレーター関数内の yield*
    • 非同期イテレーターの next 呼び出しの解決時
    • 非同期イテレーターの return 呼び出しの解決時
    • 非同期イテレーターの throw 呼び出しの解決時
    • next, return, throwdone: false を返したとき、 yield に移譲する際に value に対して暗黙的に呼ばれる
  • asyncジェネレーター関数内の return (式を伴うものに限る)
    • return 引数に対して暗黙的に呼ばれる

awaitのタイミング仕様

await 式またはそれに相当する暗黙のawaitは以下のような処理として扱われます。

Promise.resolve(x).then(
  (y) => {
    // await式がyに評価されたとして続行
  },
  (e) => {
    // await式がthrow eしたものとして続行
  }
);

そのため、

  • x がPromiseの場合は、Promiseがfulfill/rejectされてから次のタイミングで実行が再開されます。
  • x がPromiseでもThenableでもない場合は、awaitの次のタイミングで実行が再開されます。
  • x がPromise以外のThenableの場合も先述した通りのルールで解決されますが、やや複雑なのでここでは説明を省略します。
(async () => {
  // 1microtick待つ
  await null;
})();

for-await-of の挙動とタイミング仕様

for-await-of はnextの呼び出し結果に対してawaitするため、 for-of とはタイミングが異なります。

// 3, 4, 5, 0, 1, 2 の順に表示される
(async () => {
  for await (const x of [0, 1, 2]) console.log(x);
})();
(async () => {
  for (const x of [3, 4, 5]) console.log(x);
})();

なお上のように(asyncではない)Iterableを渡した場合は自動的にIteratorからAsyncIteratorへの変換が行われます。このときの next 関数はおよそ以下のようなものになります。

next(nextValue) {
  const { done, value } = iter.next(nextValue);
  return Promise.resolve(value).then((value) => ({ done, value }));
}

そのため、Promiseの配列をイテレートする場合は for-offor-await-of で結果が異なります。また、1ループあたり最低でも2microtickは待つことになります。 (AsyncIterableを渡す場合は1ループあたり最低1microtick)

async開始位置のタイミング仕様

async関数の本体は即座に実行開始されます。

(async () => {
  console.log(1);
  await null;
  console.log(3);
})();
console.log(2);

ジェネレーター関数は呼び出しても本体を実行せずに中断するため、async関数とは挙動が違います。

async関数脱出時のタイミング仕様

async関数本体が終了するときに以下が発生します。

  • 正常終了 (returnまたは暗黙のreturn) → 本体の戻り値を用いてPromiseの resolve 関数を呼ぶ
  • 例外による終了 (throw) → throwされた値を用いてPromiseの reject 関数を呼ぶ

そのため、

  • PromiseでもThenableでもない値をreturnしたときは、async functionの戻り値は即座にfulfillされ、最短で次のmicrotickで外のthenコールバックが呼ばれます。
  • Promiseをreturnしたときは、「returnに使われたPromiseに対するthenの実行」を待つのに1microtick、「thenで登録されたコールバックの実行」を待つのに1microtickかかる。そのため、最短で3microtick後に外のthenコールバックが呼ばれます。
  • 例外をthrowしたときは、async functionの戻り値は即座にrejectされ、最短で次のmicrotickで外のthenコールバックが呼ばれます。
(async () => { for(const i in [0, 1, 2, 3, 4, 5]) { console.log(`microtick ${i}`); await null; } })();

const promise = (async () => {
//    ^^^^^^^ Promise A
  console.log("foo");
  return "bar";
})();
promise.then((x) => console.log(x));
//           ^^^^^^^^^^^^^^^^^^^^^ callback 1
// Microtick 0: fooが出力される
// Microtick 0: return "bar" が呼ばれる
// Microtick 0: Promise Aがfulfillされる
// Microtick 1: callback 1が呼ばれる
(async () => { for(const i in [0, 1, 2, 3, 4, 5]) { console.log(`microtick ${i}`); await null; } })();

const promise = (async () => {
//    ^^^^^^^ Promise A
  console.log("foo");
  return Promise.resolve("bar");
  //     ^^^^^^^^^^^^^^^^^^^^^^ Promise B
})();
promise.then((x) => console.log(x));
//           ^^^^^^^^^^^^^^^^^^^^^ callback 1
// Microtick 0: fooが出力される
// Microtick 0: Promise Bがfulfillされる
// Microtick 0: Promise Bがreturnされる
// Microtick 0: Promise Bに対してthenを登録するジョブがエンキューされる
// Microtick 1: Promise Bに対してthenが登録される (これをcallback 2とする)
// Microtick 2: callback 2が呼ばれる
// Microtick 2: Promise Aがfulfillされる
// Microtick 3: callback 1が呼ばれる
(async () => { for(const i in [0, 1, 2, 3, 4, 5]) { console.log(`microtick ${i}`); await null; } })();

const promise = (async () => {
//    ^^^^^^^ Promise A
  console.log("foo");
  return { then(r) { r("bar"); } };
  //       ^^^^^^^^^^^^^^^^^^^^^ callback 2
  //     ^^^^^^^^^^^^^^^^^^^^^^^^^ Thenable B
})();
promise.then((x) => console.log(x));
//           ^^^^^^^^^^^^^^^^^^^^^ callback 1
// Microtick 0: fooが出力される
// Microtick 0: Thenable Bがreturnされる
// Microtick 0: Thenable Bに対してthenを登録するジョブがエンキューされる
// Microtick 1: Thenable Bに対してthenが登録される (これをcallback 3とする)。つまり、callback 2が呼ばれる。
// Microtick 1: callback 3が呼ばれる
// Microtick 1: Promise Aがfulfillされる
// Microtick 2: callback 1が呼ばれる

return await としても返される値は return とほぼ同じですが、タイミングが異なります。呼び出し元が値を受けとるまでにかかる最小microtick数は以下の通りです。 (この表はuhyo先生の記事の結論をPromise以外の値に対しても広げたものにあたります)

return return await
non-Thenable 1 2
Promise 3 2
Thenable, not Promise 2 3

興味深いことに、Promiseに対しては return await のほうが早いのに対し、それ以外の値に対しては return のほうが早いです。これは以下の原因によります。

  • awaitPromise.resolve を行うため、Promiseに対して有利である。 (return はケーパビリティ関数としてのresolveを使うためPromiseをThenableに対して特別扱いしない)
  • await 自体は then に由来して1microtickの不利が生じるが、returnがThenableを処理することで起こる2microtick分の不利を回避できるのでお釣りがくる。

ジェネレーター関数

ジェネレーター関数は中断可能な関数で、 function* 構文で定義できます。非同期ジェネレーター関数は中断可能なasync関数で、 async function* 構文で定義できます。ジェネレーターの詳細については別記事にまとめたのでそちらを参照ください。

const gen = (function*() {
  for (let i = 0; i < 3; i++) yield i;
})();
// 一回呼び出すごとに次のyieldまで実行が進められる
console.log(gen.next()); // => { done: false, value: 0 }
console.log(gen.next()); // => { done: false, value: 1 }
console.log(gen.next()); // => { done: false, value: 2 }
console.log(gen.next()); // => { done: true, value: undefined }

非同期版もほぼ同様に使えます。

const gen = (async function*() {
  // この例では出てこないが、ここにawaitを書くこともできる
  for (let i = 0; i < 3; i++) yield i;
})();
// 一回呼び出すごとに次のyieldまで実行が進められる
console.log(await gen.next()); // => { done: false, value: 0 }
console.log(await gen.next()); // => { done: false, value: 1 }
console.log(await gen.next()); // => { done: false, value: 2 }
console.log(await gen.next()); // => { done: true, value: undefined }

非同期ジェネレーターは 「ジェネレーター」+「async」 ですから、基本的には両者の知識の延長で理解できます。しかし、特筆すべき点がいくつかあります。

  • yield* はIterableとAsyncIterableの両方をサポートしています。
  • yield は暗黙的に引数をawaitします。 (引数が省略されたときは undefined をawaitします。)
  • yield* はイテレーターの出力した値を暗黙的にawaitします。
  • return が暗黙的に引数をawaitします。 (ジェネレーターではない通常のasync関数では暗黙のawaitが起きないことは注目に値します)

非同期ジェネレーターのタイミング仕様

興味深い言語仕様上の非一貫性として、以下のものがあります。

引数なし 引数あり
yield awaitする awaitする
return × awaitする

つまりタイミング管理上 yieldyield undefined は同等ですが、 returnreturn undefined は違うということになります。

// barが先に表示される
(async function*() { return undefined; })()
  .next()
  .then(() => console.log("foo"));
(async function*() { return; })()
  .next()
  .then(() => console.log("bar"));
// fooが先に表示される
(async function*() { yield undefined; })()
  .next()
  .then(() => console.log("foo"));
(async function*() { yield; })()
  .next()
  .then(() => console.log("bar"));

returnを書かずに関数本体の末尾で暗黙のreturnが発生したときの挙動は、引数を取らないreturnと同じです。

また、returnの挙動は通常の(ジェネレーターではない)async functionとも異なることは注目に値します。

async function foo() {
  // awaitは起きない (戻り値となるPromiseのresolve関数に直接渡される)
  return something;
}

async function* bar() {
  // awaitが発生する
  return something;
}

ジェネレーターとasync/await

async functionやasyncジェネレーター関数は同期的なジェネレーター関数に(比較的簡単に)トランスパイルできます。async functionをトランスパイルするにはおおよそ以下のようなヘルパ関数で上手くいきます。

// yieldをasyncに置き換える処理
function toPromise<T>(gen: Generator<T, any, any>): Promise<T> {
  return new Promise((resolve, reject) => {
    function cont(f: () => IteratorResult<T, any>) {
      let result;
      try {
        result = f();
      } catch (e) {
        reject(e);
        return;
      }
      if (result.done) {
        resolve(result.value);
      } else {
        Promise.resolve(result.value).then(onFulfilled, onRejected);
      }
    }
    function onFulfilled(value: any) {
      cont(() => gen.next(value));
    }
    function onRejected(e: any) {
      cont(() => gen.throw(e));
    }
    cont(() => gen.next());
  });
}

async generatorの場合は yieldasync を出し分けるなど複雑になるため本稿では省略します。

try-catchとreturn promise

async関数でreturn値が解決される仕様はAsyncBlockStart, つまりasync関数本体側の挙動 (戻り値Promiseのresolve関数に渡される) として定義されています。 (returnの挙動 はasync関数と通常の関数で変わりません) このことはタイミング仕様にも影響を与えますが、より大きな区別としてrejectionがtry-catchで捕捉されないという違いが挙げられます。

async function f() {
  try {
    return await Promise.reject(new Error("foo"));
  } catch(e) {
    console.log("Catched");
    throw e;
  } finally {
    console.log("Done");
  }
}
f(); // => Catched が表示される
async function f() {
  try {
    return Promise.reject(new Error("foo"));
  } catch(e) {
    console.log("Catched");
    throw e;
  } finally {
    console.log("Done");
  }
}
f(); // => Catched は表示されない

また次に説明するように非同期スタックトレース上も (現在のV8の実装では) 好ましくない結果になります。この3点の理由から、promise値をreturnせずに明示的にawaitするようにするのがよいでしょう。

ただし、このことは非同期ジェネレーターには当てはまりません。非同期ジェネレーターではreturn文で自動的にawaitが発生する (yield式も同様) からです。非同期ジェネレーターのこれらの位置ではawaitを書かないほうがわずかに良いでしょう。

非同期スタックトレース

多くのJavaScript実装が、スタックトレースを生成する機能 Error.prototype.stack を有しています。

⚠️ 各ブラウザベンダが独自に実装している非標準機能であり、フォーマットはおろか存在することすら保証されていません。 ⚠️

スタックトレースは通常エラー処理に関連して作られますが、例外機構とは直接の関係がないことは注目に値します。たとえば以下のソースコード

level1();
function level1() {
  level2();
}
function level2() {
  level3();
}
function level3() {
  console.log(new Error("test").stack);
}

をNode.js (V8) で実行すると以下のようなスタックトレースが生成されていることがわかります。

Error: test
    at level3 (./test.js:9:15)
    at level2 (./test.js:6:3)
    at level1 (./test.js:3:3)
    at Object.<anonymous> (./test.js:1:1)
    at Module._compile (node:internal/modules/cjs/loader:1101:14)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1153:10)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:79:12)
    at node:internal/main/run_main_module:17:47

このスタックトレースは古典的にはJavaScriptの関数呼び出しのネスト (execution context stack) を所定の形式でダンプすることで実装されていました。しかしこの方法だと非同期処理中に発生したエラーについて情報が不十分であるという問題があります。たとえば以下の例を考えます。

level1();
function level1() {
  level2();
}
function level2() {
  setTimeout(level3, 0);
}
function level3() {
  console.log(new Error("test").stack);
}

これをNode.js (V8) で実行すると以下のようになります。

Error: test
    at Timeout.level3 [as _onTimeout] (./test.js:9:15)
    at listOnTimeout (node:internal/timers:557:17)
    at processTimers (node:internal/timers:500:7)

V8の非同期スタックトレース

これを解決するのが非同期スタックトレースです。V8の非同期スタックトレースは現代のPromiseやasync/awaitベースのプログラムに対して優れたスタックトレースを出力できます。たとえば

level1();
async function level1() {
  await level2();
}
async function level2() {
  await null;
  await level3();
}
async function level3() {
  console.log(new Error("test").stack);
}

をNode.jsで実行すると以下のようになります。

Error: test
    at level3 (./test.js:10:15)
    at level2 (./test.js:7:9)
    at async level1 (./test.js:3:3)

ここで level2 内に await null がある関係で、 await level3()level1 内で直接実行されているわけではない点に注意が必要です (= 同期的スタックトレースとしてはlevel1の支配下にない)

しかし実際には async level1 という行が追加されていることでasync/awaitの呼び出し元がわかるようになっています。これが非同期スタックトレースです。

ℹ️ async functionが必ず非同期スタックトレースになるわけではありません。async functionが呼び出されてから最初のawaitまでは同期的に実行されるため、この中でスタックトレースを生成すると同期的なスタックフレームに含まれます。

V8の非同期スタックトレースの仕組み

V8は非同期スタックトレースを最小限のオーバーヘッドで取得するために、非常に賢い方法をとっています。

(同期的な)スタックトレースとは、 「関数がreturnしたらどこに戻るか」 という話に言い換えられます。そこでスタックトレースの概念を非同期処理に拡張するには 「関数がPromiseをfulfill/rejectしたら、処理はどう続行されるか」 を考えればいいことになります。これがV8の非同期スタックトレースの基本的な考え方です。

そのためにはまず始点となるPromiseを特定する必要があります。Promiseベースやasync/awaitベースの非同期プログラミング言語では、アプリケーションコードは基本的にthenコールバック (onFulfilled, onRejected) の中で実行されており、これらのコールバックは専用のマイクロタスク内で実行されています。そこで、まずはthenコールバックの発見を行います

function sleep(timeout) {
  return new Promise((resolve) => {
    setTimeout(() => {
      // この部分はasync stack trace の対象外 (setTimeoutによって呼ばれたタスクのため)
      console.log(new Error("foo").stack);
      resolve();
    }, timeout);
  });
}

sleep(1000)
  .then(() => {
    // async stack traceの対象 (thenコールバックのためにエンキューされたマイクロタスク内のため)
    console.log(new Error("bar").stack);
  });

(async () => {
  await sleep(1000);
  // async stack traceの対象 (thenコールバックのためにエンキューされたマイクロタスク内のため)
  console.log(new Error("baz").stack);
});

queueMicrotask(() => {
  // この部分はasync stack trace の対象外 (マイクロタスクだがthenに紐付いていない)
  console.log(new Error("quux").stack);
});

thenコールバックを発見したら、それがawaitに由来するかどうかに応じてPromiseを発見します。具体的には、以下のようにして発見します。

  • async functionのawait (fulfill / reject) 後の継続の場合
    • async functionが対外的に返したPromiseを調べる
    • (このasync function自体は同期的スタックトレースの一部なのでスキップする)
  • async generator functionのawait / yield (fulfill / reject) 後の継続の場合
    • next() の呼び出し元に返されたPromiseを調べる
    • (このasync generator function自体は同期的スタックトレースの一部なのでスキップする)
  • それ以外で promise.then(...) のコールバック内だった場合
    • promise.then(...) 自体がPromiseを返しているので、このPromiseを調べる

たとえば先ほどのコード例

level1();
async function level1() {
  await level2();
}
async function level2() {
  await null;
  await level3();
}
async function level3() {
  console.log(new Error("test").stack);
}

では、同期的スタックトレースの最上位は level2 (の await null から復帰するための継続) です。この継続は level1 内の level2() によって作られていますが、同じ level2() によって返されたPromiseを [[Promise]] 内部スロットに保持しています。そこでこのPromiseを起点にasync stack traceの構築を行います。

起点となるPromiseを見つけたら、そのPromiseに紐付いた then 処理を探します。 then コールバックがちょうど1組だけ登録されているときに限り探索を続行します (それ以外の場合はここで探索が打ち切られます。) then コールバックの種類に応じて以下のように探索を行います。

  • async functionのawait用の継続がアタッチされている場合
    • このasync functionをスタックトレースの最上位に追加する。
    • async functionが対外的に返したPromiseを辿り、非同期スタックトレースの探索を続行する。
  • async generator functionのawait / yield用の継続がアタッチされている場合
    • このasync generator functionをスタックトレースの最上位に追加する。
    • next() の呼び出し元に返されたPromiseを辿り、非同期スタックトレースの探索を続行する。
  • Promise.all のfulfillmentまたは Promise.any のrejection用のハンドラがアタッチされている場合
    • Promise.all / Promise.any をスタックトレースの最上位に追加する。
    • Promise.all / Promise.any が返したPromiseを辿り、非同期スタックトレースの探索を続行する。
  • 別のPromiseの resolve 関数がアタッチされている場合
    • アタッチされている resolve 関数に紐づいたPromiseを辿り、非同期スタックトレースの探索を続行する。
    • この処理自体はスタックトレースには含まれない。
    • なお、たとえば以下のケースがこの分岐に該当する。
      • async functionのreturn位置
      • Promise.all / Promise.race のrejection用ハンドラ
      • Promise.any / Promise.race のfulfillment用ハンドラ
  • それ以外の .then(...) の場合
    • .then(...) が返したPromiseを辿り、非同期スタックトレースの探索を続行する。
    • この処理自体はスタックトレースには含まれない。

特にasync functionのreturnは「別のPromiseの resolve 関数がアタッチされている場合」でハンドルされていることは注目に値します。ここではスタックトレースの探索処理自体は継続されるものの、「スタックトレースの最上位に追加する」処理は行われないため、現在のV8の実装ではasync function内でPromiseをawaitせずreturnしたときに当該async functionが非同期スタックトレースに含まれません。

async function foo() {
  return bar(); // fooが非同期スタックトレースに含まれなくなる
  // return await bar(); // この方法ならfooが非同期スタックトレースに含まれる
}

その他の例は以下の通りです。

async function foo() {
  // thenなどを使って派生させたPromiseにawaitをつけても適切に追跡される
  await bar().then((x) => x);
}
async function foo() {
  // Promise.all, Promise.any, Promise.race でまとめられたPromiseの1つがスタックトレースを取得しても
  // 適切に追跡される
  // (Promise.allSettled は現時点では未対応)
  await Promise.all([bar(), baz()]);
}

SpiderMonkeyの非同期スタックトレース

Gecko (Firefox) のJavaScriptエンジンであるSpiderMonkeyでは非同期スタックトレースを陽に追跡する仕組みを採用しています。あらかじめ特定のタイミングでスタックトレースを保存しておき、以下のようなタイミングで保存したスタックトレースを接続するようです。

これにより非同期スタックトレースがキャプチャされる条件がV8とは異なっています。たとえば以下のケースで非同期スタックトレースがキャプチャされます (有効化されている場合)。

function foo() {
  setTimeout(bar, 0);
}
function bar() {
  // SpiderMonkeyでは、fooから呼ばれたことが追跡されている
  console.log(new Error("test").stack);
}
foo();

V8と違い、非同期スタックトレースのために追加で情報を保存する必要があるため、有効化条件が細かく設定されているようです。 (V8も非同期スタックトレースの無効化オプションがあるが、原則として有効化されている)

他の言語の場合

RustのFutureはpollベースでawaitが yield* のように実装されているため、タスクの実行が再開されるごとに元のスタックトレースをなぞるように再帰的にpollが呼び出されます。そのため特別なサポートをしなくても同期的処理と同様のスタックトレースが復元されます

まとめ

  • Promiseチェインは逐次的な非同期処理を書きやすくしたが、分岐やループを含む非同期処理には依然として弱かった。async/awaitは非同期処理を同期的な処理と同様の制御構文で書けるようにするもので、これにより非同期処理の書きづらさに関する多くの問題が解消された。
  • async関数はジェネレーター関数にトランスパイルできる。
  • async関数とジェネレーター関数を組み合わせたasyncジェネレーター関数もある。asyncジェネレーター関数ではyieldやreturnでも暗黙的にawaitされる。
  • ジェネレーターではないasync関数ではPromiseをreturnすると解決されるが、これはtry-catchをすり抜けるため使うべきではない。
  • async関数がasync関数を呼び出しても、同期的処理と同様のコールスタックは生成されない。これはエラー診断で問題になるが、多くのJavaScript処理系が擬似的に非同期呼び出しのスタックトレースを生成する機構を持っている。
  • SpiderMonkeyの非同期スタックトレースが「呼び出し元」をトレースする素朴な実装であるのに対し、V8は「復帰先」をトレースすることでゼロコストで非同期スタックトレースを生成している。 (現時点での実装に基づく)

次回→ AbortSignal, Event, Async Context

関連資料

更新履歴

  • 2021/10/10 公開。
  • 2021/10/16 コードのコメントが正しくなかったのを修正。

Discussion

petamorikenpetamoriken

スタックトレースについて補足です。

多くのJavaScript実装が、スタックトレースを生成する機能 Error.prototype.stack を有しています。

⚠️ 各ブラウザベンダが独自に実装している非標準機能であり、フォーマットはおろか存在することすら保証されていません。 ⚠️

実はブラウザの JavaScript エンジンにおいて stack プロパティを Error.prototype に持つのは SpiderMonkey (Firefox) のみとなっていて、V8 (Chrome) や JavaScriptCore (Safari) では直接オブジェクトが保有するプロパティとなっています(特に V8 では Error.captureStackTrace を使うことで任意のオブジェクトに stack プロパティを付与できます)。

スタックトレースを標準化し、それを取得できるスタティックメソッドを定義する提案が Stage 1 Error Stacks で、そこに各実装がどうなっているのかの記載がされています。

Masaki HaraMasaki Hara

実はブラウザの JavaScript エンジンにおいて stack プロパティを Error.prototype に持つのは SpiderMonkey (Firefox) のみ

補足ありがとうございます。知りませんでした。

特定クラスのインスタンスが持つプロパティを表す慣例的な記法として、本文中の記載はそのままにしようと思います (現にMDNでもそうなっていますし)。

yohhoyyohhoy

非同期ジェネレーターのタイミング仕様:2例目は「fooが先に表示される」の誤記でしょうか?

Masaki HaraMasaki Hara

非同期ジェネレーターのタイミング仕様:2例目は「fooが先に表示される」の誤記でしょうか?

ありがとうございます! その通りです。直しておきます。