NodeJS 如何实现 “ThreadLocal”
提起 ThreadLocal
这个词,线程局部存储,Java 的朋友们可能很熟悉。从名字看就可以看出来应该是多线程语言的 “特权”,大家都知道 NodeJS 是单线程的,那么它与 NodeJS 又有什么关系呢?
关于 ThreadLocal⌗
多线程语言的 http server 为了提高性能,会使用多线程来处理请求,线程往往就像 worker 一样处理着请求,一般来说一个请求整个生命周期会在同一个线程中,那么此时,使用 ThreadLocal
来传递请求上下文,tracing 信息之类的就很方便了。也就是我们可以不用向 golang
那样显式传递这些上下文信息。
那么 NodeJS 虽然是单线程,但是它使用 异步
的方式提升性能,所以请求处理器也就是一堆异步同步函数的调用,假如异步调用能够追踪,我们也就可以实现类似于多线程语言的 ThreadLocal
了,不过我们只是隔离相同异步函数的 “并发” 调用。
Async Hooks⌗
NodeJS 在 8.1 版本引入了一个新的 API – Async Hooks。官方对它的介绍是:‘async_hooks 用来追踪 Node.js 中异步资源的生命周期’。
async_hooks.createHook
可以允许我们注册 4 个方法来跟踪所有异步资源的初始化(init),回调前(before),回调后(after),销毁后(destroy)事件。
我们引用官方事例了解一下:
注意: 官方文档提醒我们这一点,createHook
中我们不能使用 console.log
打印,因为 NodeJS 中 console.log
也是异步的,如果使用会发生递归。
解释一下这个输出:
为了实现异步追踪,NodeJS 为每个函数(无论同步或异步)提供了一个
async scope
,我们可以使用async_hooks.executionAsyncId()
获取当前作用域的 async scope id 也就是asyncId
, 使用async_hooks.triggerAsyncId()
可以获取调用者的 asyncId异步资源创建时,会触发 init 事件,该事件会传给我们当前 scope 的
asyncId
和triggerAsyncId
(还有资源类型 type 和资源 resource),别的事件均只会收到asyncId
这一个参数我们可以看出上面的调用关系为: 7 -> 6 -> 5 -> 1
最外层的作用域总为 1,并且别的异步资源创建时
asyncId
递增
这几点对我们下面实现 “ThreadLocal” 很重要。
如何实现⌗
先理一下思路,由于 NodeJS 是单线程,并且我们可以得到异步调用的关系,那么我们就可以建立一个以 asyncId
为 key 的全局 Map
来保存这些关系并继承调用方的上下文数据,再维护一个 “正在执行的 asyncId”,同一时刻执行的函数是唯一的,所以对应的 asyncId
也是唯一的,那么用它拿到的上下文数据就是与之对应的了。
首先,定义数据类型⌗
建立全局 Map,并初始化⌗
初始化 hook⌗
暴露 ContextData 读写 API⌗
接着我们可以验证下:
可以看出两次调用,各自的 contextData 确实做到了隔离。
上述完整代码见 Gist。
应用⌗
这样的功能可以在哪些场景下使用呢?
最基本的就是在 web server 中传递上下文,可以传递 requestId 之类的 tracing 信息,然后 logger, http client, rpc client 就可以轻松拿到这些信息了。具体应用后续会列出一点点。
NodeJS 社区著名 ORM 库 Sequelize
也在使用此种技术管理事务,自动传递事务参数,见文档。
后记⌗
虽然这个功能仍然是 实验性
的,但是相信不久就会稳定。如果你想使用,可以使用这个现成库 node-async-context,本文的实现方式也是学习了此项目。