AsyncContextFrame と createHook との決別 ─ Node 24 の刷新
前章までの最適化(PR #36394 の V8 PromiseHook 刷新)でも、ALS は依然として async_hooks.createHook() の上に乗っていた。createHook を使う限り、init/before の dispatch は避けられない。
Node 24 で landed した一連の PR ── AsyncContextFrame を ALS のデフォルト実装にする変更と、続く PR #56082(AsyncContextFrame を AsyncResource のメンバに統合)── は、この前提自体を覆した。
結論を先に
Node 24+ の AsyncLocalStorage は、もう
async_hooks.createHook()を使っていない。
API 表面は変わらない(run / getStore は同じ)。だが内部の動作機構が完全に置き換わった。新実装は AsyncContextFrame という構造を介して、V8 の ContinuationPreservedEmbedderData という仕組みに乗る。
--no-async-context-frame フラグで旧実装に戻すこともできる(ABI 互換のため。第 8 章で運用上の意味を扱う)。
ContinuationPreservedEmbedderData とは
V8 が提供する低レベル API:
// V8 API(C++)
v8::Context::SetContinuationPreservedEmbedderData(value);
v8::Context::GetContinuationPreservedEmbedderData();
「Continuation を跨いで Embedder(≒ Node.js)が任意の値を保持できる」スロット。Continuation とは Promise の resolve や await の再開のような「実行の続き」のこと。V8 は Promise の continuation を作る時、このスロットの値を自動的に子 continuation に伝播する。
これが ALS の根本的な解になる。
graph TB
subgraph "旧実装 (createHook 経由)"
A1[Promise 生成] -->|init hook 発火| A2[JS: ALS init 実装]
A2 --> A3[Map に親→子の継承を書く]
A3 --> A4[実行]
end
subgraph "新実装 (ContinuationPreservedEmbedderData)"
B1[Promise 生成] -->|V8 が自動継承| B2[実行]
end
style A2 fill:#ffe1e1
style A3 fill:#ffe1e1
style B1 fill:#e1ffe1
旧実装で発火していた init/before/after フック自体が不要になる。Promise が生まれた時点で V8 が値を自動継承するからだ。JS callback が走らないということは、VM 境界跨ぎが発生しないということ。性能的には PR #36394 の延長線上にあるが、そもそもフックを呼ばないという別の次元にいる。
AsyncContextFrame の役割
ContinuationPreservedEmbedderData に置く「値」が AsyncContextFrame だ。Node.js の lib/internal にある async_context_frame.js で実装されている。
要点を要約する:
// 概念コード
class AsyncContextFrame {
// 親 frame と「自分の追加分」のリンクリスト
parent: AsyncContextFrame | null;
storage: Map<AsyncLocalStorage, unknown>;
}
// run() の動作(要約)
function run(als, store, callback) {
const current = getCurrentFrame(); // 親 frame
const next = new AsyncContextFrame();
next.parent = current;
next.storage = new Map([[als, store]]);
setContinuationPreservedEmbedderData(next); // V8 のスロットに置く
try {
return callback();
} finally {
setContinuationPreservedEmbedderData(current); // 戻す
}
}
// getStore() の動作(要約)
function getStore(als) {
let frame = getContinuationPreservedEmbedderData();
while (frame) {
if (frame.storage.has(als)) return frame.storage.get(als);
frame = frame.parent;
}
return undefined;
}
ポイント:
- 継承は V8 が勝手にやる。JS 側で何もしない
getStore()は frame chain を辿るだけ。Map 検索 + parent 辿りで O(1)(depth は通常浅い)- 複数 ALS インスタンスが同じ frame chain を共有。インスタンスごとの
createHook登録は不要
PR #56082: AsyncResource にも frame を持たせた
この続編 PR が、AsyncResource クラスに AsyncContextFrame のメンバを直接持たせた。これにより以下が消えた:
- 環境(
env)グローバルのasync_resource_context_frames_Map AsyncResourceごとに Map を引く間接コスト
ABI breaking change として semver-major 扱いになり、Node 24 のメジャーリリースに乗った。これで「AsyncResource.runInAsyncScope」も AsyncContextFrame の上で動くようになった ── 第 6 章で扱う context loss の対処法も、内部的には新実装の恩恵を受ける。
--no-async-context-frame フラグ
万が一旧実装に依存していたら、
--no-async-context-frameで v23 以前の挙動に戻せる。
ABI 互換と移行期のリスクヘッジのために残された。だが新実装は性能的に明確に優れているので、特別な事情がない限り使う必要はない。第 8 章で「フラグを使うと脆弱性が再発する」という運用上の罠を見る。
この章の要点
- Node 24 の ALS は
async_hooks.createHook()を使わない実装になった - 内部は
AsyncContextFrame(V8 のContinuationPreservedEmbedderDataに乗る) - フック発火が消え、Promise 継承を V8 が自動でやる
AsyncResourceにも frame メンバが追加され、API レイヤー全体が新実装に移行--no-async-context-frameで旧実装にフォールバック可能(ABI 互換用、推奨ではない)
次章への問いかけ
実装の進化は分かった。だが、これだけ高度な仕組みでも context loss は起きる。
なぜか。次章で公式ドキュメントの “Troubleshooting: Context loss” を、ここまでの実装知識と並べて読み直す。