目次を表示する

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

Context Loss の根本原因

Context Loss の根本原因

ここまでの章で「ALS は Promise の continuation を V8 が自動継承する」という新実装の仕組みを見てきた。だが現実には、ALS を使うコードで getStore()undefined を返してしまう状況に遭遇する。

公式ドキュメントの “Troubleshooting: Context loss” を、ここまでの知識を踏まえて読み直す。

公式が示す 3 ケースと「実装レベルでの理由」

公式は context loss の対処として大きく 3 つを挙げる:

  1. callback-based API → util.promisify() で promise 化
  2. 独自 thenable → 標準 Promise でラップ
  3. 避けられない callback → AsyncResource で手動 wrap

なぜこの 3 つが解になるのか。実装を見ると一行で説明できる:

V8 が context を自動継承するのは Promise の continuation だけ。Promise を経由しない非同期は対象外。

つまり「callback 系で context が消える」のは設計どおりの挙動で、Promise 化することで V8 の自動継承の傘下に戻すしかない。

ケース 1:callback-based API

import * as fs from 'node:fs';

als.run({ requestId: 'r-1' }, () => {
  // ❌ 古い callback API
  fs.readFile('./x.txt', (err, data) => {
    als.getStore(); // undefined になる場合がある
  });
});

fs.readFile の callback は libuv の thread pool で実行され、Promise を介さない。V8 から見ると context の継承先がない。

import { readFile } from 'node:fs/promises';

als.run({ requestId: 'r-1' }, () => {
  // ✅ Promise ベース
  readFile('./x.txt').then((data) => {
    als.getStore(); // { requestId: 'r-1' }
  });
});

fs/promises 版は内部で標準 Promise を作っているので、V8 の自動継承に乗る。古い API なら util.promisify で十分:

import { promisify } from 'node:util';
const readFileAsync = promisify(fs.readFile);

ケース 2:独自 thenable

// ❌ 独自 thenable(標準 Promise ではない)
class MyPromise {
  then(onFulfilled, onRejected) {
    setTimeout(() => onFulfilled(42), 100);
    return this;
  }
}

als.run({ id: 1 }, async () => {
  await new MyPromise(); // V8 はこれを Promise として扱わない
  als.getStore(); // undefined
});

V8 の continuation 継承は 標準 Promise インスタンスに対してのみ動作する。then() メソッドを持つだけの thenable は対象外。Promise.resolve でラップして強制的に標準 Promise に変換する:

als.run({ id: 1 }, async () => {
  await Promise.resolve(new MyPromise());
  als.getStore(); // { id: 1 }
});

ケース 3:EventEmitter の listener

これは公式の Troubleshooting には明示的に書かれていないが、ハマり率が高い。

import { EventEmitter } from 'node:events';

const emitter = new EventEmitter();

als.run({ requestId: 'r-1' }, () => {
  emitter.on('data', (chunk) => {
    als.getStore(); // 別リクエストの context、または undefined
  });
});

// ───── 別の場所で
als.run({ requestId: 'r-2' }, () => {
  emitter.emit('data', 'hello');
});

emit() 時の context が listener に流れ込む。on() を呼んだ時の context ではない。これは V8 の continuation 継承の話ではなく、listener の登録と呼び出しのタイミングが違うための問題だ。

第 2 章で出た AsyncResource.bind() が解になる:

als.run({ requestId: 'r-1' }, () => {
  // ✅ 関数を「現在の async scope」に bind
  emitter.on('data', AsyncResource.bind((chunk) => {
    als.getStore(); // { requestId: 'r-1' }
  }));
});

AsyncResource.bind は内部で listener を runInAsyncScope でラップして返す。Node 24 以降は AsyncContextFrame のスナップショットを bind 時に取り、call 時にそれを復元する。

ケース 4:Worker thread pool

第 2 章で公式が挙げた WorkerPoolTaskInfo extends AsyncResource パターンの理由がここで分かる:

// ❌ 何もしない実装
class WorkerPool {
  runTask(task, callback) {
    const worker = this.freeWorkers.pop();
    worker[kCallback] = callback;
    worker.postMessage(task);
  }
}
// worker から message が返ってきた時の context は worker 側の context
// → callback 内で als.getStore() は undefined

postMessage は Worker スレッドへの構造化複製を行うが、context は伝播しない。返事の message イベントもまた、Worker 側の async scope で発火する。

公式が示す対処:

// ✅ AsyncResource を継承
class WorkerPoolTaskInfo extends AsyncResource {
  constructor(callback) {
    super('WorkerPoolTaskInfo'); // ← この時点の async scope を保持
    this.callback = callback;
  }

  done(err, result) {
    this.runInAsyncScope(this.callback, null, err, result); // ← 復元
    this.emitDestroy();
  }
}

new WorkerPoolTaskInfo()タスク投入時の async scopeAsyncContextFrame として捕まえ、runInAsyncScope でそれを復元する。Worker からの message 受信時に正しい context が戻る。

ケース 5:shared promise

Promise.all で複数の await が同じ Promise を共有する場合:

const sharedPromise = doSomething(); // 1 回だけ起動

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

als.run({ id: 2 }, async () => {
  await sharedPromise;
  als.getStore(); // { id: 2 } になるはず
});

実は新実装ではこれは正しく動く。await のタイミングで V8 が AsyncContextFrame当該 await 地点の context として捕獲するため、同じ Promise を await しても各 caller の context が保たれる。これは旧 createHook 実装では正しく動かないケースもあった ── 子ノード生成タイミングと依存関係が一致しないため。

ケース横断のフロー図

flowchart TB
  Start[als.run で context を立てる] --> Q1{非同期処理は<br/>標準 Promise?}
  Q1 -->|はい| OK1[V8 が自動継承<br/>→ getStore OK]
  Q1 -->|いいえ| Q2{callback 系か<br/>thenable か}

  Q2 -->|callback| Sol1[util.promisify で Promise 化]
  Q2 -->|thenable| Sol2[Promise.resolve でラップ]
  Q2 -->|EventEmitter| Sol3[AsyncResource.bind でラップ]
  Q2 -->|Worker| Sol4[AsyncResource を継承<br/>runInAsyncScope]

  Sol1 --> OK1
  Sol2 --> OK1
  Sol3 --> OK1
  Sol4 --> OK1

  style Start fill:#e1f5ff
  style OK1 fill:#e1ffe1

デバッグの定石

公式が推奨するデバッグ法は単純:

// 怪しい箇所に挟む
console.log('store at X:', als.getStore());

undefined になる最初の地点を見つけたら、その直前の非同期処理が「Promise でないものを使っている」可能性が高い。

この章の要点

  • ALS の context 継承は V8 が標準 Promise の continuation に対して自動で行うもの
  • 標準 Promise を経由しない非同期処理(callback / 独自 thenable / EventEmitter / Worker)は context が継承されない
  • 対処の本質は「標準 Promise の経路に戻す」または「AsyncResource で手動キャプチャする
  • Promise.resolve でラップ / AsyncResource.bind / AsyncResource 継承 + runInAsyncScope

次章への問いかけ

落とし穴も理解した。最後に気になるのは「実環境で性能はどう変わったのか」。

自分でベンチを取らなくても、信頼できる公開ベンチがいくつもある。次章でそれらを「ここまでの実装知識と並べて」読み解く。