目次を表示する

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

AsyncContextFrame と createHook との決別 ─ Node 24 の刷新

AsyncContextFrame と createHook との決別 ─ Node 24 の刷新

前章までの最適化(PR #36394 の V8 PromiseHook 刷新)でも、ALS は依然として async_hooks.createHook() の上に乗っていた。createHook を使う限り、init/before の dispatch は避けられない。

Node 24 で landed した一連の PR ── AsyncContextFrame を ALS のデフォルト実装にする変更と、続く PR #56082AsyncContextFrameAsyncResource のメンバに統合)── は、この前提自体を覆した。

結論を先に

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” を、ここまでの実装知識と並べて読み直す。