Node.js 异步上下文详解(实验驱动)

系列第五篇。前四篇分别聊了子进程、cluster、worker_threads、事件循环。这一篇解决一个业务味十足的问题:日志里的 traceId 是怎么自动跟着请求跑的。

全部结论都有实验撑腰,实验代码在 node-study/async-hooks/ 目录,共 9 个脚本。

开篇:一个你可能遇到过的场景

客服转来一条投诉:"我下午 3 点下单失败了,用户 ID 8848。"

你的服务一天几百万请求,日志每秒刷几千行,同一时刻几百个请求的日志交错混在一起。你要回答的问题是:8848 那一次下单,到底哪一步炸了?

没有 traceId 的世界,你按时间戳筛出几千行日志,逐行人肉分辨哪行属于 8848,基本等于捞针。有 traceId 的世界,入口给这次请求生成唯一 ID,之后每一行日志自动带上它:

text 复制代码
[trace-7f3a9c] router 收到 POST /order
[trace-7f3a9c] 库存校验通过
[trace-7f3a9c] 调用支付服务 耗时 2300ms
[trace-7f3a9c] 支付服务返回 504        ← 就是它

搜一个 ID,全程回放。traceId 就像快递单号:包裹经过揽收、中转、派送,每个节点扫同一个单号,你查单号就能看到全程轨迹。微服务之间通过 HTTP 头(比如标准的 traceparent)传递它,就能把多台机器上的日志拼成一条链路,这就是分布式链路追踪的底座。

但 traceId 有个前提:它得跟着异步调用链自己走 。一个请求的处理要穿过路由、中间件、service、N 次 await、DB 回调,没人能忍受每层函数都多传一个参数。Node 给出的答案是 AsyncLocalStorage(简称 ALS),从 async_hooks 模块导出。

这篇文章要搞清楚三件事:它怎么用、它凭什么工作、它什么时候会突然失灵。

第 1 章:先看没有它的世界(实验 01)

同一条三层调用链(router → service → db,每层异步 10ms),两个请求并发进来,要求每条日志挂对 traceId。三种写法对比。

实验文件:async-hooks/01-为什么需要ALS.js

js 复制代码
// 版本 A:模块级全局变量(写着爽,但错)
let currentTraceId = null;
const logA = (msg) => console.log(`  [${currentTraceId}] ${msg}`);

function handleA(traceId, id) {
  currentTraceId = traceId;          // 入口处往全局一塞
  logA('router 收到请求');
  serviceA(id, () => logA('router 返回响应'));
}

// 版本 C:AsyncLocalStorage
const als = new AsyncLocalStorage();
const logC = (msg) => console.log(`  [${als.getStore()?.traceId}] ${msg}`);

function handleC(traceId, id) {
  als.run({ traceId }, () => {       // 只在入口存一次
    logC('router 收到请求');
    serviceC(id, () => logC('router 返回响应'));
  });
}

实际输出:

text 复制代码
== 版本 A:全局变量(并发两请求) ==
  [req-A] router 收到请求
  [req-A] service 处理中
  [req-B] router 收到请求
  [req-B] service 处理中
  [req-B] db 查询 user1 完成      ← user1 明明是 req-A 的查询!
  [req-B] router 返回响应         ← 这也是 A 的
  [req-B] db 查询 user2 完成
  [req-B] router 返回响应

== 版本 B:手动传参(并发两请求) ==
  (全部正确,但每个函数签名都多背一个 traceId)

== 版本 C:AsyncLocalStorage(并发两请求) ==
  [req-A] router 收到请求
  [req-A] service 处理中
  [req-B] router 收到请求
  [req-B] service 处理中
  [req-A] db 查询 user1 完成      ← 全对
  [req-A] router 返回响应
  [req-B] db 查询 user2 完成
  [req-B] router 返回响应

版本 A 翻车原理:全局变量只有一份。A 的同步段设完全局,B 的同步段立刻覆盖;10ms 后 A 的 db 回调醒来,读到的是 B。同步段独占主线程、异步回调以后才被重新调度,所以任何"全局存一份"的方案在并发异步面前必然串号。

