Node 中的 AsyncLocalStorage 的前世今生和未来 - 阿里技术
作者系统详实的介绍了什么是AsyncLocalStorage、如何使用、Node 是如何实现的 AsyncHook等。不论你是0基础还是对此API有些了解的读者都比较适合阅读此文。(文末有活动)
一、背景
你好!非常高兴你能看到这篇文章,在开始前我将大致介绍两点,关于适合谁看和能了解到什么。
适合谁看
类型人群 | 推荐指数 |
---|---|
完全不了解Node,但有兴趣看看 | 适合,有点门槛,会尽量照顾到 |
写过Node Server,但没有用过了解过这个API | 很适合,交流学习的机会 |
用过了解过此API,但不知道他的前世今生 | 非常适合,你看标题啊 |
非常了解此API甚至提过相关PR | 非常适合! |
Takeaway
如果你有耐心看完本篇文章,你将了解到什么:
- 什么是 AsyncLocalStorage ?一般什么时候使用它?如何使用它?
- 没有 AsyncLocalStorage 这个 API 之前的时代是怎么解决异步存储的?大概的原理是什么?
- 了解广义上的 Async Local Storage 是如何一步一步发展过来的?(即合订本)
- AsyncLocalStorage 与最新的阿里巴巴主导的 TC39 提案 AsyncContext 之间是什么关系?
- 其他语言中类似的方法是怎么用的?
- Node 是如何实现的 AsyncHook?
二、开门见山:什么是 AsyncLocalStorage
一个案例引入
当一个 Request 通过层层关卡打到我们的 Server,一般会产生多个系统的日志,包括但不限于:
- 访问日志
- 异常日志
- SQL日志
- 第三方服务日志等
而当发生了线上问题的时候,需要进行溯源排查。
一般的做法是在请求之处,生成一个 unique traceId,此 id 在所有日志中携带就可以追踪该请求的所有链路,这就是所谓的全链路日志追踪。
好的,那么在 Node Server 中如何让一个请求的上下游调用都带上这个 traceId 呢,我们下面给几个方案。
(先不管这个 id 是 server 自己生成的还是 client 带上来的)
方案1:全局变量
简单,先拍脑袋想一个方法,全局变量 globalTraceId !
因为 closure 闭包的特性,会帮我们close住全局变量的引用。
所以可以在任何异步调用中,读取这个变量所指的内存里的值,然后传给日志服务即可:(以 raw Node.js 为例)
// Raw Node.js HTTP server
const http = require('http');
let globalTraceId // 全局变量
// 0. 处理请求的方法
function handleRequest(req, res) {
// 生成唯一 traceId,每次请求进入,复写 globalTraceId 的值
globalTraceId = generateTraceId()
// 检查用户cookie是否有效
cookieValidator().then((res) => {
// 校验成功,返回给用户他需要的内容
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.write('Congrats! Your damn cookie is the best one!');
res.end();
}).catch((err) => {
// 把 traceId 连同 error 上报给异常监控系统
reportError(err, globalTraceId)
// 写状态码500和错误信息等
// ...
});
}
// 1. 创建 server
const server = http.createServer((req, res) => {
handleRequest(req, res)
});
// 2. 让 server 和 port:3000 建立 socket 链接,持续接收端口信息
server.listen(3000, () => {
console.log('Server listening on port 3000');
});
但是在 Node.js 是单线程(主线程是单线程), globalTraceId 这样全局变量,在第一个请求异步校验 cookie 的过程中,因为 main stack 已空,所以从 backlog 里面调入第二个请求进入主线程。
而 globalTraceId 会被第二个请求复写,导致第一个请求在错误上报的时候不能拿到正确的 id
方案2:直接透传参数
那上面全局变量的方法确实不对,那你会想,那我把生成的 traceId 作为参数一步步透传,是不是也可以达到每个请求同一个 traceId 的效果,好像好多库和框架就是这么做的。
是的,我们来看下直接透传参数是怎么做的。
const http = require('http');
function handleRequest(req, res) {
const traceId = req.headers['x-trace-id'] || generateTraceId();
// 把 traceId 写入 req 这个 object,将参数一路带下去
req.traceId = traceId;
// 同上
cookieValidator().then((result) => {
// 校验成功,返回给用户他需要的内容
// ...
}).catch((err) => {
// 上报 traceId
reportError(err, req.traceId)
// 写状态码500和错误信息等
// ...
});
}
function cookieValidator() {
return new Promise((resolve, reject) => {
setTimeout(() => {
// do someting
// ...
}, 1000);
});
}
// 此后省略监听等操作
// ...
能够看出来,把 traceId 通过 req 这个 object 一路传下去。能传下去的原因是 node 异步调用的时候,会创建一个新的 context(上下文),把当前调用栈、local variable、referenced global variable 存下来,一直到请求返回再在存下来的 context 中继续执行。
所以所谓的直接透传参数,就是通过 local variable 被存到了 async function call context 里面而完成了 traceId 在一次请求里面一路传递。
常见的 Node 库如何处理 traceId 的?
细心的你已经发现,我们最常用 express 或者 koa 这样的库的时候,不就是这样干的嘛。那我们来举几个常用的库的例子
Express.js
Express 是最早流行的Node server库,到现在依然很流行,实现了路由、中间件等各种功能。
下面看下 Express 是如何传递 TraceId 的
// Via express
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { reportError } = require('./error-logging');
const app = express();
// 中间件
app.use((req, res, next) => {
const traceId = uuidv4(); // generate a new UUID for the trace ID
req.traceId = traceId; // attach the trace ID to the request object
next();
});
// 设置路由
app.get('/', async (req, res, next) => {
const traceId = req.traceId;
try {
// call an asynchronous function and pass along the trace ID
const result = await someAsyncFunction(traceId);
// do something with the result
res.send(result);
} catch (error) {
// log the error and trace ID to the error logging system
reportError(error, { traceId });
next(error);
}
});
// 监听端口
// ...
Koa.js
Koa 也是社区非常流行的库
Koa 早期使用 yield 语法,后期支持了 await 语法。我们熟悉的 egg.js是基于 Koa 封装的Node Server框架。现在淘宝的 midwayjs 最早也是基于egg.js做的。
好的,辈分关系梳理完了,我们来看下在 Koa 中是如何透传参数的。
const Koa = require('koa');
const { v4: uuidv4 } = require('uuid');
const { reportError } = require('./error-logging');
const app = new Koa();
// 中间件A
app.use(async (ctx, next) => {
const traceId = uuidv4(); // generate a new UUID for the trace ID
ctx.state.traceId = traceId; // store the trace ID in the Koa context object
try {
await next();
} catch (error) {
// log the error and trace ID to the error logging system
reportError(error, { traceId });
throw error;
}
});
// 中间件B,通过 ctx 透传 traceId
app.use(async (ctx) => {
const traceId = ctx.state.traceId;
// call an asynchronous function and pass along the trace ID
const result = await someAsyncFunction(traceId);
// do something with the result
ctx.body = result;
});
// 监听端口
// ...
从上面的代码几乎和 express 一样,也是通过把 tracId 存到一路透传的 ctx 变量里面实现参数的透传。
Nest.js
Nest (NestJS) 是一个用于构建高效、可扩展的 Node.js 服务器端应用程序的开发框架。它利用 JavaScript 的渐进增强的能力,使用并完全支持 TypeScript (仍然允许开发者使用纯 JavaScript 进行开发),并结合了 OOP (面向对象编程)、FP (函数式编程)和 FRP (函数响应式编程)。
总结来说 Nest 的特点是,完美支持ts、拥抱装饰器和注解,同时通过依赖注入(DI)和模块化思想等,使代码结构工整,易于阅读。更多介绍可以看官网。
在官方文档上,Nest 是推荐如何使用 Async Local Storage 的,可以见这篇文章,代码如下:
https://docs.nestjs.com/recipes/async-local-storage#nestjs-cls
// 使用 nestjs-cls这个库
// npm i nestjs-cls
// 模块初始化的时候,申明 Cls Module
@Module({
imports: [
// Register the ClsModule,
ClsModule.forRoot({
middleware: {
// automatically mount the
// ClsMiddleware for all routes
mount: true,
// and use the setup method to
// provide default store values.
setup: (cls, req) => {
// 通过CLS存储 traceId
cls.set('traceId', req.headers['x-trace-id'] || generateTraceId());
},
},
}),
],
providers: [CatService],
controllers: [CatController],
})
export class AppModule {}
// 在 Service 中注册 Cls,并且直接调用
@Injectable()
export class CatService {
constructor(
// We can inject the provided ClsService instance,
private readonly cls: ClsService,
private readonly catRepository: CatRepository,
) {}
getCatForUser() {
// and use the "get" method to retrieve any stored value.
const userId = this.cls.get('traceId'); // 获得 traceId
return this.catRepository.getForUser(userId);
}
}
上面的代码我们可以看到,Nest 和上面的库肉眼上的不同,是采用了依赖注入的方式进行注册,同时大量使用装饰器的方法。
如果对依赖注入有兴趣可以看这篇文章,完成了IOC的一个简单的实现。
https://zhuanlan.zhihu.com/p/311184005
OK,那么 nestjs-cls这个库做了什么?我们来看这个包的描述
The nestjs-cls package provides several DX improvements over using plain AsyncLocalStorage (CLS is an abbreviation of the term continuation-local storage).
DX 是 Developer Experience 即开发者体验。所以这个库是用于提升开发者体验的基于原生 AsyncLocalStorage 的包,所以,下面终于介绍到了我们今天的主角 AsyncLocalStorage !
nestjs-cls 直接引用了 AsyncLocalStorage
方案3:今天的角,
AsyncLocalStorage
AsyncLocalStorage 是 Nodejs 的 API(大约在2019年推出,2020年进入稳定版本)
简单来说,就是 Node.js 觉得大家的方法都不够优雅,哥直接在C++的层面给你们做掉这个事,然后提供个API给大伙用,怎么样?嗯大概就是这样。
前面已经说了,这是官方的API,自然有官方的文档来描述
官方文档:
This class creates stores that stay coherent through asynchronous operations.
While you can create your own implementation on top of the node:async_hooks module, AsyncLocalStorage should be preferred as it is a performant and memory safe implementation that involves significant optimizations that are non-obvious to implement.
The following example uses AsyncLocalStorage to build a simple logger that assigns IDs to incoming HTTP requests and includes them in messages logged within each request.
文档地址:https://nodejs.org/api/async_context.html#class-asynclocalstorage
中文解释简单来说就是: AsyncLocalStorage 是基于node:async_hooks实现的,并且(比之其他方法)是性能好、内存安全的方法去存储用于 log 的信息。
我们来看个例子 AsyncLocalStorage 是怎么使用的
// How to use AsyncLocalStorage in Node.js
import http from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';
const asyncLocalStorage = new AsyncLocalStorage();
function logWithId(msg) {
const traceId = asyncLocalStorage.getStore();
console.log(`${traceId}:`, msg);
}
let traceId = 0;
http.createServer((req, res) => {
// 关键的API调用
asyncLocalStorage.run(traceId++, () => {
logWithId('start');
// Imagine any chain of async operations here
setImmediate(() => {
logWithId('finish');
res.end();
});
});
}).listen(8080);
http.get('http://localhost:8080');
http.get('http://localhost:8080');
// Prints:
// 0: start
// 1: start
// 0: finish
// 1: finish
下面是这段代码的解释:
上面展示了2个请求被打到port:8080,异步调用被放在了asyncLocalStorage.run这个API里面。 setImmediate 异步调用的时候,通过 logWithId 取出该请求在进入时被赋予的 id 。可以看到,即使第二个请求的id(第一个请求进来的时候idSeq=0,第二个请求进来时idSeq=1)已经被log出来了,但是 line 8 的 同一个 asyncLocalStorage 却能 getStore 出对应每个请求的不同id。
所以此方法,比通过函数内部变量的方式存储 id 优雅得多(另外,通过 asyncLocalStorage 这样隐式传递参数有点像函数式编程中的 State Monad 帮助开发者隐式管理参数传递)
小结
讲到这,我们 Takeaway 中的1已经被解决了
- 什么是 AsyncLocalStorage ?什么时候使用它?如何使用它?
如果认真看完,相信你对它已经有了个感性的认识。那么接下来,我们来看标题的后一句话, AsyncLocalStorage 的前世今生
三、历史合订:在 Node.js 14 之前的 Async Local Storage
忘记历史就意味着背叛(狗头),所以我们来看看历史的合订本。
既然大家都有参数异步传递的需求,所以等19年AsyncLocalStorage被推出之前一定有社区方案被大家使用,所以我们来看下在 Node.js 14发布之前,社区是如何处理的。
这里我按照时间线来梳理此事,那要从2013年的春天说起
2013年:CLS横空出世(1.1k Star)
2013年4月30号,node-continuation-local-storage 这个仓库提交了第一个 commit ,而这个仓库,社区更多地称它为CLS(Continuation-Local Storage,之后简称为CLS)。我截取了这个10岁的仓库README中的第一段给大家。
Continuation-local storage works like thread-local storage in threaded programming, but is based on chains of Node-style callbacks instead of threads. The standard Node convention of functions calling functions is very similar to something called "continuation-passing style" in functional programming, and the name comes from the way this module allows you to set and get values that are scoped to the lifetime of these chains of function calls.
这段话可以提取出几个信息:
- CLS 像多线程编程中的独立线程的 storage(TLS: thread local storage)一样工作,只是原理是基于 callback function 而不是线程
- 取名中有 Continuation 代表 C,是因为类似于函数编程中的 "continuation-passing style" 概念,旨在链式函数调用过程中维护一个持久的数据
- 你 set 和 get 的值,是在这些异步的function的整个生命周期的调用链内的
如何使用
下面是 github 上该仓库的示例
const express = require('express');
const cls = require('continuation-local-storage'); // require('cls-hooked') 也行,后面会提到
const app = express();
// Create a new namespace for the traceId
const traceNamespace = cls.createNamespace('trace');
// Middleware to set the traceId for each request
app.use((req, res, next) => {
traceNamespace.run(() => {
// Generate a new traceId if one doesn't exist
traceNamespace.set('traceId', generateTraceId());
next();
});
});
// Route to get the traceId for the current request
app.get('/traceId', async (req, res) => {
try {
const cookie = await cookieValidator()
// 校验是否成功等
// ...
} catch(e) {
// 上报 traceId
const traceId = traceNamespace.get('traceId');
reportError(err, traceId)
}
res.send(`Trace ID: ${traceId}`);
});
每次执行 namespace.run(callback) 都会生成一个上下文。语法上,通过 run 方法,包住一个回调函数,在这个回调内可以访问到我们的 Continuation-Local Storage。这个xxx.run(callbakc, …)的语法之后我们会多次看到。
实现原理
CLS 通过 process.addAsyncListener 这个 API 监听异步事件。在创建异步事件的时候将当前上下文传入,执行异步回调时,传入上下文,异步事件执行结束销毁上下文。而process.addAsyncListener是 Node v0.11 版本的 API,目前仓库引用的是 polyfill 的方法。
从下面截图中可以看下,这是段 polyfill 的代码是2013年9月2号被提交的,是相当古早的事情了。
// load polyfill if native support is unavailable
if (!process.addAsyncListener) require('async-listener');
通过 async call 的事件,可以写出一个方法来存储我们在每个异步调用中的需要存储的变量。所以,这里还是用的一个局部变量来存储当前异步调用的上下文;同时在全局变量里面,维护了一个类似于栈的结构,通过此数据结构完成了 nest 的功能,即嵌套调用,run里面在嵌入一个run。不得不说,栈的结构非常适合做调用场景,因为 main call stack 就是一个栈 : )
我们来看下具体的代码实现:https://github.com/othiym23/node-continuation-local-storage/blob/master/context.js
// createNamespace 就是调用内部的 create 方法
function create(name) {
assert.ok(name, "namespace must be given a name!");
var namespace = new Namespace(name); // 新建 space
namespace.id = process.addAsyncListener({
create : function () { return namespace.active; },
before : function (context, storage) { if (storage) namespace.enter(storage); },
after : function (context, storage) { if (storage) namespace.exit(storage); },
error : function (storage) { if (storage) namespace.exit(storage); }
});
process.namespaces[name] = namespace;
return namespace;
}
在 create 这个方法中,我们会新建一个 Namespace 来管理所有的方法,此 name 会在原生API上监听各种事件,同时触发我们的 store 变化。其中namespace.enter(storage)表示将此时的 ctx 入栈,在async call before的时候调用,即完成异步时间后、开始执行回调函数之前。而在async call after时,则是调用出栈方法 namespace.exit(storage)。
这个过程中,传入的参数 storage,就是我们在 store 中存入的 traceId
// cls的实现
// 这是 store 全局变量的 class
function Namespace(name) {
this.name = name;
// changed in 2.7: no default context
this.active = null;
this._set = [];
this.id = null;
}
// run方法
Namespace.prototype.run = function (fn) {
var context = this.createContext();
this.enter(context);
try {
fn(context);
return context;
}
catch (exception) {
if (exception) {
exception[ERROR_SYMBOL] = context;
}
throw exception;
}
finally {
this.exit(context);
}
};
// 当前的 active 入栈,把新的 ctx 当做 this.active
Namespace.prototype.enter = function (context) {
assert.ok(context, "context must be provided for entering");
this._set.push(this.active);
this.active = context;
};
上面的 this._set就是刚才说的被维护的栈的结构。每一次 run 的调用,会创建一个 context 作为 this.active,同时把当前的老的 context(this.active)给 push 进入 this._set 这个栈,等待后续被pop后调用。
后来介绍的cls-hooked逻辑和他差不多,但是实现更容易理解,把他把每个异步调用的上下文存到了一个全局变量new map(),然后通过全局唯一的为异步调用生成的asyncId 作为 key 来区分。不过为了嵌套能力,栈的结构依旧保留。
虽然这个仓库已经在"历史的垃圾堆"里了,但是里面 API 的设计和数据存储结构的还是值得一看,因为之后的实现也沿用的类似的设计。
那我们接下来,看下一个API的发布。
2017年:async_hooks
async_hooks不是一个三方库,而是一个Node build-in的module,供用户调用。
The async_hooks API was released in Node.js 8.x in 2017
如何使用
通过 hook 可以往 async call 的各个阶段注册方法,类似于我们熟悉的React生命周期。同时,每次异步初始化,都会生成一个独一无二的 asyncId ,所以基于此可以实现我们的异步监听
const asyncHooks = require('async-hooks')
const asyncHook = asyncHooks.createHook({
init: (asyncId, type, triggerAsyncId, resource) => {},
before: asyncId => {},
after: asyncId => {},
destroy: asyncId => {},
promiseResolve: asyncId => {},
})
asyncHook.enable();
// init() is called during object construction. The resource may not have
// completed construction when this callback runs. Therefore, all fields of the
// resource referenced by "asyncId" may not have been populated.
function init(asyncId, type, triggerAsyncId, resource) { }
// before() is called just before the resource's callback is called. It can be
// called 0-N times for handles (such as TCPWrap), and will be called exactly 1
// time for requests (such as FSReqCallback).
function before(asyncId) { }
// after() is called just after the resource's callback has finished.
function after(asyncId) { }
// destroy() is called when the resource is destroyed.
function destroy(asyncId) { }
实现原理
具体的讨论,可以看最后的延展章节,这里就不过多介绍了
2017年:cls-hooked
在2017年,async_hooks发布后,Jeff Lewis 这位老兄马不停蹄地将老仓库fork出来,重新用最新的 async_hooks 重写了 CLS。由于重写后 API 没有任何变化,就不再列举使用方法了。
下面我们来看看他的 README
This is a fork of CLS using AsyncWrap OR async_hooks instead of async-listener.
When running Nodejs version < 8, this module uses AsyncWrap which is an unsupported Nodejs API, so please consider the risk before using it.
When running Nodejs version >= 8.2.1, this module uses the newer async_hooks API which is considered Experimental by Nodejs.
从他的 README 可以看到,Node版本小于8的,使用了 AsyncWrap,而Node版本大于8.2.1的则用async_hooks重写了。
值得注意的是,他用 Experimental 来描述此API,自然而然我们到 Nodejs 的官网可以看到,不再被推荐使用。原因是可用性、安全性、以及性能表现都有问题。
当你想使用 Async Context Tracking 的能力,取而代之的应该是 AsyncLocalStorage 。
其实因为是 ALS 做了适当的优化并且语法更简洁
Stability: 1 – Experimental. Please migrate away from this API, if you can. We do not recommend using the createHook, AsyncHook, and executionAsyncResource APIs as they have usability issues, safety risks, and performance implications. Async context tracking use cases are better served by the stable AsyncLocalStorage API.
Node文档中,API的稳定性(就是告诉你敢不敢用这个 API)有分级
即Stability index被分为了4档,分别是:
- Stability: 0 – Deprecated
- Stability: 1 – Experimental
- Stability: 2 – Stable
- Stability: 3 – Legacy
我们的 async_hooks 被归到了 Experimental,在未来的任何版本中都可能出现非向后兼容的变化或删除。不建议在生产环境中使用该功能。所以,任何线上环境都只使用 Statbility:2 – Stable 就好。
2019年:AsyncLocalStorage(ALS)千呼万唤始出来
AsyncLocalStorage was first introduced in Node.js version 12.0.0, released on April 23, 2019.
因为AsyncLocalStorage (ALS) 的需求强烈,所以在经过一系列的实验后,ALS终于在 Node v13.10.0 完整支持了,随后 API 迁移到了长期支持版本 Node v12.17.0
因为之前已经介绍过API的使用,也夸了不少了,下面我从其他角度来阐述下
性能问题
AsyncLocalStorage 直接基于 async_hooks 进行封装。而 async_hooks 对于性能有一定的影响,在高并发的场景,大家对此 API 保持了谨慎的态度。
图片来源:https://github.com/bmeurer/async-hooks-performance-impact
同时,Node 的 issue 里面也有大量对此的讨论,比如这个《AsyncLocalStorage kills 97% of performance in an async environment #34493》:https://github.com/nodejs/node/issues/34493
本来Node的性能就是他的短板(或者说这事因为他的特质所导致的),现在用上ALS后性能又变差了不少,自然会让人在生产环境对他敬而远之,所以怎么解决呢?
后续更新
这个时候老大哥 V8 出手了,我借给你一个 V8 的 API 用吧,可以直接监听我的 Promise lifecycle
这就是 v8::Context PromiseHook API。这个 Hook 被加入 V8 后,很快被 Stephen Belanger 加入到了 async_hooks
这是引入 PromiseHook 的 PR 地址:PR: async_hooks: use new v8::Context PromiseHook API #36394:https://github.com/nodejs/node/pull/36394
然后,从21年5月这个评论里面就能看出,https://github.com/nodejs/node/issues/34493#issuecomment-845094849,在V8的加持下,在 Node v16.2.0 的版本里,ALS的性能"大幅"提升。
小结
我们完成了2和3,同时拓展了些类似的场景和用法
- 没有 AsyncLocalStorage 这个 API 之前的时代是怎么解决异步存储的?大概的原理是什么?
- 了解广义上的 Async Local Storage 是如何一步一步发展过来的?(即合订本)
通过此章,我们其实可以按照时间轴画一张图出来
好的,这幅图已经阐述了大致的发展的历史。
而最后一个之前从未提及的东西,也引出了这篇文章被写出的原因 AsyncContext 。为什么这么说,最总结的时候说吧。
四、异枝同根:ALS 与最新 TC39 提案 AsyncContext 的关系
由阿里巴巴 TC39 代表主导的 Async Context 提案 刚在 2023年 2 月初的 TC39 会议中成为了 TC39 Stage 1 提案。提案的目标是定义在 JavaScript 的异步任务中传递数据的方案。
既然看到这里,大家也能很快明白新的 TC39 提案 AsyncContext 是在做什么。
对比两者的API,可以看到 AsyncContext 结合了 AsyncLocalStorage 和 AsyncResource ,并用了更通用的名字 context 来指代后两种方法的结合。
TC39提案: AsyncContext
class AsyncContext<T> {
// 快照当前执行上下文中所有 AsyncContext 实例的值,并返回一个函数。
// 当这个函数执行时,会将 AsyncContext 状态快照恢复为执行上下文的全局状态。
static wrap<R>(fn: (...args: any[]) => R): (...args: any[]) => R;
// 立刻执行 fn,并在 fn 执行期间将 value 设置为当前
// AsyncContext 实例的值。这个值会在 fn 过程中发起的异步操作中被
// 快照(相当于 wrap)。
run<R>(value: T, fn: () => R): R;
// 获取当前 AsyncContext 实例的值。
get(): T;
}
Node API: AsyncLocalStorage
class AsyncLocalStorage<T> {
constructor();
// 立刻执行 callback,并在 callback 执行期间设置异步局部变量值。
run<R>(store: T, callback: (...args: any[]) => R, ...args: any[]): R;
// 获取异步局部变量当前值
getStore(): T;
}
class AsyncResource {
// 快照当前的执行上下文异步局部变量全局状态。
constructor();
// 立刻执行 fn,并在 fn 执行期间将快照恢复为当前执行上下文异步局部变量全局状态。
runInAsyncScope<R>(fn: (...args: any[]) => R, thisArg, ...args: any[]): R;
}
我在这里回答下这章的标题,他们的关系是什么?
答案是
- AsyncLocalStorage是Node的API;不是标准,只是一个 runtime 的 API
- AsyncContext是EMACScript标准(如果通过);通过后将成为规范,具体实现由各种 runtime 配合 JS Engine 来支持
看来这比雷锋和雷锋塔的关系更近些。
另外,为了让 ECMA 标准能同时能兼容Node的API(因为标准和目前的API不一样,到时候又得改),所以吞吞老师的提案让 AsyncContext 的语法和 AsyncLocalStorage 非常接近。
五、它山之石:其他语言中管理多线程上下文的方法
大家可以了解下同行都是怎么做的,大家看看门道,我也看个热闹。
使用 ThreadLocal 来处理线程内的变量
public class TraceIdFilter implements Filter {
private static final String TRACE_ID_HEADER = "X-Trace-Id";
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String traceId = httpRequest.getHeader(TRACE_ID_HEADER);
if (traceId == null || traceId.isEmpty()) {
traceId = generateTraceId();
}
TRACE_ID.set(traceId);
try {
chain.doFilter(request, response);
} finally {
TRACE_ID.remove();
}
}
public static String getTraceId() {
return TRACE_ID.get();
}
private String generateTraceId() {
return UUID.randomUUID().toString();
}
// Other methods for initializing and destroying the filter...
}
C++
使用 thread_local 来处理本地线程变量
#include <iostream>
#include <thread>
thread_local int my_thread_local;
void my_thread_function() {
my_thread_local = std::hash<std::thread::id>()(std::this_thread::get_id());
std::cout << "My thread-local value is " << my_thread_local << std::endl;
}
int main() {
std::thread t1(my_thread_function);
std::thread t2(my_thread_function);
t1.join();
t2.join();
return 0;
}
Python
同上
my_thread_local = threading.local()
def my_thread_function():
my_thread_local.value = threading.get_ident()
print(f"My thread-local value is {my_thread_local.value}")
t1 = threading.Thread(target=my_thread_function)
t2 = threading.Thread(target=my_thread_function)
t1.start()
t2.start()
t1.join()
t2.join()
六、多走一步:AsyncLocalStorage 是如何实现的
下面两章,我们将多走俩步,介绍和讨论下这些 API 是如何实现的。如有错误,希望指正。
因为具体的一些细节会和 Node 的版本有强相关
所以特别声明:下面的文档、代码都以 Node v16.x LTS (Long-term Support) 中的文档和代码为例。
I Wonder Where the API Comes From
要想知道这个API是如何实现的。第一步,读文档
https://nodejs.org/docs/latest-v16.x/api/async_context.html#class-asynclocalstorage
很遗憾,文档并没有过多介绍这个API的实现,但是却透露了一个重要信息,source code 来自lib/async_hook.js
所以顺理成章,我们进入这个文件进行探索
探索之前,我先大概介绍下 nodejs 项目的目录结构
配置文件和构建文件咱先不看,咱们看最主要的三个文件夹 deps 、 lib 、 src
名字 | 说明 | 主要使用语言 |
---|---|---|
deps | 包含 Node.js 使用的第三方依赖项,包括 V8 引擎和 libuv 库等。简单理解就是 node_modules | C++/C |
lib | 包含 Node.js 核心库文件,包括用于处理 I/O 操作、网络和其他核心功能的模块。简单理解就是核心库的JS的封装,作为API提供给Nodejs使用。比如咱们熟悉的 require(http) 就是引用的 lib/http.js | Javascript |
src | 包含 Node.js 的 C++ 源代码,包括核心组件如事件循环、模块加载机制和 HTTP 服务器。C++核心模块 | C++ |
让我们一步步来梳理 AsyncLocalStorage API’s calling chain
Javascript Zone
OK到这一步,我们大概知道了的 AsyncLocalStorageAPI 来自哪里,接着我们打开 async_hooks.js文件。
// location: lib/async_hooks.js
// 1. 真正的储存位置
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);
}
}
});
function createHook(fns) {
return new AsyncHook(fns);
}
// 2. ALS Class 的实现
class AsyncLocalStorage {
constructor() {
this.kResourceStore = Symbol('kResourceStore');
this.enabled = false;
}
_enable() {
if (!this.enabled) {
this.enabled = true;
ArrayPrototypePush(storageList, this);
storageHook.enable();
}
}
run(store, callback, ...args) {
// Avoid creation of an AsyncResource if store is already active
if (ObjectIs(store, this.getStore())) {
return ReflectApply(callback, null, args);
}
this._enable();
// 新老 resource 交接班
const resource = executionAsyncResource(); // 新的resource
const oldStore = resource[this.kResourceStore]; // 老的resource
resource[this.kResourceStore] = store; // 新的resource,traceId存放的地方
try {
return ReflectApply(callback, null, args);
} finally {
resource[this.kResourceStore] = oldStore; // 等callback执行结束,将老的oldStore归还
}
}
getStore() {
if (this.enabled) {
const resource = executionAsyncResource();
return resource[this.kResourceStore];
}
}
}
为了便于阅读,上面的代码删去了不必要的部分。
当我们运行 AsyncLocalStorage.run(callback)的时候,会执行2个动作:
参照下面的API调用代码来看
- this._enable(),激活 hook 监听
- 通过 executionAsyncResource(),获得当前异步资源 resource (AsyncResource,每次异步调用,V8都会创建一个对应的AsyncResource)
- 然后把我们传入的 store 当做 resource 里 kResourceStore 对应的值(store就是traceId,kResourceStore就是一个Symbol而已)
- 然后才执行我们的callback代码ReflectApply(callback, null, args)。其中 ReflectApply 直接理解为JS中的Function.Apply()。
- 之后这个 run 方法里面,任何通过executionAsyncResource()得到的值都是我们👆🏻上面的 traceId
- 最后,我们通过getStore()拿到这个 traceId ,完美!
import { AsyncLocalStorage } from 'node:async_hooks';
const asyncLocalStorage = new AsyncLocalStorage();
let traceId = 0;
asyncLocalStorage.run(traceId++, () => {
console.log(asyncLocalStorage.getStore())
setImmediate(() => {
console.log(asyncLocalStorage.getStore())
})
});
asyncLocalStorage.run('test', () => {})
总的来说基于此,我们ALS.run()里面的callback同步请求都可以顺利拿到对应的 store ,但是异步的请求每次会新建 AsyncResource 。所以拿不到上面的 store
此时我们来看 storageHook 变量,他创建了一个 Hook 来监听 init 事件,在每个异步事件初始化的时候,把当前的异步资源(AsyncResource)的值传给我们初始化的异步调用,我们命名它为异步A。所以在不久的将来,异步A执行的时候,我们通过asyncLocalStorage.getStore()可以拿到正确的值
结论,ALS也是基于AsyncHook和神秘的 executionAsyncResource 实现的。但是他只使用了 init Hook,而且封装的很好,所以性能和可用性都更好。
所以不管怎么看 AsyncHook 都更可疑。所以下章我们来分析它是如何实现的,并且可以监听劫持任何一种异步操作的生命周期。
另外,这个run方法 里面又是一个类似栈的结构,只不过实现的形式是通过类似于递归调用实现的。
通过这个方法完成了嵌套nest能力
其实从这段代码的 commit log 中也能证实我们的猜想
这个截图里面还有个小彩蛋 Reviewed-By: Zijian Liu
其实,这种递归还有点像 Leetcode 经典的回溯算法题 51. N-Queens,它就是对 tree 的DFS遍历。DFS遍历用递归写是上面的写法,而用迭代写就是用Stack了
七、再走一步:AsyncHook 是如何在 Node Core 层实现的
Intro and Guess
在上一个章节中,我们已经发现 AsyncHook 和 executionAsyncResource 是比较可疑的。所以我们先来看 AsyncHook
// location: lib/async_hooks.js
class AsyncHook {
constructor({
init,
before,
after,
destroy,
promiseResolve
}) {
this[init_symbol] = init;
this[before_symbol] = before;
this[after_symbol] = after;
this[destroy_symbol] = destroy;
this[promise_resolve_symbol] = promiseResolve;
}
enable() {
// The set of callbacks for a hook should be the same regardless of whether
// enable()/disable() are run during their execution. The following
// references are reassigned to the tmp arrays if a hook is currently being
// processed.
const {
: hooks_array,
: hook_fields
} = getHookArrays();
// Each hook is only allowed to be added once.
if (ArrayPrototypeIncludes(hooks_array, this))
return this;
const prev_kTotals = hook_fields[kTotals];
// createHook() has already enforced that the callbacks are all functions,
// so here simply increment the count of whether each callbacks exists or
// not.
hook_fields[kTotals] = hook_fields[kInit] += +!!this[init_symbol];
hook_fields[kTotals] += hook_fields[kBefore] += +!!this[before_symbol];
hook_fields[kTotals] += hook_fields[kAfter] += +!!this[after_symbol];
hook_fields[kTotals] += hook_fields[kDestroy] += +!!this[destroy_symbol];
hook_fields[kTotals] +=
hook_fields[kPromiseResolve] += +!!this[promise_resolve_symbol];
ArrayPrototypePush(hooks_array, this);
if (prev_kTotals === 0 && hook_fields[kTotals] > 0) {
enableHooks();
}
updatePromiseHookMode();
return this;
}
}
还好,构造函数不算可疑,和预想的一样,把每个阶段的 hook 的 callback 存起来。然后再通过 enable 方法激活他们,那 line 44 的 enableHooks() 来自哪里?来自lib/internal/async_hooks.js(是的,自此我们知道原来每一个lib文件夹的API还调用了lib/internal这层内部的实现,简单理解就是又抽象了一层出来。)
看下代码
// location: lib/internal/async_hooks.js
const async_wrap = internalBinding('async_wrap');
const { setCallbackTrampoline } = async_wrap;
function enableHooks() {
async_hook_fields[kCheck] += 1;
setCallbackTrampoline(callbackTrampoline);
}
在里面调用了 setCallbackTrampoline,这个方法来自 async_wrap。
其实,看代码可以知道,我们刚刚调用的神秘的 executionAsyncResource 里面调用的关键变量,几乎都来自async_wrap,通过 internalBinding 获取到的。
既然这里用的是internalBinding(String),入参是个string,再加上这个方法的名字,我们自然可以猜测 internalBinding 里面还有许多string可以被调用,而且可枚举完(但是为啥没有统一管理为const、enum、或者symbol,这个可能需要聪明的你去解答了)
随便搜下,无数个方法都通过 internalBinding 获取到,猜测验证结束
但是在这一步,我们遇到一些小麻烦,因为你会发现 internalBinding 并没有通过 require()的方式引用进代码,command+左键也只能到它的d.ts定义里面。感觉似乎陷入了死胡同或者什么黑魔法之中。
// d.ts file
declare function InternalBinding(binding: 'blob'): {
createBlob(sources: Array<Uint8Array | InternalBlobBinding.BlobHandle>, length: number): InternalBlobBinding.BlobHandle;
FixedSizeBlobCopyJob: typeof InternalBlobBinding.FixedSizeBlobCopyJob;
getDataObject(id: string): [handle: InternalBlobBinding.BlobHandle | undefined, length: number, type: string] | undefined;
storeDataObject(id: string, handle: InternalBlobBinding.BlobHandle, size: number, type: string): void;
revokeDataObject(id: string): void;
};
https://github.com/nodejs/help/issues/3079
简单一搜,就能搜到问题的回答,柳暗花明。(其实全局搜索也能搜到这个方法)
让我们把目光来到lib/internal/bootstrap/loader.js。
// location: lib/internal/bootstrap/loader.js
// This file creates the internal module & binding loaders used by built-in
// modules. In contrast, user land modules are loaded using
// lib/internal/modules/cjs/loader.js (CommonJS Modules) or
// lib/internal/modules/esm/* (ES Modules).
// C++ binding loaders:
// - internalBinding(): the private internal C++ binding loader, inaccessible
// from user land unless through `require('internal/test/binding')`.
// These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL()
// and have their nm_flags set to NM_F_INTERNAL.
// This file is compiled as if it's wrapped in a function with arguments
// passed by node::RunBootstrapping()
/* global process, getLinkedBinding, getInternalBinding, primordials */
// Set up internalBinding() in the closure.
/**
* @type {InternalBinding}
*/
let internalBinding;
{
const bindingObj = ObjectCreate(null);
// eslint-disable-next-line no-global-assign
internalBinding = function internalBinding(module) {
let mod = bindingObj[module];
if (typeof mod !== 'object') {
mod = bindingObj[module] = getInternalBinding(module);
ArrayPrototypePush(moduleLoadList, `Internal Binding ${module}`);
}
return mod;
};
}
这是简化后的备注,简单理解就是这个文件被加载为了 loader,既然是 loader 自然要 load 文件,load 什么呢?
用于 load built-in modules(加载内部模块)。把C++的模块load到js里面进行调用
大家可以发现,In line 30,getInternalBinding 也是‘凭空’出现的。自然我们去搜下他。
看来,getInternalBinding() 是真在js文件里面找不到了,因为它的定义不来自js世界。
Alright,恭喜你,到达C++的地盘。
其实,我们是使用 internalBinding() 将 async_wrap 从async_wrap.cc加载到了 async_wrap.js
那 internalBinding 是被定义在了 js 文件里面,又为啥可以被全局访问到,而且没使用 require。这个在备注里面也有解释
// This file is compiled as if it’s wrapped in a function with arguments
// passed by node::RunBootstrapping()
这个文件被编译了,就像函数的参数一样被传入node::RunBootstrapping()调用。而这个方法,就是Node的C++ built-in module的启动函数。
C++ World
我们回到主线,看看async_wrap在做什么,是怎么实现的
总之,async_wrap被 getInternalBinding 给get到了loader.js里面,那有get就一定有个对应的set或者说注册。是的,我们把目光放到src文件夹的这里src/async_wrap.cc
// location: src/async_wrap.cc
// 该文件结尾处
// 在node_binding.h里面定义了宏macro
// #define NODE_MODULE_CONTEXT_AWARE_INTERNAL(modname, regfunc)
NODE_MODULE_CONTEXT_AWARE_INTERNAL(async_wrap, node::AsyncWrap::Initialize);
NODE_MODULE_EXTERNAL_REFERENCE(async_wrap, node::AsyncWrap::RegisterExternalReferences);
在该文件的结尾处,我们通过NODE_MODULE_CONTEXT_AWARE_INTERNAL这个宏(macro),注册了async_wrap。
自此我们在代码层面回答了 where set async_wrap
is called。
其实我们的内部模块(Internal Module)是通过NODE_MODULE_CONTEXT_AWARE_INTERNAL来暴露给js以作调用的,过程中使用的 loader 就是上文提到的 getInternalBinding
至于NODE_MODULE_CONTEXT_AWARE_INTERNAL是怎么实现的,欢迎大家自己深挖。
NODE_MODULE_CONTEXT_AWARE_INTERNAL-> NODE_MODULE_CONTEXT_AWARE_CPP -> …
自此小结,我们知道了async_wrap是在哪里被注册的,以及async_wrap的行为在哪里被定义。
接下来我们看async_wrap.cc内部在做什么。
async_wrap.cc
这个文件有700行左右的代码,我就不全部贴出来了。
不过在看具体代码前,我们先猜下,他在干嘛。下面是我的猜测,从名字来看,他叫做 async wrap,wrap就是把我们的async call给包住了,包住代表什么?代表async call想做什么事,都得先过一层我wrap再说。
熟悉吗?这就是我们所谓的监听(listen)、劫持(hijack)、包裹(wrap)、监控(monitor),大致都是一个意思。wrap其实还有点像AOP(面向切面编程)、洋葱模型、代理proxy等东西,都是主观上给人一种层(layer)的感觉。
话说回来,在一开始我们就知道, libuv 是用C写的库,负责异步I/O等工作,那我们的就猜测,你既然wrap劫持async call,那具体是劫持什么呢,多半就是和libuv的东西有关了。所以下一步,我们找文件里面和libuv和劫持相关的代码。
不过很遗憾,并没有在async_wrap.cc代码内部找到uv.h头文件(代表libuv)的引用,至少libuv没有被直接使用在这个文件里。但是大的方向不会错,那我们来看libuv官网文档http://docs.libuv.org/en/v1.x/api.html
这里里面有大量句柄(handle),用于处理I/O事件的对象,它负责管理底层I/O资源的分配和释放,以及处理I/O事件的通知和回调。
注意下面只是猜想!不是对的!
猜想1:
应该就是调用的 libuv 里这个API了,用作提交异步请求,并且拿到异步的回调。
两个库内部的代码直接互相调用,并不符合规范,他们都被包装到一个内部对外的API进行交互
所以我们 async_wrap <—> libuv 这种关系可以抽象为下面的图👇🏻
猜想2:
AsyncWrap作为基类,提供各种基础API和JS层交互。衍生的子类和 libuv 通过 uv_handle_t 进行交互,由 libuv 通知子类去执行对应的 async hook
我们可以在下一章看看猜测是否正确
最后, libuv 里面的uv不是每日访问量UV(Unique Visitor)或者DAU(Daily Active User),而是 Lib of Unicorn Velociraptor(独角迅猛龙)。你问我为啥是独角迅猛龙,因为。。。。看他的logo吧
How Is AsyncHook.Init() Invoked by Node Core
为了回答上面那个猜想,我想我可以直接介绍下 AsyncHook 的 init 方法是如何被 Node Core 调用的。
既然我们注册了方法,把 init 存在了某个地方,那么在一个 async call 的初始化的时候,它会被触发。所以我们有了下面2个步骤:
Step 1, where is callback stored in?
上一章说了,每一个 hook cb 被存在了 AsyncHook Class 对应的 this[xxx_symbol] = xxx 里面,在被 enable 的时候,通过 ArrayPrototypePush(hooks_array, this) 被 push 到了 hooks_array。
这个 hooks_array 来自 lib/internal/async_hooks.js,叫做 active_hooks,看下定义,一个简单的 object
// location: lib/internal/async_hooks.js
// Properties in active_hooks are used to keep track of the set of hooks being
// executed in case another hook is enabled/disabled. The new set of hooks is
// then restored once the active set of hooks is finished executing.
const active_hooks = {
// Array of all AsyncHooks that will be iterated whenever an async event
// fires. Using var instead of (preferably const) in order to assign
// active_hooks.tmp_array if a hook is enabled/disabled during hook
// execution.
array: [],
// Use a counter to track nested calls of async hook callbacks and make sure
// the active_hooks.array isn't altered mid execution.
call_depth: 0,
// Use to temporarily store and updated active_hooks.array if the user
// enables or disables a hook while hooks are being processed. If a hook is
// enabled() or disabled() during hook execution then the current set of
// active hooks is duplicated and set equal to active_hooks.tmp_array. Any
// subsequent changes are on the duplicated array. When all hooks have
// completed executing active_hooks.tmp_array is assigned to
// active_hooks.array.
tmp_array: null,
// Keep track of the field counts held in active_hooks.tmp_array. Because the
// async_hook_fields can't be reassigned, store each uint32 in an array that
// is written back to async_hook_fields when active_hooks.array is restored.
tmp_fields: null
};
module.exports = {
executionAsyncId,
triggerAsyncId,
// Private API
getHookArrays,
symbols: {
async_id_symbol, trigger_async_id_symbol,
init_symbol, before_symbol, after_symbol, destroy_symbol,
promise_resolve_symbol, owner_symbol
},
// ..
executionAsyncResource,
// Internal Embedder API
// ...
nativeHooks: {
init: emitInitNative, // <====== 看这里
before: emitBeforeNative,
after: emitAfterNative,
destroy: emitDestroyNative,
promise_resolve: emitPromiseResolveNative
},
};
同时,这个 lib/internal/async_hooks.js 文件export的方法中有个名字比较可疑,emitInitNative。Native, Native,名字里面有 Native,实在太不对劲了。我们来看下实现:
// location: lib/internal/async_hooks.js
// Emit From Native //
// Used by C++ to call all init() callbacks. Because some state can be setup
// from C++ there's no need to perform all the same operations as in
// emitInitScript.
function emitInitNative(asyncId, type, triggerAsyncId, resource) {
active_hooks.call_depth += 1;
resource = lookupPublicResource(resource);
// Use a single try/catch for all hooks to avoid setting up one per iteration.
try {
// Using var here instead of let because "for (var ...)" is faster than let.
// Refs: https://github.com/nodejs/node/pull/30380#issuecomment-552948364
// eslint-disable-next-line no-var
for (var i = 0; i < active_hooks.array.length; i++) {
if (typeof active_hooks.array[i][init_symbol] === 'function') {
active_hooks.array[i][init_symbol](
asyncId, type, triggerAsyncId,
resource
);
}
}
} catch (e) {
fatalError(e);
} finally {
active_hooks.call_depth -= 1;
}
// Hooks can only be restored if there have been no recursive hook calls.
// Also the active hooks do not need to be restored if enable()/disable()
// weren't called during hook execution, in which case active_hooks.tmp_array
// will be null.
if (active_hooks.call_depth === 0 && active_hooks.tmp_array !== null) {
restoreActiveHooks();
}
}
通过 comment 证明了,emitInitNative 确实这个方法,最终会被 native 调用(C++)。可以看到,我们一路存下来的 active_hooks 会在 line 18 里面,在js层被调用。
同理,我们上面的 before, after 等也是一样的。
至此,我们回答了标题,callback是存在哪里并被执行的。
先别急着走到下一步,我们还差一件事,就是如何把这个 js 的方法暴露给我们的 native 层?在这里
// location: lib/internal/bootstrap.js
const { nativeHooks } = require('internal/async_hooks');
internalBinding('async_wrap').setupHooks(nativeHooks);
神秘的,从C++层来的 async_wrap 负责把我们的 nativeHooks 注册到c++。OK,现在我们只需要记住 setupHooks is responsible for registering nativeHooks 即可。
Step 2, which code line is responsible for calling init callback?
先说结论,每一个 async call 都会由一个 C++ 的类叫做 AsyncWrap 来包装,地址在 src/async_wrap.cc
同时,上面提到,我们是通过下面这个方法把 async_wrap 暴露出去的,所以我来看Initialize
NODE_MODULE_CONTEXT_AWARE_INTERNAL(async_wrap, node::AsyncWrap::Initialize)
Initialize
// location: src/async_wrap.cc
void AsyncWrap::Initialize(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
Environment* env = Environment::GetCurrent(context);
Isolate* isolate = env->isolate();
HandleScope scope(isolate);
env->SetMethod(target, "setupHooks", SetupHooks);
env->SetMethod(target, "setCallbackTrampoline", SetCallbackTrampoline);
env->SetMethod(target, "pushAsyncContext", PushAsyncContext);
env->SetMethod(target, "popAsyncContext", PopAsyncContext);
env->SetMethod(target, "executionAsyncResource", ExecutionAsyncResource);
env->SetMethod(target, "clearAsyncIdStack", ClearAsyncIdStack);
env->SetMethod(target, "queueDestroyAsyncId", QueueDestroyAsyncId);
env->SetMethod(target, "setPromiseHooks", SetPromiseHooks);
env->SetMethod(target, "registerDestroyHook", RegisterDestroyHook);
PropertyAttribute ReadOnlyDontDelete =
static_cast<P>(ReadOnly | DontDelete);
// ...
}
先解释几个基本概念和数据类型:
-
Isolate: line 8 中被用到,被定义在 v8.h。Isolate是V8引擎的一个独立实例。它是一个独立的JavaScript运行时,运行在一个单独的线程中,拥有自己的内存堆、垃圾回收器和执行上下文。可以在一个进程中创建多个Isolate,每个Isolate提供一个单独的运行时环境,可以独立地运行JavaScript代码。
-
Context: line 5 中被用到,被定义在v8.h。Context表示Isolate中的一个执行上下文。它是一个JavaScript对象,包含当前执行上下文的状态,包括变量、函数和其他数据。Context在Isolate中创建,并与特定的执行线程相关联。可以在单个Isolate中创建多个Context,每个Context可以独立地执行JavaScript代码。我们熟知的 vm.createContext()也是创建了一个新的 Context 实例。
-
Local: lin 5 中被用到,被定义在v8.h。在 V8 引擎(Node.js 的 JavaScript 引擎)中,用于表示 JavaScript 对象的本地句柄(Handle)
-
看下原文描述:An object reference managed by the v8 garbage collector. All objects returned from v8 have to be tracked by the garbage collector so that it knows that the objects are still alive。
-
可以理解为类似于指针,但是指向的内存地址会随着GC(garbage collection)而变化,确保总是指向我们需要的值,同时管理引用的对象是否可以被清理。
-
Local 句柄是一种轻量级的对象引用,它在 V8 的内存管理系统中的生命周期是有限的。当 V8 的垃圾回收器进行内存回收时,Local 句柄所引用的对象可能会被清理。Local<Context>就代表一个V8 Context 的本地句柄。除了本地句柄Local,还有MaybeLocal,Eternal等类型。
-
line 9 中的 HandleScope 也是用于管理句柄生命周期的。
-
Environment: line 7 中被用到,被定义在src/env.h。在 Node.js 的 C++ 层面,Environment 类是一个核心组件,负责管理和维护 Node.js 应用程序的上下文环境和资源。它提供了一个桥梁,让 Node.js 的 JavaScript 层与底层的 C++ 实现进行交互。Environment 类封装了许多与 JavaScript 运行时相关的资源。以下是 Environment 类的一些主要职责:
-
管理 V8 Isolate 实例:Isolate 是 V8 引擎中表示独立的 JavaScript 运行时环境的对象。一个 Environment 实例与一个特定的 Isolate 实例关联,它们共同构成了 Node.js 应用程序的运行时环境。
-
内存管理:Environment 类负责管理与内存相关的资源,如对象句柄、缓冲区等。通过创建 V8 HandleScope 和 EscapableHandleScope 实例,Environment 能确保 V8 能正确地管理 JavaScript 对象的生命周期。
-
与 JavaScript 层的互操作:Environment 类提供了一系列方法,使 JavaScript 层与底层的 C++ 实现进行交互。这些方法包括设置 JavaScript 对象的属性和方法、执行回调函数等。
OK,基于此我们再来看代码。
在 line 7,我们通过 Environment::GetCurrent(context) 获取到当前上下文的 Environment* 指针,接着在line 11,通过这个指针所指的方法 SetMethod,讲我们的 SetupHooks 绑定到上一节提到过的 internalBinding(‘async_wrap’).setupHooks(nativeHooks);
那 SetupHooks 是怎么实现的
SetupHooks
// location: src/async_wrap.cc
static void SetupHooks(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
CHECK(args[0]->IsObject());
// All of init, before, after, destroy, and promise_resolve are supplied by
// async_hooks internally, so this should only ever be called once. At which
// time all the functions should be set. Detect this by checking if
// init !IsEmpty().
CHECK(env->async_hooks_init_function().IsEmpty());
Local<Object> fn_obj = args[0].As<Object>();
#define SET_HOOK_FN(name)
do {
Local<Value> v =
fn_obj->Get(env->context(),
FIXED_ONE_BYTE_STRING(env->isolate(), #name))
.ToLocalChecked();
CHECK(v->IsFunction());
env->set_async_hooks_##name##_function(v.As<Function>());
} while (0)
SET_HOOK_FN(init);
SET_HOOK_FN(before);
SET_HOOK_FN(after);
SET_HOOK_FN(destroy);
SET_HOOK_FN(promise_resolve);
#undef SET_HOOK_FN
}
这里第一步,是获取 Environment* 指针,接着确保 args[0] 是一个 Objext,同时 async_hooks_init_function 是 empty,确保只会被初始化1次。
接着定义了 SET_HOOK_FN 这个宏(marco),通过这个宏,将 init 方法绑定到触发函数
env -> set_asynchooks##name##_function()
name## 就是我们的init、before、after、destroy变量,‘#’ 和 ‘##’ 语法用于 C/C++ 中的 macro 命令。最后的 #undef 使用是去掉宏的定义,目的是为了防止此宏在其他地方调用。
所以最终,这个方法,在 AsyncWrap::EmitAsyncInit 中调用
EmitAsyncInit
// location: src/async_wrap.cc
void AsyncWrap::EmitAsyncInit(Environment* env,
Local<Object> object,
Local<String> type,
double async_id,
double trigger_async_id) {
CHECK(!object.IsEmpty());
CHECK(!type.IsEmpty());
AsyncHooks* async_hooks = env->async_hooks();
// Nothing to execute, so can continue normally.
if (async_hooks->fields()[AsyncHooks::kInit] == 0) {
return;
}
HandleScope scope(env->isolate());
Local<Function> init_fn = env->async_hooks_init_function();
Local<Value> argv[] = {
Number::New(env->isolate(), async_id),
type,
Number::New(env->isolate(), trigger_async_id),
object,
};
TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
USE(init_fn->Call(env->context(), object, arraysize(argv), argv));
}
// location: v8.h
/**
* A JavaScript function object (ECMA-262, 15.3).
*/
class V8_EXPORT Function : public Object {}
set the fn: env -> set_asynchooks##name##_function()
get the corresponding fn: env -> async_hooks_init_function()
在 line 18,我们获得了之前注册的 init,这是一个 Local 句柄,Local<Function> 就是一个指向 js 的 function 的句柄,最后,我们通过 line 28 的 init_fn -> Call() 可以来触发 js 函数。
至此,说完了一个 Async Init Hook 是如何被完整调用的。
下面我们来回顾下整体的关系,算是是一个小结。
Sum Up: High-Level Overview Flowchart
-
API:很好理解,暴露了这3个重要的API
-
Node Core – Native JS Module:
-
上面的3个API来自async_hooks.js中的3个类:AsyncLocalStorage/AsyncResource/AsyncHook
-
AsyncHook负责注册4个阶段的Callback function
-
在这里通过 internalBinding(‘async_wrap’) 获得C++层的 AsyncWrap
-
Node Core – C++ Binding:
-
在 async_wrap.cc 中定义了关键的基类 AsyncWrap ,它继承自 BaseObject
-
通过 NODE_MODULE_CONTEXT_AWARE_INTERNAL 方法暴露给 JS 层
-
AsyncWrap只是一个基类。UPDWrap、TCPWrap、FSEventWrap等直接或间接继承者它,为各种 Wrap 提供负责触发Hook回调的方法。
-
比如 TCPWrap -> ConnectionWrap -> LibuvStreamWrap -> HandleWrap -> AsyncWrap
-
libuv的方法在具体的 Wrap 里面调用。
-
举个例子,当一个 TCP 网络请求发出时,会执行 new TCPWrap,通过 uv_tcp_connect() 发起链接(方法来自libuv);
-
链接成功后,会通过一个句柄(uv_tcp_t),对 libuv 保持访问。整个过程中句柄类型会被转变 uv_tcp_t -> uv_stream_t
-
当请求返回的时候, TCPHandle 对象会触发 uv__stream_io() 方法去执行 uv__read(),最终通知 TCPWrap 或者其父类执行回调
-
src/api 文件夹中给三方addons提供了一些API,其中AsyncResource是基于AsyncWrap的封装,AsyncWrap触发before和after的异步事件是通过 AsyncWrap::MakeCallback 方法,该方法调用 CallbackScope 内部的 InternalMakeCallback
-
Deps:
-
Libuv: 对I/O异步、网络异步回调负责
-
V8: 对Promise 和 async/await 语法负责
-
最终通过 AsyncWrap 通知到 JS 层的 AsyncHook
Add-On
这里是一些收集资料过程中发现的相关信息和彩蛋,分享给大家。
Performance Improvement
PR:async_hooks: use resource stack for AsyncLocalStorage run #39890
https://github.com/nodejs/node/pull/39890
关键字:
- using stack instead of AsyncResouce instance
- eliminate extra lifecycle event
上文提到过,执行AsyncLocalStorage.run的时候有个 commit log,这次的 PR 目的是为了提升性能。
PromiseHook
PR: async_hooks: fast-path for PromiseHooks in JS #32891
https://github.com/nodejs/node/pull/32891
又是这个哥们。
也有一个彩蛋。Reviewed-By: Chengzhong Wu
(但是为啥明明被关闭的PR,代码 commit 却出现了在了 Node v16.18?因为Node发版和代码,不使用Github的PR,有一套自己的流程)
How is loader created
这里放下 lib/internal/bootstrap/loader.js文件的完整备注,有兴趣的同学可以看下,里面的分情况讨论了 require 语法里拿到的各种对象是怎么被加载进去的。
其实把文档的注释读完,整个 loader 这一层就会清楚很多。所以非常喜欢 Node 的丰富的注释。
// location: lib/internal/bootstrap/loader.js
// This file creates the internal module & binding loaders used by built-in
// modules. In contrast, user land modules are loaded using
// lib/internal/modules/cjs/loader.js (CommonJS Modules) or
// lib/internal/modules/esm/* (ES Modules).
//
// This file is compiled and run by node.cc before bootstrap/node.js
// was called, therefore the loaders are bootstrapped before we start to
// actually bootstrap Node.js. It creates the following objects:
//
// C++ binding loaders:
// - process.binding(): the legacy C++ binding loader, accessible from user land
// because it is an object attached to the global process object.
// These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE()
// and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees
// about the stability of these bindings, but still have to take care of
// compatibility issues caused by them from time to time.
// - process._linkedBinding(): intended to be used by embedders to add
// additional C++ bindings in their applications. These C++ bindings
// can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag
// NM_F_LINKED.
// - internalBinding(): the private internal C++ binding loader, inaccessible
// from user land unless through `require('internal/test/binding')`.
// These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL()
// and have their nm_flags set to NM_F_INTERNAL.
//
// Internal JavaScript module loader:
// - NativeModule: a minimal module system used to load the JavaScript core
// modules found in lib/**/*.js and deps/**/*.js. All core modules are
// compiled into the node binary via node_javascript.cc generated by js2c.py,
// so they can be loaded faster without the cost of I/O. This class makes the
// lib/internal/*, deps/internal/* modules and internalBinding() available by
// default to core modules, and lets the core modules require itself via
// require('internal/bootstrap/loaders') even when this file is not written in
// CommonJS style.
//
// Other objects:
// - process.moduleLoadList: an array recording the bindings and the modules
// loaded in the process and the order in which they are loaded.
'use strict';
// This file is compiled as if it's wrapped in a function with arguments
// passed by node::RunBootstrapping()
/* global process, getLinkedBinding, getInternalBinding, primordials */
The Workflow of Node.js Startup
图片来源:https://leezhenghui.github.io/node.js/2018/11/11/demystify-node.js-modularity.html
启动流程
Node架构
我们的上面提到过的 loader 就是在 LoadEnv 阶段被执行的。
而我们之前讨论的 AsyncHook 的实现,就在上面的 [node native module] – [node-core(c/c++)] 两层之间。
有兴趣的同学可以看看原文章。
非常详细得介绍了 Node,名字也很有意思 《Demystify node.js – Modularization》https://leezhenghui.github.io/node.js/2018/11/11/demystify-node.js-modularity.html
八、最后的最后:打个总结
写这篇文章的想法非常突然。大概是2月初,吞吞老师在群里分享了关于 AsyncContext 提案进入 Stage1 的消息,就瞄了一眼发现看不懂,于是在大群里求教。
后来,了解到这个概念和Node中的一些API类似,之后就开始慢慢了解这个东西的背景,心里想着都整理了些内容了,不如写篇文章总结下吧。但是光介绍 API 咋用由有点浅,官网文档也有,于是所幸梳理下这个概念的发展过程。后来感觉,发展过程都看了,不如实现原理也一把梭吧,况且要写就质量写高点嘛,正好借这个机会再了解下Node,于是,便有了这篇文章。
不过,整个过程还是比较艰难的,边猜边学边写。难点有二,难点之一,是文章内容对我有一定难度;从一个点切入进入(一个API),发现还需要一条线(Node的机制)甚至一个面的知识(其他语言、甚至相关领域的知识)。一开始有点overwhelming,发现需要同步学的知识太多了。后来可算找到了方法,就是我只要紧盯着这个点,不要盲目展开 Todo List,就慢慢向外扩展即可。难点之二,是行文如何展开,因为就我自己读技术书籍的感觉,很少描写为什么,需要自己去想或者本身就有知识储备才能得到答案,我还是希望能提供更多的背景来一步一步介绍这个概念。想法很美好,不过事实上自己上手之后发现,还是很难理出一条思路,你需要像演员带入角色一样来带入读者,很考验功力,特别是最后3章,把握不住了。
最后,希望大家在有收获的地方不吝点赞,发现错误的地方不吝指正,谢谢!