这其实是通过 async_hook API 来实现的,具体来说,node 在全局维护了一个 store 列表:
const storageList = [];
const storageHook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
const currentResource = executionAsyncResource();
// Value of currentResource is always a non null object
for (let i = 0; i < storageList.length; ++i) {
storageList[i]._propagate(resource, currentResource, type);
}
}
});
// als._enable()
_enable() {
if (!this.enabled) {
this.enabled = true;
ArrayPrototypePush(storageList, this);
storageHook.enable();
}
}
如上面的代码所示,ALS 创建了一个 init hook,首次调用 run 时,会把当前 store 对象存入全局 store 列表并启动 hook。这样,每次进入新的异步操作前,会将旧 resource 中的所有 store 实例传递给新的 resource,这样在异步操作内,也能正常通过 getStore 获取 store 啦!
import { createServer } from 'node:http';
import { AsyncResource, executionAsyncId } from 'node:async_hooks';
const server = createServer((req, res) => {
req.on('close', AsyncResource.bind(() => {
// Execution context is bound to the current outer scope.
}));
req.on('close', () => {
// Execution context is bound to the scope that caused 'close' to emit.
});
res.end();
}).listen(3000);
AsyncLocalStorage 源码分析
TL; DR
本文概述了
AsyncLocalStorage
的概念和它在 Node.js 中的历史发展。随后简要探讨了AsyncLocalStorage
的部分源码,以阐明其在异步编程中如何管理上下文状态。最后简单分析了AsyncLocalStorage
在fx-code
项目中的实际应用场景。What, Why & History
What & Why?
AsyncLocalStorage
(后文简写为ALS
)是Node.js
中的一个 API,它提供了一种在异步调用链中存储和传递数据的方法。这个类属于
async_hooks
模块,类似于Python
的contextvars
、Java
的ThreadLocal
,能够让程序在一个异步上下文中存储数据。ALS
的典型应用场景包括:请求范围内跟踪用户 auth、日志记录、事务追踪、Metrics 等等。ALS 的历史
在
ALS
之前,Node 社区曾涌现过各种解决异步上下文跟踪的方案,包括domain
、CLS
和CLS-hooked
:domain
是 Node 古早时期用来解决异步代码错误处理的 api,也可以用来维持异步上下文。它的实现涉及到对 Node 的一些底层 API 的魔改,主要是涉及到事件循环的部分,例如nextTick
、setTimeout
以及大部分异步 IO 的操作,以确保上下文的维持和拦截其中的错误。随之而来的缺点是,由于该模块魔改了全局函数,很容易出现奇奇怪怪的副作用,并且有非常严重的性能问题。目前已被废弃,不再推荐使用。Continuation-Local Storage (CLS)
是一个社区维护的库,其实原理类似于domain
,大概就是给 Node.js 的核心异步代码打猴子补丁,依赖的应该是process.addAsyncListener
这个古老(node v0.11)的API,后续通过 polyfill 来兼容。更深入一点其实就是维护了一个全局的栈结构,用来实现一些嵌套的上下文切换。async_hooks
API,给出了一个 hook 异步调用生命周期各阶段的官方实现。热心网友立马就 fork 了CLS
并基于async_hooks
重写,也就是CLS-Hooked
库。不过由于async_hooks
的很多 api 其实一直处于 stage-1 实验阶段,依赖于它的CLS-Hooked
也并不推荐在生产环境使用。ALS
,并随后加入到了 12.17 LTS 里。不过刚出来的时候,ALS
其实有比较严重的性能问题的。2020 年底,Qard 使用新的v8::Context PromiseHook
API 重构了 async_hooks,随后的 benchmarks 显示,性能问题已经得到了大幅度的缓解。简单来说,
domain
和CLS
的实现方式是给 node 打猴子补丁(运行时补丁),而CLS-hooked
和ALS
则是依赖官方的async_hooks
API。经过一系列的优化,ALS
的性能损耗已经足够低,官方也推荐使用ALS
来追踪异步执行上下文。使用方式
根据官方文档,
ALS
的主要 API 如下:官网的一个例子:
在这个例子中,我们创建了一个
AsyncLocalStorage
实例,并使用中间件来为每个请求分配一个唯一 ID。使用run
方法可以在当前异步执行上下文中绑定请求 ID,这样,在这个异步执行上下文创建的任何异步事件中,我们都可以通过getStore
方法得到同一个 ID,而无需显式地将请求 ID 透传给每个函数。实现原理
ALS
的原理其实非常简单,下面以Node 18.13
源码为例进行简要分析。ALS
的源码中 js 部分在代码仓库的lib/async_hooks.js
文件里,首先看run
的实现:其中,
this._enable()
用于启动ALS
的一个 init hook,这个稍后再聊。executionAsyncResource()
: 这是async_hooks
模块的一个函数,它返回代表当前执行异步资源的对象。在 Node.js 中,每个异步操作都与一个 resource 对象相关联,它代表了操作的上下文。this.kResourceStore
: 这个其实是 ALS 构造函数里创建的一个 Symbol,用来往 resource 里存 store 时的 key。可以看出,
run
主要做了这样一件事情:备份当前的store
,然后替换为 run 函数入参提供的新store
,然后执行入参里的回调函数,这样,后续的异步操作都可以通过这个 resource 访问到新的store
。当然,回调函数里也能继续嵌套调用 run,而这里其实是借用函数的调用栈结构,实现了一个 store 的切换与重置。
到这里,回调函数里的所有同步函数都能正确的通过
getStore
获得对应的存储:不过,万一回调里开启了新的异步操作呢?这时executionAsyncResource() 会得到一个全新的 resource。因此,我们需要一个机制去在调用处传递 resource 里的 store。
这其实是通过
async_hook
API 来实现的,具体来说,node 在全局维护了一个 store 列表:如上面的代码所示,
ALS
创建了一个init
hook,首次调用run
时,会把当前 store 对象存入全局 store 列表并启动 hook。这样,每次进入新的异步操作前,会将旧 resource 中的所有 store 实例传递给新的 resource,这样在异步操作内,也能正常通过getStore
获取 store 啦!至于剩下两个函数
enterWith
和disable
,实现比较简单。扫一眼代码就大概知道是在干嘛了:FX-CODE 里的应用
fx-code 工程里的 nstarter-core 依赖里使用
ALS
封装了一个异步上下文管理器,主要用来维护一个请求的 traceId。另外 james 之前做 metrics 的时候也用它来存储一些请求范围的 trace 信息。实现上来说,在
node_modules/nstarter-core/src/context/provider.ts
里定义了ContextProvider
类,其中getMiddleware
返回一个中间件,将 next 函数包括在了 als 的上下文里。这样每一个新的请求,都能通过静态方法ContextProvider.getContext()
获取请求上下文了:上下文丢失的问题
在大多数情况下,
AsyncLocalStorage
可以正常工作,对于基于回调的函数,只需要使用util.promisify()
将其转换为Promise
就可以了。在少数情况下可能会发生上下文丢失,这时只需要通过
AsyncResource
API 恰当地绑定好上下文即可。当你发现上下文丢失时,可以通过在适当的位置执行getStore
来推理造出上下文丢失的函数调用并解决修复。例如,由
EventEmitter
触发的事件监听器可能在调用eventEmitter.on()
时的执行上下文中运行。下面展示了一个问题场景以及对应的修复方式,其他类似的基于事件驱动的场景也可以用同样的方式修复: