系列第五篇。前四篇分别聊了子进程、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 的上下文传递,就都是同一个模型的不同投影了。