目次を表示する

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

AsyncLocalStorage の "薄さ" ─ Node 13.10 当初の実装

AsyncLocalStorage の “薄さ” ─ Node 13.10 当初の実装

ALS は 2020 年 2 月、Node.js 13.10 で公式 API に昇格した。しかし当初の実装は驚くほど薄い。

当時の lib/internal/async_hooks.js 周辺を見ると、ALS は createHook の上に Map を一つ作って、それで context を保持しているだけだった。

この章ではその薄さを実装で確認し、なぜそれが「最初は良かったが、すぐに性能問題になった」のかを見る。

当初の実装は ~50 行

ALS の核心は概ね以下のロジックだった(実装を要約。実コードは lib/internal/async_local_storage 配下の祖先にあたる)。

// 概念コード(当時の実装の骨子)
class AsyncLocalStorage {
  private store = new Map<number, unknown>();

  constructor() {
    createHook({
      init: (asyncId, type, triggerAsyncId) => {
        // 親の store を子に継承
        if (this.store.has(triggerAsyncId)) {
          this.store.set(asyncId, this.store.get(triggerAsyncId));
        }
      },
      destroy: (asyncId) => {
        this.store.delete(asyncId);
      },
    }).enable();
  }

  run(value: unknown, callback: () => void) {
    const asyncId = executionAsyncId();
    this.store.set(asyncId, value);
    try {
      return callback();
    } finally {
      this.store.delete(asyncId);
    }
  }

  getStore() {
    return this.store.get(executionAsyncId());
  }
}

仕組みはシンプルだ:

  1. init フックで「親 → 子」を見て、親の store を子にコピー
  2. getStore() は今いる asyncId に対応する store を返す
  3. destroy で削除(メモリリーク防止)

これで await を跨いでも store が見える。親が子を生み、子が孫を生み、その都度 store が継承されていくからだ。

実行フロー

run() を叩いた時の流れを見る。

sequenceDiagram
  participant U as user code
  participant ALS as ALS instance
  participant Hook as async_hooks
  participant Store as Map<asyncId, value>

  U->>ALS: als.run({id:1}, fn)
  Note over ALS: executionAsyncId() = 5
  ALS->>Store: set(5, {id:1})
  ALS->>U: invoke fn()

  U->>U: setTimeout(callback, 100)
  Note over Hook: init: asyncId=6, trigger=5
  Hook->>Store: get(5) → {id:1}<br/>set(6, {id:1})

  U-->>U: 100ms 後
  Note over U: callback 実行中<br/>executionAsyncId() = 6
  U->>ALS: als.getStore()
  ALS->>Store: get(6) → {id:1}
  ALS-->>U: {id:1}

async_hooks全ての非同期リソース生成(new Promise()setTimeout() 等)に init フックを発火し、その都度 ALS が Map を更新している。これで await を跨いだ伝播が実現していた。

「薄い」ことの代償

この実装は問題を生んだ。

問題 1:new Promise() の度に JS callback が走る

// この一行で
async function foo() {
  const x = await bar();
  // ↑ Promise が 1 つ生まれる
}

// 内部的にはこれが起きている:
//  1. C++ 側で Promise オブジェクト生成
//  2. C++ → JS の boundary crossing
//  3. ALS の init hook 実行(Map 操作)
//  4. C++ に戻る
//  5. 続行

VM 境界をまたぐ呼び出しは一般に重い。Promise を生むたびにこれが起きる。async/await を使うアプリでは Promise 生成が1 リクエスト数千回に達することも珍しくない。

問題 2:destroy フックの遅延

destroy は GC タイミングで呼ばれる。Promise の解放が GC に任されるため、Map のクリーンアップが遅延し、メモリ使用量が膨らむ。

問題 3:他のフックとの相互作用

createHook を使う APM ツール(Datadog 等)と ALS が同じ V8 PromiseHook 機構を奪い合う。フックがネストするたびに重さが増す。

これらが顕在化したのが Issue #34493 ── 2020 年に報告された衝撃のタイトル:

AsyncLocalStorage kills 97% of performance in an async environment

97% の性能低下。微小ベンチでも 25% の低下が報告された(Aschen のベンチ)。「ALS は便利だが、本番では使えない」という空気が一時期あった。

ここから Node.js コアチームによる改善の連鎖が始まる。最初の大きな一手が、次章で見る V8 PromiseHook API の刷新だ。

この章の要点

  • Node 13.10 当初の ALS は createHook 上の Map 1 つの薄いラッパー
  • 親→子の triggerAsyncId 継承で await を跨いだ伝播を実現
  • 全ての非同期リソース生成で JS callback が走るため重い
  • Issue #34493 で「97% 性能低下」と報告され、改善の動機になった

次章への問いかけ

性能問題の根は「Promise 生成のたびに C++ → JS の境界を越える」ことにあった。

ここを変えれば一気に速くなる。Node 16 で導入された V8 PromiseHook API がどう問題を解いたか、次章で見る。