版本 B 全对但痛:三层能忍,十层是灾难,中间经过第三方库时它还不认识你的 traceId。

版本 C 又对又爽:dbC、serviceC 根本不知道 traceId 的存在,日志函数想拿就拿。

留下悬念:getStore() 用起来和全局变量一样随手,凭什么不串号?答案在下面一层层揭开。

第 2 章:地基,异步家谱(实验 02、03)

ALS 不串号的底气来自 Node 内部的登记制度:每个异步操作出生时登记两个字段。

  • asyncId:自增户口号,我是谁
  • triggerAsyncId:我是在谁的执行上下文里被创建的,我爹是谁

任何回调被执行时,executionAsyncId() 报告"现在是谁在执行"。有这两样,任何回调都能回答"我属于哪条调用链"。

实验文件:async-hooks/02-asyncId家谱.js

js 复制代码
const { executionAsyncId, triggerAsyncId } = require('node:async_hooks');

function who(label) {
  console.log(`${label.padEnd(22)} 我的编号=${executionAsyncId()}  触发我的=${triggerAsyncId()}`);
}

who('主模块同步代码');
setTimeout(() => {
  who('setTimeout 回调');
  Promise.resolve().then(() => who('timer 里的 Promise.then'));
}, 10);
Promise.resolve().then(() => who('主模块的 Promise.then'));
fs.readFile(__filename, () => who('readFile 回调'));

2.1 两种认爹规则(本文最容易踩坑的点)

输出里藏着一个反直觉的现象。timer 回调(编号 4)里写的那行 Promise.resolve().then(cb),then 回调执行时自报"编号 17,爹 16"。爹不是 4!

拆开看,那一行代码其实出生了两个 Promise:

text 复制代码
Promise.resolve()  → P1:编号 16,爹=4      ← 认书写位置,创建上下文是 timer 回调
.then(cb)          → P2:编号 17,爹=16     ← 认上游 Promise,回调挂在它身上

规则是:

  • 普通资源(Timeout、FSReqCallback 等)的爹 = 你在哪里写的它(执行上下文)
  • Promise 链的爹 = 数据从哪里流过来(上游 Promise)

两者统一在一个更高的原则上:爹 = "你之所以在此刻运行,直接拜谁所赐"。P2 的回调何时执行,直接取决于 P1 何时 resolve,所以 P1 才是爹。机制上,Promise 的户口由 V8 的 PromiseHook 发放,.then() 派生时 V8 会把上游 promise 作为 parent 传进来。官方文档的钩子序列一字不差地印证了这个结构:

text 复制代码
init for PROMISE with id 5, trigger id: 1     ← Promise.resolve(),爹是主模块
  promise resolve 5
init for PROMISE with id 6, trigger id: 5     ← then() 派生的,爹是上游 5
  before 6                                    ← then 回调执行

2.2 readFile 的爹为什么不是主模块(实验 03)

同一个输出里还有第二个反直觉数字:readFile 回调"编号 12,爹 11",爹也不是主模块(1)。

原因:fs.readFile 对你是一步,对内部是四步流水线。用 init 钩子直接观测(实验文件 async-hooks/03-readFile内部流水线.js):

text 复制代码
init    FSREQCALLBACK(2) 爹=1    ← open:主模块亲手创建
before  2 → init (3) 爹=2        ← fstat:在 open 的完成回调里出生
before  3 → init (4) 爹=3        ← read:在 fstat 的完成回调里出生
before  4 → init (5) 爹=4        ← close:在 read 的完成回调里出生
before  5 → 用户回调执行 eid=5    ← 你的回调坐在末节车厢

对照 v22 源码 lib/fs.js,每一步都在上一步的完成回调里发起:readFileAfterOpen 里 binding.fstat,stat 完成后预分配 buffer 再 read,读完 close。

钉死通用规则:triggerAsyncId = 资源出生那一刻的 executionAsyncId。规则只有这一条,没有例外。表面矛盾来自"主模块亲手创建了谁":主模块只亲手创建了 open,后面每一节车厢都在前一节的现场里出生。

2.3 Promise 默认没有户口

第一次跑实验 02 时,两个 Promise.then 都显示"编号 0,爹 0"。官方文档给了原话:

