前言
在现代服务端架构中,为了更好抽象组件和迭代,首先会将服务细分成底层服务(如 redis、mysql、MQ)、业务服务,业务服务可能会再细分成中台服务(账号权限中心)以及 BFF 等等。一个接口发起,经过数个服务后再响应至前端,全链路监控的作用是详细展示该接口在不同服务之间的耗时。
由于能看到接口的每个阶段耗时,开发者可以轻而易举找出为什么请求耗时长或请求失败的关键阶段,进而提升开发效率以及降低事故的 MTTR。正因为 Tracing 如此重要,在 Nodejs 中有个专门为它而生的模块 async_hook。
API 研读
以下所有 Demo 代码的运行环境基于 nodejs 20.9.0
接下来有请主角: ,不,由于官网公告说明 createHook 和 executionAsyncResource 暂时不推荐使用,可能会有安全和性能风险,用 AsyncHook
AsyncLocalStorage
****代替。
Node 已经很贴心的帮开发者整理一个新文档名叫 "Asynchronous context tracking",其中包括今天的主角 AsyncLocalStorage
****和 ****AsyncResource
。 这两个 API 来自 async_hook 模块,官网对这个模块的解释:
这些类用于关联状态并在整个回调和 Promise 调用链中传递 。它们允许在 Web 请求的整个生命周期或任何其他异步方法内存储数据。类似于其他语言中的 Thread Local Storage。
Thread Local Storage
Thread Local Storage(简称 TLS,线程局部存储) 是一个项解决多线程内部变量使用问题的技术。设想下,如果一个变量是全局的,那么所有线程访问的是同一份引用地址,某一个线程对其修改会影响其他所有线程。如果我们需要一个变量在每个线程中都能访问,并且值在每个线程中互不影响,这就是 TLS 存在的意义。
最简单的 TLS 实现:在全局有一个张映射表,由于线程 ID 是唯一,每个线程 ID 则对应一块独立内存。
类推过来,nodejs 中需要用这种方式来解决异步上下文传递,关键字从"线程"变成"异步",由此引出 AsyncId(异步 Id)。
AsyncId & Async Scope
在 nodejs 引入 async_hook 模块后,每一个函数(不论异步还是同步)都会提供一个上下文, 称之为 Async Scope。相对应有个 API:asyncResource.runInAsyncScope()
****,即在同一个 Async Scope 下执行 。
AsyncId 则是每个 Async Scope 的标志,也是全局唯一标识符,并在每个异步资源在创建时, AsyncId 自动递增。如下例子所示:
猜测:那是不是类似 Thread Local Storage 一样,在全局有张映射表,每个异步 Id 都其相对应的独立对象?(答案在小结中)
Store
Store 在这里类似于 redux 中的"store",表示某个 异步上下文的存储器,注意关键字"某个",说明不同异步上下文的存储器是隔离的。它来自 asyncLocalStorage.getStore
,主要特点是在串联多个异步资源参数时可以更优雅,无需层层透传。先看个例子:
可以看出,即使在 setTimeout 回调函数中获取 id,获取到的值依然是 run 时传入的参数 { id: 1 },可以把 setTimeout 想象成与 socket.connect 或 http.listen 一样的异步资源,也就是说只要在同样的 Async Scope 下,不用显式透传参数就能拿到预期中相同的对象。
看起来很奇妙对不对!为什么没有透传参数的前提下能做到不同函数域获取相同的上下文?接下来就层层剥开 AsyncId 和 Async Scope 神秘面纱。
源码解读
为了更好分析其底层原理,将上面的例子简化了下,如下所示,我们就来讲讲 nodejs 是怎么做到"隔空"传递参数
属性
在深入具体的 API 前,先来熟悉下 async_hook 的基本属性。当阅读 nodejs 仓库下的 /lib/async_hook.js 时,会发现某些属性例如 kExecutionAsyncId,这种通过 internalBinding 来获取在 C++ 层定义的数据,会有一个 .d.ts 文件,与 src/env.h 想吻合,如下所示:
这些属性的定义对后续的分析极有帮助,先大概熟悉下:
C++
// src/env.h
class AsyncHooks
{
enum Fields
{
kInit,
kBefore,
kAfter,
kDestroy,
kPromiseResolve, // 从 kInit - kPromiseResolve,都是异常资源创建的钩子函数
kTotals, // 钩子函数总数,从 kInit - kPromiseResolve 有五个钩子函数
kCheck, // 开启 AsyncHook 的实例总数,在 AsyncHook.enable 中自增
kStackLength, // 指向栈 async_ids_stack 的指针
kUsesExecutionAsyncResource, //
kFieldsCount, // 和 kTotals 作用类似,代表 Fields 有 8 个可用的 key
};
enum UidFields
{
kExecutionAsyncId, // 当前执行异步 id
kTriggerAsyncId, // 父级异步资源 id(触发当前异步资源的异步资源 Id)
kAsyncIdCounter, // asyncId 自增计数
kDefaultTriggerAsyncId, // 默认 trigger async id
kUidFieldsCount, // UidFields 可用属性总数
};
// 对应 nodejs 层的 async_ids_stack,存储当前执行上下文栈的 id,包括 kExecutionAsyncId 和 kTriggerAsyncId
AliasedFloat64Array async_ids_stack_;
// 对应 nodejs 层的 async_hook_fields,可理解对象,key 是 enum Fields 下所有的值,所以它的数组长度就是 kFieldsCount + 1
AliasedUint32Array fields_;
// 对应 nodejs 层的 async_id_fields,可理解成对象,key 是 enum UidFields 下所有的值,所以它的数组长度就是 kUidFieldsCount + 1
AliasedFloat64Array async_id_fields_;
// 对应 nodejs 层的 execution_async_resources,类型是 Array,用来存储栈指针对应的异步资源上下文
v8::Global<v8::Array> js_execution_async_resources_;
// 在 nodejs 层中用 executionAsyncResource_() 来获取它的值,类型是 Object,用来存储 key 对应异步资源上下文
std::vector<v8::Local<v8::Object>> native_execution_async_resources_;
};
这里强调下,async_hook_fields 和 async_id_fields 看似是数组,其实这里只是当成预定义所有 key 的对象来用,key 由枚举组成,从类型也可看出来,value 是 float 和 int,基本表示个数和 id,他们之间具体联系如下所示:
Timeout 实例与 store 建立联系
我们先在 callback 下的 getStore 打个断点,发现 execution_async_resources 这个数组变量的第一个值是个 Timeout 对象,并且挂载着 Symbol(kResourceStore): { id: 1},如下图所示:
还记得在开头说过:每个函数(不管是同步还是异步)都有自己的 Async Scope。当前这个 Timeout 对象以及身上挂载的属性就是 Async Scope 的标志位。
全局搜索下,只有在 pushAsyncContext 这个函数中最有可能被推入数据,如下所示:
JavaScript
function pushAsyncContext(asyncId, triggerAsyncId, resource) {
// 当前指向 async_ids_stack 的下标位置
const offset = async_hook_fields[kStackLength];
// 将当前 resource 推入下标为 offset 的 execution_async_resources
execution_async_resources[offset] = resource;
// 每次 push 都存储两个元素 kExecutionAsyncId 和 kTriggerAsyncId:
// [0]=kExecutionAsyncId [1]=[kTriggerAsyncId] [2]=kExecutionAsyncId [3]=[kTriggerAsyncId] 以此类推
// 所以需要拿 offset * 2 和 async_wrap.async_ids_stack 长度做比较
if (offset * 2 >= async_wrap.async_ids_stack.length)
// 如果栈空间不够,调用 C++ 函数进行扩容
return pushAsyncContext_(asyncId, triggerAsyncId);
// 将 kExecutionAsyncId 和 kTriggerAsyncId 存入栈,与上面的 async_hook_fields[kStackLength] 形成映射关系
async_wrap.async_ids_stack[offset * 2] = async_id_fields[kExecutionAsyncId];
async_wrap.async_ids_stack[offset * 2 + 1] = async_id_fields[kTriggerAsyncId];
// 上面已将数据推入 async_ids_stack,自增 1,为下次调用 pushAsyncContext 做准备
async_hook_fields[kStackLength]++;
// 赋值当前执行 asyncId
async_id_fields[kExecutionAsyncId] = asyncId;
// 赋值当前触发 asyncId
async_id_fields[kTriggerAsyncId] = triggerAsyncId;
}
上面代码有个巧妙设计,为了让 kExecutionAsyncId 和 kTriggerAsyncId 和栈下标对应起来,每次都是连续存储两个相邻数据,在后续的 popAsyncContext 也是如此操作,赋值操作示意图如下所示:
pushAsyncContext 再往上回溯是被 emitBeforeScript 调用,emitBeforeScript 被 promiseBeforeHook 调用,最终发现源头是在 asyncLocalStorage.run,具体代码如下所示:
C++
// 创建异步资源的钩子函数,每次有新异步资源被创建时都会先执行一遍钩子函数
const storageHook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
// 获取当前异步资源
const currentResource = executionAsyncResource();
for (let i = 0; i < storageList.length; ++i) {
storageList[i]._propagate(resource, currentResource, type);
}
},
});
// 以下代码选择性删除一些边缘逻辑
class AsyncLocalStorage {
constructor() {
// 可以有多个实例,这里 Symbol 是为了能做到唯一键
this.kResourceStore = Symbol('kResourceStore');
this.enabled = false;
}
_enable() {
if (!this.enabled) {
this.enabled = true;
// 将 this 推入 storageList
ArrayPrototypePush(storageList, this);
storageHook.enable();
}
}
_propagate(resource, triggerResource, type) {
const store = triggerResource[this.kResourceStore];
if (this.enabled) {
// 将 store 塞入当前异步资源
resource[this.kResourceStore] = store;
}
}
run(store, callback, ...args) {
this._enable();
// 获取当前异步上下文
const resource = executionAsyncResource();
const oldStore = resource[this.kResourceStore];
// 将新 store 设置为当前异步上下文的 store
resource[this.kResourceStore] = store;
try {
// 先执行 callback,执行完后再恢复旧的 store
// 这也解释了为什么在 asyncLocalStorage.run() 外的函数获取不到同样的 store 对象
return ReflectApply(callback, null, args);
} finally {
// 执行完 callback 后再恢复旧的 store
resource[this.kResourceStore] = oldStore;
}
}
}
在 run 函数中先执行了 executionAsyncResource 再执行 callback,这个函数作用其实就是通过 async_hook_fields 获取栈的指针(下标),通过当前指针去 execution_async_resources 中拿到具体的 resource。
JavaScript
function executionAsyncResource() {
// 向 native 层表明这个函数可能会被使用,在这种情况下它将通过上面的 trampoline 通知 js 当前的异步资源
async_hook_fields[kUsesExecutionAsyncResource] = 1;
// 获取当前指向栈的下标
const index = async_hook_fields[kStackLength] - 1;
if (index === -1) return topLevelResource;
// 取出对应的异步资源
// executionAsyncResource_ 对应 C++ 层的 ExecutionAsyncResource
const resource = execution_async_resources[index] ||
executionAsyncResource_(index);
return lookupPublicResource(resource);
}
上面代码中会执行到 execution_async_resources[index] || executionAsyncResource_(index);
,此时的 execution_async_resources
是个空数组,就执行到 C++ 层的 executionAsyncResource_
,结合外面的 resource[this.kResourceStore] = store;
说明首次 run 传入的 store 是存在 native_execution_async_resources_
对象中 ,代码如下所示:
C++
void AsyncWrap::ExecutionAsyncResource(
const FunctionCallbackInfo<Value> & args) {
Environment* env = Environment::GetCurrent(args);
uint32_t index;
if (!args[0]->Uint32Value(env->context()).To(&index)) return;
args.GetReturnValue().Set(
env->async_hooks()->native_execution_async_resource(index));
}
// 通过 key 去 native_execution_async_resources_ 拿到对应异步资源,注意它是 Object 而不是 Array
v8::Local<v8::Object> AsyncHooks::native_execution_async_resource(size_t i) {
if (i >= native_execution_async_resources_.size()) return {};
return native_execution_async_resources_[i];
}
所以我们知道在 asyncLocalStorage.run 的代码逻辑是:获取当前异步资源 -> 保存异步资源对应的 old store -> 赋值新 store 至当前异步资源 -> 执行完 callback -> 恢复成旧异步资源
javascript
class AsyncLocalStorage {
run(store, callback, ...args) {
// 获取当前异步资源
const resource = executionAsyncResource();
// 保存旧 store
const oldStore = resource[this.kResourceStore];
resource[this.kResourceStore] = store;
try {
// 先执行 callback,执行完后再恢复旧的 store
// 这也解释了为什么在 asyncLocalStorage.run() 外的函数获取不到同样的 store 对象
return ReflectApply(callback, null, args);
} finally {
resource[this.kResourceStore] = oldStore;
}
}
说明在执行 setTimeout 时必定挂载了某些属性才能和当前异步资源有关联,在 setTimeout 前打个 debugger 后,可发现它的逻辑如下图所示:
javascript
class Timeout {
constructor(callback, after, args, isRepeat, isRefed) {
// .... 省略代码
// 将 this 作为异步资源传进去
initAsyncResource(this, 'Timeout');
}
}
function initAsyncResource(resource, type) {
// 自增全局 asyncId,并赋值到异步资源上,也就是上面传入的 Timeout 实例
const asyncId = resource[async_id_symbol] = newAsyncId();
// 获取默认触发 asyncId,也就是上一个异步资源的 id
const triggerAsyncId =
resource[trigger_async_id_symbol] = getDefaultTriggerAsyncId();
if (initHooksExist())
emitInit(asyncId, type, triggerAsyncId, resource);
}
function emitInitScript(asyncId, type, triggerAsyncId, resource) {
if (!hasHooks(kInit))
return;
if (triggerAsyncId === null) {
triggerAsyncId = getDefaultTriggerAsyncId();
}
// 触发 asyncLocalStorage._enable 创建下的 init 钩子函数
emitInitNative(asyncId, type, triggerAsyncId, resource);
}
function emitInitNative(asyncId, type, triggerAsyncId, resource) {
// .... 省略代码
resource = lookupPublicResource(resource);
for (var i = 0; i < active_hooks.array.length; i++) {
if (typeof active_hooks.array[i][init_symbol] === 'function') {
// 触发 asyncLocalStorage._propagate 将 store 赋值到 Timeout 实例
active_hooks.array[i][init_symbol](
asyncId, type, triggerAsyncId,
resource,
);
}
}
}
从下面断点调试的图可以看出来,即将在运行 _propagate(resource, currenResource, type),此时参数依次是:
- resource:运行 setTimeout 时 new Timeout 的实例
- currenResource:带有 store 的异步资源,当前资源指向 asyncLocalStorage.run
- type:字符串 'Timeout'
至此,setTimeout 对应的异步资源和 asyncLocalStorage.run 域下的异步资源已经建立了联系,用一个流程图小结下:
执行 setTimeout 回调
setTimeout 的回调会被放入 libuv 的事件循环中,然后继续执行后续的代码。当定时器到期时,libuv 会将回调函数放入事件队列中,在下一个事件循环时会执行 processTimers 函数,取出 timer,也就是在 setTimeout 运行时初始化的 Timeout 实例,上面还挂着 async_id、triggerId 和 Store,且在执行正式回调前会先执行 emitBefore,代码如下所示:
JavaScript
function emitBeforeScript(asyncId, triggerAsyncId, resource) {
// 将当前 async_id 和 trigger_async_id 存储到 async_id_stack
// 将当前资源推入 execution_async_resources
pushAsyncContext(asyncId, triggerAsyncId, resource);
if (hasHooks(kBefore))
emitBeforeNative(asyncId);
}
可以看到 emitBeforeScript 会将执行 pushAsyncContext,获取栈指针 kStackLength 的位置,将 当前 resource(指 timer) 推入 execution_async_resources ,因为在 new Timeout 时会将回调挂载到 this._onTimeout,所以执行到 timer._onTimeout() 时,真正进入函数体,此时运行到 asyncLocalStorage.getStore 时再次拿到刚才的 栈指针 kStackLength 位置,通过 execution_async_resources 拿到预期的 store。
当然在执行完函数本体后会继续执行后面的钩子并传入当前 asyncId,如:emitDestroy、emitAfter,用来恢复当前异步执行上下文对应的 asyncId 等等。
小结
上面的例子虽然较为简单,只有 asyncLocalStorage.run 和 setTimeout 搭配使用,但麻雀虽小五脏俱全,其实走了大部分异步资源(如 http、net、dns)都会走的流程,简单来说,每个异步资源在创建的时候都会触发 createHook 下的五个钩子函数,每个钩子会做不同的事,比如上述例子中:
- asyncLocalStorage.run 订阅 kInit,并将当前异步资源赋值到新创建的异步资源
- 执行 emitBeforeScript 时会自动将当前异步资源信息更新到 async_id_fields、execution_async_resources 等等
简而言之,async_hook 模块就是通过栈指针 kStackLength 来获取指定异步资源、及时更新当前异步执行信息 async_id_fields 和 async_ids_stack,从而做到"隔空"传递上下文的效果。所以看起来类似 Thread Local Storage 机制,但异步资源可以多层嵌套以及动态生成和销毁,最终实现的逻辑会比 TLS 复杂一些。
实际场景效果
下面通过两个在 Koa 中间件传递参数的例子,对比 AsyncLocalStorage 和传统方法传递上下文的区别:
下篇预告
在实际业务场景中,例如将多个异步资源的上下文串联起来,一般是 AsyncLocalStorage 和 AsyncResource 配合使用才是最佳实践,而且本篇文章中没有仔细提到 async_id_fields、async_ids_stack 和 栈指针 kStackLength 之间是如何关联,我将在下一篇文章《剖析 AsyncResource》继续解读 async_hook 模块。