Context Loss の根本原因
ここまでの章で「ALS は Promise の continuation を V8 が自動継承する」という新実装の仕組みを見てきた。だが現実には、ALS を使うコードで getStore() が undefined を返してしまう状況に遭遇する。
公式ドキュメントの “Troubleshooting: Context loss” を、ここまでの知識を踏まえて読み直す。
公式が示す 3 ケースと「実装レベルでの理由」
公式は context loss の対処として大きく 3 つを挙げる:
- callback-based API →
util.promisify()で promise 化 - 独自 thenable → 標準 Promise でラップ
- 避けられない 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 scope を AsyncContextFrame として捕まえ、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
次章への問いかけ
落とし穴も理解した。最後に気になるのは「実環境で性能はどう変わったのか」。
自分でベンチを取らなくても、信頼できる公開ベンチがいくつもある。次章でそれらを「ここまでの実装知識と並べて」読み解く。