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 又对又爽:dbCserviceC 根本不知道 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,每一步都在上一步的完成回调里发起:readFileAfterOpenbinding.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 的上下文传递,就都是同一个模型的不同投影了。

相关推荐
用户2181697049301 小时前
Flutter (二十五)视频播放
前端
半个落月1 小时前
在浏览器里运行 DeepSeek-R1:推理、流式输出与停止生成(二)
前端·人工智能·react.js
绿岛之北1 小时前
Electron 安全第四章: Preload 与 IPC
前端·electron
X-future4261 小时前
Node.js快速入门+demo案例
node.js
半个落月1 小时前
在浏览器里运行 DeepSeek-R1:从 WebGPU 检测到模型加载(一)
前端·人工智能·react.js
八角丶1 小时前
Node.js 事件循环详解(实验驱动)
前端·node.js
deli0070071 小时前
汉诺塔益智小游戏:浏览器里说句话,码道 WebUI 一键生成+部署上线
前端·ai编程
用户2181697049301 小时前
Flutter (二十四) 音频
前端
愚公搬代码1 小时前
【愚公系列】《Web应用安全》012-Behinder工具的使用
前端·安全