likes
comments
collection
share

Nodejs 监控系列:剖析 AsyncLocalStorage

作者站长头像
站长
· 阅读数 15

前言

在现代服务端架构中,为了更好抽象组件和迭代,首先会将服务细分成底层服务(如 redis、mysql、MQ)、业务服务,业务服务可能会再细分成中台服务(账号权限中心)以及 BFF 等等。一个接口发起,经过数个服务后再响应至前端,全链路监控的作用是详细展示该接口在不同服务之间的耗时。

由于能看到接口的每个阶段耗时,开发者可以轻而易举找出为什么请求耗时长或请求失败的关键阶段,进而提升开发效率以及降低事故的 MTTR。正因为 Tracing 如此重要,在 Nodejs 中有个专门为它而生的模块 async_hook。

API 研读

以下所有 Demo 代码的运行环境基于 nodejs 20.9.0

接下来有请主角:AsyncHook,不,由于官网公告说明 createHook 和 executionAsyncResource 暂时不推荐使用,可能会有安全和性能风险,用 AsyncLocalStorage ****代替。

Nodejs 监控系列:剖析 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 监控系列:剖析 AsyncLocalStorage

类推过来,nodejs 中需要用这种方式来解决异步上下文传递,关键字从“线程”变成“异步”,由此引出 AsyncId(异步 Id)。

AsyncId & Async Scope

在 nodejs 引入 async_hook 模块后,每一个函数(不论异步还是同步)都会提供一个上下文, 称之为 Async Scope。相对应有个 API:asyncResource.runInAsyncScope() ****,即在同一个 Async Scope 下执行

AsyncId 则是每个 Async Scope 的标志,也是全局唯一标识符,并在每个异步资源在创建时, AsyncId 自动递增。如下例子所示:

Nodejs 监控系列:剖析 AsyncLocalStorage

猜测:那是不是类似 Thread Local Storage 一样,在全局有张映射表,每个异步 Id 都其相对应的独立对象?(答案在小结中)

Store

Store 在这里类似于 redux 中的“store”,表示某个异步上下文的存储器,注意关键字“某个”,说明不同异步上下文的存储器是隔离的。它来自 asyncLocalStorage.getStore,主要特点是在串联多个异步资源参数时可以更优雅,无需层层透传。先看个例子:

Nodejs 监控系列:剖析 AsyncLocalStorage

可以看出,即使在 setTimeout 回调函数中获取 id,获取到的值依然是 run 时传入的参数 { id: 1 },可以把 setTimeout 想象成与 socket.connect 或 http.listen 一样的异步资源,也就是说只要在同样的 Async Scope 下,不用显式透传参数就能拿到预期中相同的对象。

看起来很奇妙对不对!为什么没有透传参数的前提下能做到不同函数域获取相同的上下文?接下来就层层剥开 AsyncId 和 Async Scope 神秘面纱。

源码解读

为了更好分析其底层原理,将上面的例子简化了下,如下所示,我们就来讲讲 nodejs 是怎么做到“隔空”传递参数

Nodejs 监控系列:剖析 AsyncLocalStorage

属性

在深入具体的 API 前,先来熟悉下 async_hook 的基本属性。当阅读 nodejs 仓库下的 /lib/async_hook.js 时,会发现某些属性例如 kExecutionAsyncId,这种通过 internalBinding 来获取在 C++ 层定义的数据,会有一个 .d.ts 文件,与 src/env.h 想吻合,如下所示:

Nodejs 监控系列:剖析 AsyncLocalStorage

这些属性的定义对后续的分析极有帮助,先大概熟悉下:

// 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,他们之间具体联系如下所示:

Nodejs 监控系列:剖析 AsyncLocalStorage

Timeout 实例与 store 建立联系

我们先在 callback 下的 getStore 打个断点,发现 execution_async_resources 这个数组变量的第一个值是个 Timeout 对象,并且挂载着 Symbol(kResourceStore): { id: 1},如下图所示:

Nodejs 监控系列:剖析 AsyncLocalStorage

还记得在开头说过:每个函数(不管是同步还是异步)都有自己的 Async Scope。当前这个 Timeout 对象以及身上挂载的属性就是 Async Scope 的标志位。

全局搜索下,只有在 pushAsyncContext 这个函数中最有可能被推入数据,如下所示:

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 也是如此操作,赋值操作示意图如下所示:

Nodejs 监控系列:剖析 AsyncLocalStorage

pushAsyncContext 再往上回溯是被 emitBeforeScript 调用,emitBeforeScript 被 promiseBeforeHook 调用,最终发现源头是在 asyncLocalStorage.run,具体代码如下所示:

// 创建异步资源的钩子函数,每次有新异步资源被创建时都会先执行一遍钩子函数
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。

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_ 对象中 ,代码如下所示:

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 -> 恢复成旧异步资源

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 后,可发现它的逻辑如下图所示:

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'

Nodejs 监控系列:剖析 AsyncLocalStorage

至此,setTimeout 对应的异步资源和 asyncLocalStorage.run 域下的异步资源已经建立了联系,用一个流程图小结下:

Nodejs 监控系列:剖析 AsyncLocalStorage

执行 setTimeout 回调

setTimeout 的回调会被放入 libuv 的事件循环中,然后继续执行后续的代码。当定时器到期时,libuv 会将回调函数放入事件队列中,在下一个事件循环时会执行 processTimers 函数,取出 timer,也就是在 setTimeout 运行时初始化的 Timeout 实例,上面还挂着 async_id、triggerId 和 Store,且在执行正式回调前会先执行 emitBefore,代码如下所示:

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);
}

Nodejs 监控系列:剖析 AsyncLocalStorage

可以看到 emitBeforeScript 会将执行 pushAsyncContext,获取栈指针 kStackLength 的位置,将 当前 resource(指 timer) 推入 execution_async_resources ,因为在 new Timeout 时会将回调挂载到 this._onTimeout,所以执行到 timer._onTimeout() 时,真正进入函数体,此时运行到 asyncLocalStorage.getStore 时再次拿到刚才的 栈指针 kStackLength 位置,通过 execution_async_resources 拿到预期的 store。

Nodejs 监控系列:剖析 AsyncLocalStorage

当然在执行完函数本体后会继续执行后面的钩子并传入当前 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 和传统方法传递上下文的区别:

Nodejs 监控系列:剖析 AsyncLocalStorage Nodejs 监控系列:剖析 AsyncLocalStorage

下篇预告

在实际业务场景中,例如将多个异步资源的上下文串联起来,一般是 AsyncLocalStorage 和 AsyncResource 配合使用才是最佳实践,而且本篇文章中没有仔细提到 async_id_fields、async_ids_stack 和 栈指针 kStackLength 之间是如何关联,我将在下一篇文章《剖析 AsyncResource》继续解读 async_hook 模块。