引子
我们都知道,Nodejs 最显著特点是单进程、异步、事件驱动。每当我们的代码碰到异步调用时,需要传入一个回调函数,等待异步调用结束时再被执行。一个典型的处理用户登录流程如下:
图1注:图中数字标号表示事件发生时间顺序
但是,真实的线上环境,往往多个用户同时在登录,真实的场景如下:
图2
上图假设数据库操作最为耗时,当用户 A 请求到达后,在等待数据库查询比对用户密码时,用户 B 发起了登录请求。让我们稍微修改下上图,加上时间轴:
图3
假设用户的一次请求作为一个独立上下文,那么上图一共发生了三次上下文的切换:
- 第一次:事件 2 和事件 3,从用户 A 切换到了用户 B
- 第二次:事件 4 和事件 5,从用户 B 切换到了用户 A
- 第三次:事件 5 和事件 6,从用户 A 再次切换到用户 B
在类似 Express、Egg 等 Web 框架,是如何帮助我们识别上下文切换的?
- Express 所有的请求都封装为一个 Request 对象,作为回调函数的第一个参数
- Egg 则是封装了一个 Context 对象,每次请求都是新的 Context。
这些方式的特点,都是先显式创建一个局部变量,后续的业务逻辑代码,都会在函数调用中层层传递这个变量,便于识别上下文信息。那有没有办法,隐式的传递上下文信息?
隐式传递上下文
结合前面的案例,在 Node.js 中同步代码不会导致用户上下文切换,只有当发生异步回调时,才有可能发生请求上下文的切换。所以要想隐式传递上下文,首先要做的就是能自动识别出异步回调。Node.js 8.1 版本开始支持监听异步调用:AsyncHooks API。
AsyncHooks
AsyncHooks 核心提供了四个钩子:init、before、after、destory
- init 每次异步调用都会触发,执行时间点是异步请求的资源准备完毕时。
- before 执行异步回调函数前调用
- after 执行异步回调函数后调用
- destory 异步调用关联的资源被销毁时调用
补充两个例子说明:
-
fs.open
打开文件操作,执行时间点为所请求的文件资源准备完毕时调用 -
net.createServer
init 会在端口监听成功时执行,而 before 则会在每次有新请求,触发createServer
中的回调函数执行前,调用
四个钩子覆盖了一次异步调用的整个生命周期。除此之外还提供了两个关键 ID:triggerAsyncId
和 asyncId
。这两个 ID 可以表达两次异步调用之间的"父子"关系。
追踪上下文
结合 AsyncHooks,将上文的案例,改成 triggerAsyncId 和 asyncId 的关系图,如下:
每次异步调用,其 triggerAsyncId 和 asyncId 值类似如下(按上图事件顺序):
- (1) 用户 A 发起 Http Request , triggerAsyncId: 0, asyncId 1
- (2) 用户 A 发起 Database Query,triggerAsyncId: 1, asyncId 2
- (5) 用户 A 发起 Write Session,triggerAsyncId 2, asyncId 5
- (3) 用户 B 发起 Http Request,triggerAsyncId: 0, asyncId 3
- (4) 用户 B 发起 Database Query, triggerAsyncId 3, asyncId 4
- (6) 用户 B 发起 Write Sessiony, triggerAsyncId 4, asyncId 6
注:此处对系统发生真实异步调用进行了简化
通过 Hooks 以及 triggerAsyncId 和 asyncId 的关系,我们就可以找回每次异步调用发生时,该调用所属的上下文。相关的完整实现,可以参考 cls-hooked。
为什么没有普遍应用?
佛瑞德·布鲁克斯早就告诉我们软件工程没有银弹,之所以这种方式没有普及,个人理解主要以下几点:
- 隐式传递,代码可读性更差,不利于维护
- AsyncHooks 会有比较大的性能损耗,详见async-hooks-performance-impact,目前 API 稳定性还处于试验阶段
结束语
本文仅是从一个简单场景:异步上下文出发,引出了 AsyncHook 相关 API 的功能与基本使用。实际应用中,很多 APM 类程序都使用类似的能力。本文并没有对相关实现做深入讨论,希望通过此文,先介绍相关概念。