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());
}
}
仕組みはシンプルだ:
initフックで「親 → 子」を見て、親の store を子にコピーgetStore()は今いる asyncId に対応する store を返す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
asyncenvironment
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 がどう問題を解いたか、次章で見る。