By default, promise executions are not assigned asyncIds due to the relatively expensive nature of the promise introspection API provided by V8.

Promise 追踪默认关闭,createHook({ init(){} }).enable() 一打开,编号立刻出现。编号消耗速度能解释为什么默认关:激活后每个 Promise 占两个号,一条 then 链烧掉一堆 id 和追踪开销。Node 不想为不用 async_hooks 的应用白付这笔钱。

另外注意 0 的官方含义:executionAsyncId 返回 0 表示"当前从 C++ 层执行,上方没有 JS 栈"。execution 只记录何时,trigger 记录因谁,所以 C++ 直接催生的资源(比如新到的 TCP 连接)execution 是 0,但 trigger 照样接得上家谱。

第 3 章:观测层,createHook(实验 04)

createHook 注册五个生命周期钩子,直播每个异步资源的生老病死。

实验文件:async-hooks/04-钩子事件直播.js

js 复制代码
createHook({
  init(asyncId, type, triggerAsyncId) { log(`init      ${type}(${asyncId}) 爹=${triggerAsyncId}`); },
  before(asyncId)  { log(`before    ${asyncId}`); },
  after(asyncId)   { log(`after     ${asyncId}`); },
  destroy(asyncId) { log(`destroy   ${asyncId}`); },
  promiseResolve(asyncId) { log(`pResolve  ${asyncId}`); },
}).enable();

setTimeout(() => log('--- timer 回调正在执行 ---'), 10);
Promise.resolve().then(() => log('--- then 回调正在执行 ---'));

实际输出:

text 复制代码
init      Timeout(2) 爹=1        ← 写下 setTimeout 那行的瞬间就响
init      PROMISE(3) 爹=1        ← Promise.resolve() 出生,爹=主模块(认书写位置)
pResolve  3
init      PROMISE(4) 爹=3        ← .then() 派生,爹=上游 3(认数据来源)
--- [同步] 同步代码结束 ---
before    4                      ← then 回调要执行了
--- then 回调正在执行 ---
pResolve  4                      ← then 回调 return,派生 promise 随之 resolve
after     4
before    2                      ← 10ms 后,timers 阶段
--- timer 回调正在执行 ---
after     2
destroy   2                      ← Timeout 用完即焚

五个钩子一句话一个:

钩子 何时响
init 资源出生那一刻(同步),登记"一个异步可能性诞生"
before / after 回调执行前后的门框;可能 0 次(被 clear)、1 次(timer、then)、N 次(TCP server 每个连接一对)
destroy 资源销毁;Timeout 显式销毁立刻到,Promise 靠 GC 收尸时机不保证
promiseResolve Promise 专属,resolve() 被调用的那一刻

注意输出里 PROMISE(3) 和 (4) 的 destroy 全程缺席:它们确实死了,但进程太短命 GC 没跑。这就是"destroy 时机不保证"的肉身,别拿它做精确计时。

3.1 createHook 不等于通电

createHook 只是组装,enable() 才是开关。而且四个钩子的时间边界不对称,实测(一个 timer 出生在 enable 前,一个在后):

text 复制代码
timerA(enable 之前出生):       timerB(enable 之后出生):
  init    ✗ 缺席!                  init    Timeout(3) ✓
  before  2 ✓                       before  3 ✓
  after   2 ✓                       after   3 ✓
  destroy 2 ✓                       destroy 3 ✓

init 过时不候(出生时检查一次,当时没钩子就永远错过);before/after/destroy 检查点在执行或销毁的当下,对老资源照常。

3.2 钩子里的四个天坑

官方文档用了好几节警告,都是血泪:

坑 后果 解法
钩子回调里 console.log 打印是异步操作,触发新 init,无限递归 用 fs.writeSync(1, ...) 同步写
钩子回调用 async 函数 返回 promise 又被追踪,套娃 只用普通函数
钩子回调里抛异常 进程直接退出,uncaughtException 被移除 回调内自己兜住
init 里存 resource 对象 GC 回收不了,destroy 永不触发,内存泄漏 只存 asyncId 数字

