目次を表示する

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

async_hooks と AsyncResource の仕組み

async_hooks と AsyncResource の仕組み

ALS が await を超えても context を保つのは、その下に async_hooks という土台があるからだ。Node.js 13.10 で ALS が公式 API になるまで、Node エンジニアは async_hooks を直接使ってコンテキスト追跡を書いていた。

ALS は元来この API の薄いラッパーとして実装された(第 3 章で実装を読む)。だから ALS の挙動を理解するには、まず async_hooks を理解する必要がある。

async_hooks のライフサイクル

async_hooks は、Node.js が抱えるすべての非同期リソース(Promise / Timer / TCPSocket / FSReqCallback / …)の生死を観察する API だ。各リソースは独自の asyncId を持ち、4 つのフェーズを経る。

graph LR
  A[init] --> B[before]
  B --> C[after]
  C --> D[destroy]
  C -.繰り返し.-> B
  style A fill:#e1f5ff
  style D fill:#ffe1e1
フェーズ発火タイミング
init非同期リソースが作られた時。new Promise()setTimeout() の度に呼ばれる
beforeリソースに紐づくコールバックが実行される直前
afterコールバックの実行直後
destroyリソースがGC された時。Promise なら resolve/reject 後

このライフサイクルを観察するには createHook を使う。

import { createHook, executionAsyncId, triggerAsyncId } from 'node:async_hooks';

createHook({
  init(asyncId, type, triggerAsyncId, resource) {
    // type: 'PROMISE' | 'Timeout' | 'TCPWRAP' | ...
    // triggerAsyncId: このリソースを生んだリソースの id
  },
  before(asyncId) { /* ... */ },
  after(asyncId) { /* ... */ },
  destroy(asyncId) { /* ... */ },
}).enable();

init の引数 triggerAsyncId が肝。新しく生まれたリソースは「自分を生んだ親」を知っている。これを辿れば「リクエスト A から派生した非同期処理」を追跡できる。ALS が await を超えて store を保てるのは、親子関係を辿って parent の context を継承しているからだ(あくまで旧実装の話。Node 24 の新実装は別の方法を使う。第 5 章)。

executionAsyncId() と triggerAsyncId()

任意の場所で「今どの async リソースの中で実行されているか」を取れる。

import { executionAsyncId, triggerAsyncId } from 'node:async_hooks';

console.log(executionAsyncId()); // 1: 現在の async ID
console.log(triggerAsyncId());   // 0: それを起こしたトリガー

これが取れることで、async_hooks の上に好きなコンテキスト追跡が書ける。実際 ALS は当初これで実装されていた(第 3 章で見る)。

AsyncResource: 自分でリソースを名乗る

ライブラリ作者には、もう一つ重要なクラスがある。AsyncResource だ。

例えば独自のジョブキューを実装するとき、ジョブ実行中の callback が「いつのリクエストから来たか」を保ちたい。AsyncResource を継承すると、自分のクラスを async リソースとして登録できる。

import { AsyncResource } from 'node:async_hooks';

class JobTask extends AsyncResource {
  constructor(public readonly callback: () => void) {
    super('JobTask'); // type 名
  }

  run() {
    // 自分の async scope で callback を実行
    this.runInAsyncScope(this.callback);
  }
}

ポイントは runInAsyncScope。これを使うと「new JobTask() した時の context」を callback 実行中に復元する。new した時刻 ≠ run した時刻という非同期処理にとって決定的な違いを、AsyncResource が橋渡しする。

第 6 章で扱う「context loss」の対処法のかなりの部分は、この AsyncResource.runInAsyncScope が答えになる。

公式が「migrate away」と書いている

ここまで読んで「async_hooks 強力じゃん」と思ったら、公式ドキュメントを開いてほしい:

Please migrate away from this API, if you can. We do not recommend using the createHook, AsyncHook, and executionAsyncResource APIs as they have usability issues, safety risks, and performance implications.

公式自身が createHook を直接使うな と書いている。理由は 3 つ:

  1. 使い勝手の悪さ: 4 つのフェーズを正しく扱うのは難しい。1 つでも実装を間違えると全 async 処理が壊れる
  2. 安全性のリスク: 第 8 章で扱う 2026/1 の DoS 脆弱性のように、hook 内のエラーが致命的に伝播することがある
  3. 性能影響: 全ての非同期リソース生成にフックが噛むため、何もしなくてもオーバーヘッドが乗る(第 4 章でこの話の続き)

そして公式は推奨する:

AsyncLocalStorage should be preferred as it is a performant and memory safe implementation.

つまり「ALS を使え。createHook を直接叩くな」が現在の公式スタンス。だが APM 系(Datadog / OpenTelemetry / NewRelic / Dynatrace / Elastic)は今でも createHook を直接使っている。第 8 章で「これが何を意味したか」を見ることになる。

この章の要点

  • async_hooks は非同期リソースの生死を観察する API(init/before/after/destroy)
  • triggerAsyncId で親子関係を辿れる ─ ALS の旧実装はこれで context を継承していた
  • AsyncResource を継承すると自分のクラスを async リソースとして登録できる
  • 公式は createHook の直接利用を非推奨にしている。理由は使い勝手・安全性・性能の 3 つ

次章への問いかけ

ALS は async_hooks の薄いラッパーだった、と書いた。どれくらい薄いのか?

次章では、Node 13.10 で ALS が登場した当時の実装を、実際に lib/internal のソースから読み解く。「薄さ」を確認することが、その後の進化を理解する起点になる。