目次を表示する

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

AsyncLocalStorage とは(おさらい)

AsyncLocalStorage とは(おさらい)

この章では、AsyncLocalStorage の API を 30 秒で振り返る。すでに run() / getStore() を書いたことがある読者向けに、知っているはずの内容を素早く確認する。重要なのは API の中身そのものではなく、**この章で生まれる「素朴な疑問」**だ。それが第 2 章以降の旅の起点になる。

ALS は何を解決するか

Node.js は伝統的に「リクエスト」という単位を持たない。http.createServer のコールバックが来るたびに、各リクエストは独立した実行コンテキストで処理されるが、それを横断的に追跡する仕組みは long-running なプロセスには存在しなかった。

スレッドベースの言語(Java, Ruby)には Thread-Local Storage がある。だが Node.js は単一スレッドで多数のリクエストを async で捌く。スレッドという入れ物がない以上、「このリクエスト固有のデータ」を関数引数で渡し続けない限り、ロガーや認可ミドルウェアからリクエスト ID を引くことができなかった。

これを解決するのが AsyncLocalStorage(以下 ALS)。スレッドの代わりに「非同期の実行コンテキスト」を入れ物にする。

API は 4 つだけ

ALS の API は本質的に 4 つしかない。

import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage<{ requestId: string }>();

// 1. run: スコープ付きで context を伝播
als.run({ requestId: 'r-001' }, () => {
  // 2. getStore: 現在の context を取得
  console.log(als.getStore()?.requestId); // 'r-001'

  setTimeout(() => {
    console.log(als.getStore()?.requestId); // 'r-001' ← 非同期を超える
  }, 100);
});

console.log(als.getStore()); // undefined: スコープ外

これだけで「await を超えてコンテキストが見える」が成立する。最初に見ると魔法のようだが、なぜ動くのかは第 4-5 章で実装レベルで解く。

run(store, callback, ...args)

スコープ付き。callback の実行中(およびそこから派生した非同期処理)でのみ store が見える。callback を抜けると消える。例外もスコープを抜ける

try {
  als.run({ id: 1 }, () => {
    throw new Error('boom');
  });
} catch (e) {
  als.getStore(); // undefined: スコープ外
}

enterWith(store)

run と違ってスコープなし。呼んだ後ずっとそのコンテキストになる。明示的に exit() するか別の run() で囲まない限り、現在の async chain 全体に影響する。

als.enterWith({ id: 1 });
als.getStore(); // { id: 1 }
// この後で発生する全ての async も { id: 1 } を見る

これは便利に見えてになる。Express のようなミドルウェア構造で enterWith を使うと、リクエスト間でコンテキストが漏れる事故が起きる。基本は run() を使い、enterWith は本当に必要な時だけ使う。

exit(callback, ...args)

run の中から一時的にコンテキスト外で何かを実行したいときに使う。callback の中では getStore()undefined を返す。

als.run({ id: 1 }, () => {
  als.getStore(); // { id: 1 }

  als.exit(() => {
    als.getStore(); // undefined
    // ここはコンテキスト外
  });

  als.getStore(); // { id: 1 }: 復帰
});

getStore()

現在の store を返す。スコープ外なら undefinedO(1) で locking なしの参照(V8 の async context slot を直接読む)。

ライフサイクル図

run() の挙動を sequence で見ると、こうなる。

sequenceDiagram
  participant U as user code
  participant A as ALS
  participant V8 as V8 / async context
  U->>A: als.run(store, callback)
  A->>V8: enter context (push store)
  A->>U: invoke callback()
  Note right of U: callback 内 await・<br/>setTimeout 内でも<br/>als.getStore() は store を返す
  U->>A: callback returns
  A->>V8: exit context (pop store)
  Note right of A: スコープ外: getStore() は<br/>undefined を返す

ここで湧く素朴な疑問callback の中で await した先や setTimeout の callback、つまり呼び出しスタックが完全に切れている場所で、なぜ V8 は「正しい store」を返せるのか。

この章の要点

  • ALS は Node.js の単一スレッド前提で「リクエストスコープのコンテキスト」を実現する仕組み
  • API は run / enterWith / exit / getStore の 4 つだけ
  • enterWith はスコープなしで便利だが、リクエスト間漏洩を起こす罠あり。基本は run
  • getStore() は O(1)、locking なし

次章への問いかけ

await をまたいでも getStore() が正しい値を返す。なぜ?

その答えは、ALS が乗っかっている下のレイヤー、async_hooks の仕組みにある。次章で見ていく。