V8 PromiseHook の登場 ─ Node 16+ で 3-4x になった話
前章で見た「new Promise() のたびに C++ → JS の境界を越える」問題を、Node 16 系で抜本的に改善する PR が landed した。Stephen Belanger(Qard)による PR #36394: async_hooks: use new v8::Context PromiseHook API だ。
この章では、何が変わったかを実装レベルで見る。
旧 PromiseHook の構造
V8 と Node.js の境界では、Promise の各イベント(init / before / after / resolve)を単一の C++ 関数経由で JS にディスパッチしていた。
graph LR
V8[V8 Promise lifecycle] --> CPP[C++ dispatcher]
CPP --> Branch{event type?}
Branch -->|init| JS_init[JS: init hook]
Branch -->|before| JS_before[JS: before hook]
Branch -->|after| JS_after[JS: after hook]
Branch -->|resolve| JS_resolve[JS: resolve hook]
style CPP fill:#ffe1e1
style Branch fill:#ffe1e1
問題はこの「単一 C++ ディスパッチ」だった。何のフックを購読しているかに関わらず、全イベントが C++ → JS の境界を越える。例えば「init だけ知りたい」アプリでも、before/after/resolve のために毎回 boundary crossing が発生する。
新 PromiseHook の構造
PR #36394 は、V8 側の API 自体を変えた。新しい v8::Context::SetPromiseHook は、イベント別に独立した JS 関数を登録できる。
graph LR
V8[V8 Promise lifecycle] -->|init only| JS_init[JS: init hook]
V8 -->|before only| JS_before[JS: before hook]
V8 -->|after only| JS_after[JS: after hook]
V8 -->|resolve only| JS_resolve[JS: resolve hook]
style V8 fill:#e1ffe1
これによる変化は 2 つ:
- 購読していないイベントは V8 側で完全にスキップされる(C++ にすら来ない)
- 購読しているイベントも、直接対応する JS 関数に飛ぶので分岐コストがなくなる
ALS は実装上 init と before の情報があれば足りる(destroy は GC 任せ)。だから新 API では、Promise の resolve/after は完全にスキップされる。これがそのまま速度になった。
ベンチ
PR 内に貼られたベンチ結果:
| 状態 | スループット |
|---|---|
| disabled(フック無効) | 1,672,015 ops/sec |
| 旧 enabled | 271,514 ops/sec |
| 新 enabled | 1,250,448 ops/sec |
旧実装からの約 4.6x 改善。disabled とのギャップも 16% 程度まで縮まった。「フック有効でも、ほぼフック無しと同じ速度」が射程に入った。
それでも残った問題
PR #36394 は劇的だったが、構造的な限界も残った。
レビュー欄では「EnablePromiseHook/DisablePromiseHook が依然として isolate レベルで動く(context レベルではない)」という指摘があった。これはつまり、1 つの hook を有効にすると、その isolate 内の全ての context(worker_threads や vm.Context)の Promise に影響するということ。
Qard の返答は「いずれ JS 側 GC tracking で解決したい」という未来形だった。この時点では完全な解にはなっていなかった。
もう一つ、createHook というAPI そのものの構造は変わっていない。createHook を使う限り、init/before/after/destroy のフルセットを内部で扱う必要があり、コールバックを記述する以上 JS への dispatch は避けられない。
つまり PR #36394 は「dispatch を最適化した」のであって、「dispatch をなくした」のではない。本質的な改善は次のステップに持ち越された。
この章の要点
- 旧 PromiseHook: 全 Promise イベントを単一 C++ ディスパッチ経由で JS に渡していた
- 新 PromiseHook (PR #36394, Node 16+): イベント別に独立した JS 関数。購読していないイベントは V8 側でスキップ
- ベンチで 271k → 1.25M ops/sec(約 4.6x)改善
- ただし isolate レベルの粗さは残り、
createHookの構造自体は不変
次章への問いかけ
dispatch は速くなった。だが
createHookの構造そのものは残っている。本当に解くなら、createHookを使わずに ALS を実装するしかない。Node 24 で行われたのが、まさにそれだ。
AsyncContextFrameという新しい仕組みで、ALS はcreateHookと決別する。次章で見る。