Upgrade to Pro — share decks privately, control downloads, hide ads and more …

JavaScriptにおけるasync/await呼び出しのスタックトレースの困難と実装

Avatar for sosukesuzuki sosukesuzuki
November 15, 2025
1.4k

 JavaScriptにおけるasync/await呼び出しのスタックトレースの困難と実装

JSConf JP 2025

Avatar for sosukesuzuki

sosukesuzuki

November 15, 2025
Tweet

More Decks by sosukesuzuki

Transcript

  1. 現代JavaScript世界における⾮同期処理 かつてJavaScriptにおける⾮同期処理はコールバックが主流 const fs = require("node:fs"); fs.readFile('/etc/passwd', (_, data) =>

    { console.log(data); }); ES2015でPromise、ES2017でasync関数とawait式が導⼊され、JavaScript世界 における⾮同期プログラミングは⼤きく変わった const fs = require("node:fs/promises"); const data = await fs.readFile('/etc/passwd');
  2. async関数とスタックトレースの問題の具体例 async function foo() { await bar(); } async function

    bar() { await 1; throw new Error("oops"); } foo().catch(e => console.log(e.stack) ); 期待する出⼒ Error: oops at bar (test.js:6:13) at async foo (test.js:2:16) 実際の出⼒ (Bun 1.2) Error: oops at bar (test.js:6:13) スタックフレーム foo が⽋損している
  3. async関数とスタックトレースの問題の具体例 async function foo() { await bar(); } async function

    bar() { await 1; throw new Error("oops"); } foo().catch(e => console.log(e.stack) ); 作成されたError の直前のawait 以降の情報(つまり関数bar)しかス タックトレースに乗らない
  4. スタックフレームが⽋損しない同期関数の例 function foo() { bar(); } function bar() { throw

    new Error("oops"); } foo(); fooが呼びされる コールスタックは foo
  5. スタックフレームが⽋損しない同期関数の例 function foo() { bar(); } function bar() { throw

    new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar
  6. スタックフレームが⽋損しない同期関数の例 function foo() { bar(); } function bar() { throw

    new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar エラーがthrowされる コールスタックは foo, bar よってスタックトレースにも foo, bar が乗る ✅
  7. スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async

    function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo
  8. スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async

    function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar
  9. スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async

    function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar await p によってbarはPromiseをreturn コールスタックは foo
  10. スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async

    function bar() { await p; throw new Error("oops"); } foo(); fooが呼びされる コールスタックは foo barが呼び出される コールスタックは foo, bar await 1 によってbarはPromiseをreturn コールスタックは foo await bar() によってfooはPromiseをreturn コールスタックは 空
  11. スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async

    function bar() { await p; throw new Error("oops"); } foo(); p がresolveされたとき、barの残りの処理がマイ クロタスクとしてスケジュールされる
  12. スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async

    function bar() { await p; throw new Error("oops"); } foo(); p がresolveされたとき、barの残りの処理がマイ クロタスクとしてスケジュールされる (コールスタックが空になったとき)barの残り の処理がマイクロタスクとして実⾏される コールスタックは bar
  13. スタックフレームが⽋損する⾮同期呼び出しの例 async関数呼び出しの場合 async function foo() { await bar(); } async

    function bar() { await p; throw new Error("oops"); } foo(); p がresolveされたとき、barの残りの処理がマイ クロタスクとしてスケジュールされる (コールスタックが空になったとき)barの残り の処理がマイクロタスクとして実⾏される コールスタックは bar エラーがthrowされる コールスタックは bar よってスタックトレースには bar しか乗らない ❌
  14. マイクロタスクから呼び出し元の情報を得る例 async function foo() { await bar(); } async function

    bar() { await baz(); } async function baz() { await p; throw new Error("oops"); } foo(); ちょうどエラーがthrowされたとする。このとき、現 在実⾏中のマイクロタスクはasync関数bazの後半部分 このマイクロタスクはジェネレータとどこまで進んだ かという状態で表現される。そしてそのジェネレータ は⾃⾝(baz)が返すPromiseへの参照を持つ。 bazに対応するジェネレータ (実⾏中のマイクロタスク) bazが返すPromise r2
  15. マイクロタスクから呼び出し元の情報を得る例 async function foo() { await bar(); } async function

    bar() { await baz(); } async function baz() { await p; throw new Error("oops"); } foo(); Promise は⾃⾝がresolveした時に⾏われる処理 (reaction)への参照を持つ。 reactionは⾃⾝が所属するasync関数(bar)に対応する ジェネレータへの参照を持つ。 bazに対応するジェネレータ (実⾏中のマイクロタスク) bazが返すPromise r2 reaction (r2 がresolveした時の処理) bar(reactionが所属するasync 関数に対応するジェネレータ)
  16. マイクロタスクから呼び出し元の情報を得る例 async function foo() { await bar(); } async function

    bar() { await baz(); } async function baz() { await p; throw new Error("oops"); } foo(); これで、実⾏中のマイクロタスクから、かつて⾃⾝を 呼び出したasync関数を取得できる。 これを再帰的に呼び出すことによって、連続した呼び 出しチェーンを再現する。 bazに対応するジェネレータ (実⾏中のマイクロタスク) bazが返すPromise r2 reaction (r2 がresolveした時の処理) bar(reactionが所属するasync 関数に対応するジェネレータ)
  17. マイクロタスクから呼び出し元の情報を得る例 VMEntryRecord JSGenerator JSPromise JSPromiseReaction JSGenerator JSPromise JSPromiseReaction JSGenerator JSPromise

    JSPromiseReaction つまり、以下のように参照を参照を辿ることで、コールスタックからはすでに失 われたasync関数の呼び出し元の情報を復元できる baz bar foo 実⾏中のマイクロタスク を持つ状態
  18. 通常の関数呼び出し function foo() { bar(); } function bar() { throw new Error("oops");

    } foo(); fooに対応するバイトコード(イメージ) [ 0] enter … [ 17] call_ignore_result callee:loc5, argc:1, argv:12 [ 22] ret value:Undefined(const0) 通常の関数呼び出しにおける位置情報の取得の例 call_ignore_result 命令が 関数bar()を呼び出す。 JSCでは、この call_ignore_result命令の関 数foo内でのインデックスが わかれば、位置情報を知る ことができる。
  19. 通常の関数呼び出し function foo() { bar(); } function bar() { throw new Error("oops");

    } foo(); fooに対応するバイトコード(イメージ) [ 0] enter … [ 17] call_ignore_result callee:loc5, argc:1, argv:12 [ 22] ret value:Undefined(const0) 通常の関数呼び出しにおける位置情報の取得の例 barでErrorが作成されたと き、コールスタックは foo, bar。 このとき、fooの呼び出しに 対応するスタックフレーム は、foo内でのpc(=17)の情 報を持っている。 通常の関数呼び出しでは、 このpcから位置情報を取得 する。
  20. async関数内における位置情報取得の問題 async function foo() { await bar(); } async function

    bar() { await 1; throw new Error("oops"); } foo(); async関数では、Errorが作成された時点ですでに コールスタックにfooは存在しない。 そのためfoo()に対応するスタックフレームのpcか ら、await bar(); の位置情報を得ることはできな い。
  21. async関数内における位置情報の取得⽅法 async関数がバイトコードへとコンパイルされるときGeneratorへと変換される (Generatorification)。Generatorは、開始されうる箇所へとジャンプする分岐をもつ state(どのawaitまで処理が進んだのかを表す)付きswitchとして表現される。 async function foo() { // state

    0 await bar(); // state 1 await baz(); // state 2 } 対応するバイトコード(イメージ) 0 op_enter 5 op_switch_imm [state] ← 再開時の分岐 case 0 → 10 case 1 → 30 (await bar() の次) case 2 → 50 (await baz() の次) 10 <call bar()> 20 op_yield ← state=1 を保存して return 30 <resume point 1> ← await bar() の後から再開 40 <call baz()> 45 op_yield ← state=2 を保存して return 50 <resume point 2> ← await baz() の後から再開 60 op_ret
  22. async関数内における位置情報の取得⽅法 Errorが作成された時のfoo()におけるstateの値は、JSGeneratorオブジェクトか ら取得できる。stateの値がわかれば、関数に対応するバイトコードのswitchの caseのオペランドからawait bar();に対応するバイトコードインデックスがわかる async function foo() { //

    state 0 await bar(); // state 1 await baz(); // state 2 } 対応するバイトコード(イメージ) 0 op_enter 5 op_switch_imm [state] ← 再開時の分岐 case 0 → 10 case 1 → 30 (await bar() の次) case 2 → 50 (await baz() の次) 10 <call bar()> 20 op_yield ← state=1 を保存して return 30 <resume point 1> ← await bar() の後から再開 40 <call baz()> 45 op_yield ← state=2 を保存して return 50 <resume point 2> ← await baz() の後から再開 60 op_ret
  23. 関連資料 • V8の実装のデザインドキュメント ◦ https://docs.google.com/document/d/13Sy_kBIJGP0XT34V1CV3nkWya4TwYx9L3Yv45LdG B6Q ◦ 細かい実装は違うが、JSCはこの設計を⼤いに参考にしている • V8の⾮同期スタックトレースの実装

    ◦ https://chromium.googlesource.com/v8/v8.git/+/f537d77845c666240c8d13466e224a5206 12f7c5 • WebKitの⾮同期スタックトレースの実装 ◦ https://github.com/WebKit/WebKit/pull/50290