本文实验脚本里清一色 fs.writeSync(1, ...),就是为了躲第一个坑。

第 4 章:ALS 的 API 全家(实验 05)

实验文件:async-hooks/05-ALS的API们.js

核心两兄弟,业务 99% 只用这俩:

API 干什么
run(store, fn) 圈一块地:fn 的同步段和它衍生的所有异步后代都能拿到 store;fn 之外拿不到
getStore() 拿当前上下文的 store;不在任何上下文里返回 undefined

其余五个:

API 干什么 什么时候用
enterWith(store) 不包函数,就地进入上下文:当前同步执行的剩余部分加后续异步后代全生效 框架切碎控制流的场景(如 Express 中间件)
exit(fn) fn 期间临时溜出上下文,出来自动恢复 某段代码明确不想带 store
snapshot() 静态 给当前上下文拍张照片,返回函数 f;以后 f(fn) 都在照片里的上下文执行 断链创可贴,第 5 章实战
bind(fn) 静态 把 fn 绑定到当前上下文 snapshot 的单函数版
disable() 永久关停实例,可被 GC 进程长寿但实例不再用时

enterWith 是全家最野的,实验里直接看事故现场:

js 复制代码
emitter.on('go', () => {
  als2.enterWith('B的store');
  console.log('监听器1(enterWith的):', als2.getStore());
});
emitter.on('go', () => {
  console.log('监听器2(无辜的)  :', als2.getStore());
});
console.log('emit 之前            :', als2.getStore());
emitter.emit('go');
console.log('emit 之后            :', als2.getStore());

输出:

text 复制代码
emit 之前            : undefined
监听器1(enterWith的): B的store
监听器2(无辜的)     : B的store    ← 啥也没干,中招了
emit 之后            : B的store    ← 漏到主模块里了,没自动恢复!

原理:emit('go') 是同步循环调用所有监听器,它们和 emit 之后的代码共享同一个同步执行流。enterWith 改的是当前执行流的 store,没有函数边界拦住它。run 的边界是函数(进函数设、出函数恢复),enterWith 的范围是这一行往后的整个同步执行流。所以文档说优先用 run。

snapshot 演示时空穿越:

js 复制代码
const 洗照片 = als4.run('底片123', () => AsyncLocalStorage.snapshot());
const 结果 = als4.run('现场321', () => 洗照片(() => als4.getStore()));
// 结果 = '底片123'

它把上下文变成一张可随身携带、随时洗出的照片。记住它,第 5 章断链救援全靠它。

第 5 章:断链,本站的核心硬仗

ALS 不是无敌的。某些写法下上下文会丢,而且丢得无声无息。这一章用一个四层模型把它讲透,模型是你自己也能推出来的。

5.1 先亲手写一个 ALS(四层递进)

第一层:同步模型。 ALS 剥到底就是一个全局变量加一对写入恢复:

js 复制代码
let current = undefined;          // 全局唯一的"当前值"寄存器

function run(store, fn) {
  const prev = current;
  current = store;                // 进圈:写入
  try { return fn(); } finally { current = prev; }   // 出圈:恢复旧值
}
function getStore() { return current; }

run('A', () => console.log('圈里:', getStore()));   // A
console.log('圈外:', getStore());                    // undefined

第二层:异步破局。 往圈里放一个 setTimeout:

js 复制代码
run('A', () => {
  setTimeout(() => console.log('timer 里:', getStore()), 10);
});
// 输出:timer 里: undefined

时间线:t=0 圈开(current = 'A'),注册 timer,fn 返回,finally 立刻把 current 恢复;t=10ms 事件循环捞起回调执行,读到 undefined。回调读的是执行那一刻的寄存器,而圈早在 10ms 前就退了。

第三层:拍照与洗照片。 修复思路:注册那一刻(圈里,值还是对的)把值存下来,回调执行时放回寄存器:

js 复制代码
function 带上下文的setTimeout(cb, ms) {
  const 照片 = current;             // 注册那一刻:拍照
  return setTimeout(() => {
    const prev = current;
    current = 照片;                 // 执行前:洗照片(写回寄存器)
    try { cb(); } finally { current = prev; }   // 执行完恢复现场
  }, ms);
}

