目次を表示する

Async Local Storage Deep Dive 2026 ─ 内部実装・落とし穴・性能を 9 章で読み解く

V8 PromiseHook の登場 ─ Node 16+ で 3-4x になった話

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 つ:

  1. 購読していないイベントは V8 側で完全にスキップされる(C++ にすら来ない)
  2. 購読しているイベントも、直接対応する JS 関数に飛ぶので分岐コストがなくなる

ALS は実装上 initbefore の情報があれば足りる(destroy は GC 任せ)。だから新 API では、Promise の resolve/after は完全にスキップされる。これがそのまま速度になった。

ベンチ

PR 内に貼られたベンチ結果:

状態スループット
disabled(フック無効)1,672,015 ops/sec
旧 enabled271,514 ops/sec
新 enabled1,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 と決別する。次章で見る。