注意为什么必须写回寄存器而不是让 cb 直接读闭包:cb 内部还会调别的函数(比如日志函数),它们只认 getStore(),读的是全局寄存器。写回才能让 cb 内部任意深度都透明生效。

到这里你其实已经发明了 AsyncLocalStorage。真的 ALS 没有更多魔法:Node 给每一个异步 API(setTimeout、readFile、nextTick、Promise.then)都在内部做了同样的包装,注册时拍照,执行时洗照片。

第四层:拍照的主体是异步资源,不是函数。 精确说,照片存在异步资源对象身上(Timeout 对象、Promise 对象、Worker 对象),资源出生那一刻由运行时拍下当时的寄存器值。普通箭头函数出生时没有任何人给它拍照,它被执行时读到什么,取决于"谁在什么现场里调用它"。

用 init 钩子指认一下谁是资源:

text 复制代码
--- 准备 new Worker ---
init    WORKER(2) 爹=1          ← Worker 是登记在册的异步资源,出生即拍照
init    MESSAGEPORT(3) 爹=1
--- new Worker 返回 ---
(worker.on('message', cb) 执行,init 毫无动静:cb 不是资源,不登记)

5.2 裸池断链现场(实验 06)

看一个手写 worker 池,三个探针。实验文件:async-hooks/06-裸池ALS断链验证.js

js 复制代码
class NaivePool {
  constructor(file) {
    this.worker = new Worker(file);        // Worker 资源出生:此刻在圈外,照片 undefined
    this.worker.on('message', ({ id, result }) => {
      console.log('[探针1]', als.getStore());   // message 回调里
      this.pending.get(id)(result);              // resolve
    });
  }
  run(task) {
    return new Promise((resolve) => {      // Promise 出生:被调时在圈里,照片 req-001
      this.pending.set(this.seq, resolve);
      this.worker.postMessage({ id: this.seq++, task });
    });
  }
}

await als.run({ requestId: 'req-001' }, async () => {
  const r = await pool.run('hello');
  console.log('[探针2]', als.getStore());  // await 恢复后
  await new Promise((res) => setTimeout(res, 10));
  console.log('[探针3]', als.getStore());  // 再隔一跳
});

输出:

text 复制代码
提交前 store: {"requestId":"req-001"}
  [探针1] message 回调里的 store: undefined      ← 断了
  [探针2] await 后的 store: {"requestId":"req-001"}   ← 又回来了?
  [探针3] setTimeout 后的 store: {"requestId":"req-001"}

探针 1 为什么断:on('message', cb) 只是往 Worker 这辆已出生的车上挂监听,不产生新资源。Worker 在池构造时出生(圈外),照片是 undefined。每次消息触发,事件循环洗的都是这张出厂旧照。

探针 2 为什么"自愈":其实没有任何东西被修好。06 里有两个回调、两个资源、两张照片:

  • 回调 A(池的 message 监听):洗 Worker 的照片,undefined,从头到尾都是断的
  • 回调 B(你的业务代码,await 续体):洗 Promise p 的照片,p 出生在圈里(提交任务时),照片是 req-001

message 回调里的 resolve 只是通知"p 有值了",不修任何照片。随后事件循环捞起续体 B 执行时,换了一个资源,换了一张照片。好比 Worker 是一座断桥,Promise 是一座好桥,resolve 是摆渡船:断桥从来没被修好,是你的代码不在断桥上走了。

5.3 callback 风格才是真灾难(实验 08)

Promise 风格池的自愈会给人虚假的安全感。如果池是 callback 风格,直接把用户回调存起来在 message 里调用,用户回调就在断桥上裸奔。实验文件:async-hooks/08-断链与两种救法.js

text 复制代码
[裸池] 回调里 store       : undefined      ← 用户回调裸奔
[裸池] 回调后代 timer store: undefined     ← 后代也全断
[snapshot救] 回调里 store       : {"requestId":"req-001"}
[snapshot救] 回调后代 timer store: {"requestId":"req-001"}
[AsyncResource救] 回调里 store       : {"requestId":"req-001"}
[AsyncResource救] 回调后代 timer store: {"requestId":"req-001"}

第二行值得细看:用户回调里注册的 setTimeout 是正规军,事件循环执行它时也切现场洗照片,但它出生在断链现场(message 回调里),拍下的就是 undefined。照片本身坏了,洗得再标准也没用。

5.4 最阴险处:文本位置不等于执行时机

很多人读 08 的代码会困惑:那个 timer 明明写在 als.run 的括号里,凭什么 undefined?

因为 als.run 圈住的是 fn 的同步执行段,不是"文本写在 fn 里的所有代码"。cb 的文本在圈里,但 pool.run 同步返回后 fn 就结束了,finally 立刻恢复寄存器;之后 cb 被 message 回调调用时,圈早退了。cb 函数体里的每行代码,执行时机都在圈外。

十行最小复现,把池和 worker 全剥掉:

js 复制代码
const als = new AsyncLocalStorage();
let 存起来的cb;

als.run('req-001', () => {
  存起来的cb = () => {
    // 文本写在 run 的括号里,但此刻并未执行
    setTimeout(() => console.log('timer 里:', als.getStore()), 10);
  };
});
存起来的cb();     // 圈已退,裸现场里调用
// 输出:timer 里: undefined

这也是断链难排查的根源:读代码的直觉是"这段明明写在 run 里面"。判别口诀:不看代码写在哪,看它执行那一刻,圈还开着吗。 所有断链场景(池缓存回调、emitter 复用监听、全局数组存回调)都是同一个模式的变种:注册在圈里,执行在圈外。

5.5 切现场全景表

"现场"就是那个全局寄存器,"切现场"就是谁把它的值换了。全部切换时机只有这几处:

动作 切不切 行为
普通函数 a() 调 b() 不切 b 共享 a 的此刻现场
als.run(store, fn) 切 进 fn 前写入,返回后恢复
als.enterWith(store) 切 就地写入,不恢复
ar.runInAsyncScope(cb) 切 洗 ar 的照片,执行,恢复
事件循环执行异步回调 必切 捞起资源,洗它的照片,执行,恢复,捞下一个

事件循环本质是一台切现场调度器。最小证据:两个圈各注册一个 10ms timer,两个回调同一阶段先后执行,各读各的:

text 复制代码
timerA 回调: A
timerB 回调: B

5.6 两种救法

救法 A:snapshot。 提交任务时(圈里)拍照,池回调里洗照片:

js 复制代码
run(task, cb) {
  const snap = AsyncLocalStorage.snapshot();   // 拍照:此刻上下文还在
  this.tasks.set(this.seq, { cb, snap });
  this.worker.postMessage({ id: this.seq++, task });
}
// message 回调里:
const { cb, snap } = this.tasks.get(id);
snap(() => cb(result));                        // 洗照片:回到提交时的上下文执行

救法 B:AsyncResource(官方姿势)。 给每个任务发一张出生证:

js 复制代码
run(task, cb) {
  const ar = new AsyncResource('PoolTask');    // 资源出生,登记在提交时的上下文
  this.tasks.set(this.seq, { cb, ar });
  this.worker.postMessage({ id: this.seq++, task });
}
// message 回调里:
ar.runInAsyncScope(cb, null, result);          // 在这张出生证的家谱分支里执行
ar.emitDestroy();                              // 用完销户

两者功能等价,差别在观测层:AsyncResource 有身份(init 钩子里能看到 PoolTask(2) 爹=1,自定义类型、独立编号、destroy 可查),snapshot 完全透明。文档原话:简单场景 snapshot 可以替代 AsyncResource。官方文档里的 Worker 池完整示例用的就是 AsyncResource 姿势。

5.7 实战:一行探针判别断链库

评估任何"替你在别的时机调回调"的第三方库(连接池、任务队列、事件总线、ORM):

js 复制代码
als.run({ requestId: 'test' }, () =>
  库.会调你回调的API(() => console.log(als.getStore())));

打出 undefined 就是断链库,你的日志体系会在经手它的请求上失明。好库(ioredis、pg 的新版本)都接了家谱;老库或自研池,上面两种救法就是修复模板。

第 6 章:实现层,手写模型 vs 官方源码

v22 里 ALS 有两个实现,入口按 flag 二选一(lib/async_hooks.js):

js 复制代码
get AsyncLocalStorage() {
  return AsyncContextFrame.enabled ?
    require('internal/async_local_storage/async_context_frame') :   // 实验开关
    require('internal/async_local_storage/async_hooks');            // v22 默认
},

6.1 v22 默认实现:117 行,和手写模型逐行同构

run 的圈地,和手写信封版几乎一行不差:

js 复制代码
run(store, callback, ...args) {
  const resource = executionAsyncResource();          // 当前资源对象
  const oldStore = resource[this.kResourceStore];     // 存旧值
  resource[this.kResourceStore] = store;              // 写入
  try {
    return ReflectApply(callback, null, args);
  } finally {
    resource[this.kResourceStore] = oldStore;         // finally 恢复
  }
}

唯一差别:手写版用全局变量当寄存器,官方用当前资源对象身上的一个 Symbol 属性。每个 ALS 实例一个自己的 Symbol key,多实例互不干扰。

拍照则是全局共享一个 init 钩子,资源出生时从爹身上拷贝:

js 复制代码
const storageHook = createHook({
  init(asyncId, type, triggerAsyncId, resource) {
    const currentResource = executionAsyncResource();      // 爹
    for (als of storageList)
      resource[als.kResourceStore] = currentResource[als.kResourceStore];  // 爹拷给子
  },
});

"继承发生在出生时刻"的代码实锤。getStore 就是读当前资源对象的属性,所谓洗照片不需要额外动作,事件循环执行回调时 executionAsyncResource 天然就是那个资源。

两个值得知道的细节:ALS 自己就是 createHook 的一个用户(观测层和传值层同根生);第一个 ALS 实例启用时才开启这个钩子(惰性追踪,第 2 章的 0/0 现象同源)。

enterWith 为什么污染,代码里一目了然:

js 复制代码
enterWith(store) {
  executionAsyncResource()[this.kResourceStore] = store;   // 直接改写当前资源,没有边界
}

6.2 frame 版:寄存器挪进 V8 引擎

新实现的 run 同构,但寄存器换了地方:

js 复制代码
enterWith(data) {
  const frame = new AsyncContextFrame(this, data);   // 复制当前帧并写入这一条
  AsyncContextFrame.set(frame);                      // 设为当前帧
}
getStore() {
  return AsyncContextFrame.current()?.get(this);
}

frame 是个 Map(ALS 实例到 store),current/set/exchange 直接读写 V8 的 continuation preserved embedder data。Promise 和微任务在 V8 层面天然会保存恢复这个槽位,拍照洗照片由 V8 在 continuation 切换时顺手完成,一个 JS 钩子都不用注册。这就是换实现的全部动机。

三版同构总表:

手写模型 v22 默认 frame 版
寄存器 全局变量 当前资源对象的 Symbol 属性 V8 continuation 槽的 Map
run 写入加 try/finally 同构 同构
拍照 包装函数里手动存 init 钩子:爹拷给子 V8 自动携带
开销 无 资源数乘 ALS 实例数 约等于零

第 7 章:开销实测(实验 09)

实验文件:async-hooks/09-ALS开销实测.js。同一脚本跑两次对撞:默认(老实现)和 --experimental-async-context-frame(frame 版),10 万跳任务链计时。

promise 链的结果(每次 await 的耗时):

场景 数值 解读
纯裸,无任何钩子 35ns 底线
1 个空 init 钩子 97ns 钩子的基础票价
叠 before 加 after 126ns 链式 promise 每跳过门框
再叠 destroy 337ns 单笔最大跳跃,约裸的 10 倍
老实现,5 个 ALS 后台传播 202ns ALS 的真实价格
frame 版,5 个 ALS 后台传播 33ns 开销基本归零

事件循环级任务(setImmediate、I/O 回调)两版都测不出 ALS 开销,13μs 的迭代底噪把几十纳秒淹没了。

三条结论:

1. ALS 的开销集中在 promise 传播,老实现可观,frame 版抹平。 5 实例下每次 await 贵约 170ns,frame 版接近零。官方换实现的动机,数字上亲眼所见。

2. 换算到业务,ALS 很便宜。 一个请求按 50 次 await 算,5 个 ALS 实例(trace、日志、DB、指标、租户)老实现多花约 8.5μs,对毫秒级请求处理不到 0.1%。"ALS 很贵"是 v12 到 v16 时代的过时传言,v22 尽管用。

3. 真正的性能杀手是全钩子。 destroy 一档把 promise 链打到 10 倍。接入全链路追踪后吞吐掉一截,账单主要在 APM 探针的钩子上,不在 ALS。生产接 APM 后必须压测。

一页速查

用(业务日常)

  • als.run(store, fn) 圈地,als.getStore() 取值,够用了
  • 中间件场景拿不到函数边界才用 enterWith,注意它污染后续整个同步流
  • 优先 run,enterWith 是逃生门不是正门

懂(排障底气)

  • 家谱两字段:asyncId 是我,triggerAsyncId 是我爹
  • 认爹两规则:普通资源认书写位置,Promise 认上游
  • 内部流水线:readFile 拆成 open、fstat、read、close,你的回调坐末节车厢

防(断链)

  • 断链统一模式:注册在圈里,执行在圈外
  • 判别口诀:不看代码写在哪,看执行那一刻圈开没开
  • 救法:snapshot 拍照洗照片,或 AsyncResource 发出生证
  • 第三方库先用探针验:回调里 getStore 是 undefined 就是断链库

算(开销)

  • ALS 本身便宜,promise 密集场景每次 await 贵约百纳秒级
  • 贵的是全钩子(before、after、destroy),APM 接入后必须压测
  • frame 实现把 ALS 开销抹到近零,v22 用 --experimental-async-context-frame 可尝鲜

实验代码清单

文件 验证的事
async-hooks/01-为什么需要ALS.js 全局变量串号、手动传参之痛、ALS 三版对比
async-hooks/02-asyncId家谱.js 家谱两字段、两种认爹规则、惰性追踪
async-hooks/03-readFile内部流水线.js readFile 内部四节车厢
async-hooks/04-钩子事件直播.js 五钩子的触发时机
async-hooks/05-ALS的API们.js run、enterWith、exit、snapshot 行为差异
async-hooks/06-裸池ALS断链验证.js 三探针:断、自愈、正常
async-hooks/07-echo工作线程.js 配套 worker
async-hooks/08-断链与两种救法.js 裸池惨状、snapshot 与 AsyncResource 修复
async-hooks/09-ALS开销实测.js 新老实现开销对撞

写到这里,开篇那个悬念可以收掉了:getStore() 像全局变量一样随手却不串号,凭的是每个异步资源出生时的拍照、回调执行前的洗照片,以及事件循环这台切现场调度器。理解了这套机制,再看 APM 探针、全链路追踪、ORM 的上下文传递,就都是同一个模型的不同投影了。

相关推荐
TheITSea2 小时前
JavaScript Web APIs
开发语言·前端·javascript
恋猫de小郭2 小时前
Android CLI 支持 AI Agent 通过 Device Streaming 调试云真机
android·前端·flutter
Wang's Blog2 小时前
Java 项目实战: 外卖平台优化-前端dist部署与Nginx反向代理rewrite
java·前端·nginx
鬓戈2 小时前
Claude Code frontend-design 插件调研 与 Vue 旧系统风格一致性方案
前端·vue.js·人工智能
IT_陈寒2 小时前
SpringBoot自动配置的坑我帮你踩过了
前端·人工智能·后端
其实防守也摸鱼2 小时前
网安自测题:掌握核心知识点的实用练习
linux·运维·服务器·前端·数据库·sql·xss
geovindu5 小时前
css: Timeline scribble
前端·css·iphone
沙漠之主13 小时前
C++编程教学设计资料:从入门到实战的完整课程方案
java·前端·c++
hasty13 小时前
不上传新包,也能改变用户拿到的版本:npm dist-tag 的 OIDC 权限治理
前端·npm·node.js
Csvn13 小时前
diff 算法(虚拟 DOM Reconciliation)
前端