一文了解AsyncHooks
一文了解AsyncHooks
async_hooks 模块提供了用于跟踪异步资源的 API。在最近的项目里用到了 Node.js 的 async_hooks 里的 AsyncLocalStorage Api。之前对 async_hooks 也有所耳闻,但没有实践过,正好趁此机会深入了解下。
什么是异步资源
这里的异步资源是指具有关联回调的对象,有以下特点:
- 回调可以被一次或多次调用。比如 fs.open 创建一个 FSReqCallback 对象,监听 complete 事件,异步操作完成后执行一次回调,而 net.createServer 创建一个 TCP 对象,会监听 connection 事件,回调会被执行多次。
- 资源可以在回调被调用之前关闭
- AsyncHook 是对这些异步资源的抽象,不关心这些异步的不同
- 如果用了 worker,每个线程会创建独立的 async_hooks 接口,使用独立的 asyncId。
为什么要追踪异步资源
因为 Node.js 基于事件循环的异步非阻塞 I/O 模型,发起一次异步调用,回调在之后的循环中才被调用,此时已经无法追踪到是谁发起了这个异步调用。
场景一
const fs = require('fs')
function callback(err, data) {
console.log('callback', data)
}
fs.readFile("a.txt", callback)
console.log('after a')
fs.readFile("b.txt", callback)
console.log('after b')
// after a
// after b
// callback undefined
// callback undefined
我们用上面的例子代表 node 的异步 I/O,执行后的结果也符合我们的预期,那么问题来了,哪个 callback 是哪个 callback 呢?先执行的是 a 的还是 b 的呢? -> 我们无法从日志中确认调用链
场景二
function main() {
setTimeout(() => {
throw Error(1)
}, 0)
}
main()
// Error: 1
// at Timeout._onTimeout (/Users/zhangruiwu/Desktop/work/async_hooks-test/stack.js:3:11)
// at listOnTimeout (internal/timers.js:554:17)
// at processTimers (internal/timers.js:497:7)
异步回调抛出异常,也拿不到完整的调用栈。事件循环让异步调用和回调之间的联系断了。我:断了的弦,还怎么连?async_hooks: 听说有人找我
AsyncHooks
下面是官方的一个 overview:
const async_hooks = require('async_hooks');
// 返回当前执行上下文的asyncId。
const eid = async_hooks.executionAsyncId();
// 返回触发当前执行上下文的asyncId。
const tid = async_hooks.triggerAsyncId();
// 创建asyncHook实例,注册各种回调
const asyncHook =
async_hooks.createHook({ init, before, after, destroy, promiseResolve });
// 开启asyncHook,开启后才会执行回调
asyncHook.enable();
// 关闭asyncHook
asyncHook.disable();
//
// 下面是传入createHook的回调.
//
// 初始化异步操作时的钩子函数
function init(asyncId, type, triggerAsyncId, resource) { }
// 异步回调执行之前的钩子函数,可能触发多次
function before(asyncId) { }
// 异步回调完成后的钩子函数
function after(asyncId) { }
// 异步资源销毁时的钩子函数
function destroy(asyncId) { }
// 调用promiseResolve时的钩子函数
function promiseResolve(asyncId) { }
当开启 asyncHook 的时候,每个异步资源都会触发这些生命周期钩子。下面介绍了 init 的各个参数:
asyncId
异步资源的唯一 id,从 1 开始的自增
type
标识异步资源的字符串,下面是内置的一些 type,也可以自定义
FSEVENTWRAP, FSREQCALLBACK, GETADDRINFOREQWRAP, GETNAMEINFOREQWRAP, HTTPINCOMINGMESSAGE,
HTTPCLIENTREQUEST, JSSTREAM, PIPECONNECTWRAP, PIPEWRAP, PROCESSWRAP, QUERYWRAP,
SHUTDOWNWRAP, SIGNALWRAP, STATWATCHER, TCPCONNECTWRAP, TCPSERVERWRAP, TCPWRAP,
TTYWRAP, UDPSENDWRAP, UDPWRAP, WRITEWRAP, ZLIB, SSLCONNECTION, PBKDF2REQUEST,
RANDOMBYTESREQUEST, TLSWRAP, Microtask, Timeout, Immediate, TickObject
triggerAsyncId
触发当前异步资源初始化的异步资源的 asyncId。
const { fd } = process.stdout;
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
const eid = async_hooks.executionAsyncId();
fs.writeSync(
fd,
`${type}(${asyncId}): trigger: ${triggerAsyncId} execution: ${eid}\n`);
}
}).enable();
net.createServer((conn) => {}).listen(8080);
// 启动后输出:
// TCPSERVERWRAP(4): trigger: 1 execution: 1 # 创建TCP Server,监听connect事件
// TickObject(5): trigger: 4 execution: 1 # listen 回调
// nc localhost 8080 后的输出:
// TCPWRAP(6): trigger: 4 execution: 0 # connect回调
新连接建立后,会创建 TCPWrap 实例,它是从 C++里被执行的,没有 js 堆栈,所以这里 executionAsyncId 是 0。但是这样就不知道是哪个异步资源导致它被创建,所以需要 triggerAsyncId 来声明哪个异步资源对它负责。
resource
一个代表异步资源的对象,可以从此对象中获得一些异步资源相关的数据。比如: GETADDRINFOREQWRAP 类型的异步资源对象,提供了 hostname。
使用示例
我们看一下官方的一个示例:
const { fd } = process.stdout;
let indent = 0;
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
const eid = async_hooks.executionAsyncId();
const indentStr = ' '.repeat(indent);
fs.writeSync(
fd,
`${indentStr}${type}(${asyncId}):` +
` trigger: ${triggerAsyncId} execution: ${eid}\n`);
},
before(asyncId) {
const indentStr = ' '.repeat(indent);
fs.writeSync(fd, `${indentStr}before: ${asyncId}\n`);
indent += 2;
},
after(asyncId) {
indent -= 2;
const indentStr = ' '.repeat(indent);
fs.writeSync(fd, `${indentStr}after: ${asyncId}\n`);
},
destroy(asyncId) {
const indentStr = ' '.repeat(indent);
fs.writeSync(fd, `${indentStr}destroy: ${asyncId}\n`);
},
}).enable();
net.createServer().listen(8080, () => {
// Let's wait 10ms before logging the server started.
setTimeout(() => {
console.log('>>>', async_hooks.executionAsyncId());
}, 10);
});
启动服务后的输出:
TCPSERVERWRAP(4): trigger: 1 execution: 1 # listen 创建TCP server,监听connect事件
TickObject(5): trigger: 4 execution: 1 # 执行用户回调放在了nextTick里
before: 5
Timeout(6): trigger: 5 execution: 5 # setTimeout
after: 5
destroy: 5
before: 6
>>> 6
TickObject(7): trigger: 6 execution: 6 # console.log
after: 6
before: 7
after: 7
对第二行 TickObject 官方的解释是,没有 hostname 去绑定端口是个同步的操作,所以把用户回调放到nextTick去执行让它成为一个异步回调。所以一个思(mian)考(shi)题来了,求输出:
const net = require('net');
net.createServer().listen(8080, () => {console.log('listen')})
Promise.resolve().then(() => console.log('c'))
process.nextTick(() => { console.log('b') })
console.log('a')
TIPS:因为 console.log 是个异步操作,也会触发 AsyncHooks 回调。所以在 AsyncHooks 回调中执行 console 会无限循环:
const { createHook } = require('async_hooks');
createHook({
init(asyncId, type, triggerAsyncId, resource) {
console.log(222)
}
}).enable()
console.log(111)
// internal/async_hooks.js:206
// fatalError(e);
// ^
//
// RangeError: Maximum call stack size exceeded
// (Use `node --trace-uncaught ...` to show where the exception was thrown)
可以用同步的方法输出到文件或者标准输出:
const { fd } = process.stdout // 1
createHook({
init(asyncId, type, triggerAsyncId, resource) {
// console.log(222)
writeSync(fd, '222\n')
}
}).enable()
console.log(111)
如何追踪异步资源
我们用 AsyncHooks 解决上面的两个场景的问题:
场景一
const fs = require('fs')
const async_hooks = require('async_hooks');
const { fd } = process.stdout;
let indent = 0;
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
const eid = async_hooks.executionAsyncId();
const indentStr = ' '.repeat(indent);
fs.writeSync(
fd,
`${indentStr}${type}(${asyncId}):` +
` trigger: ${triggerAsyncId} execution: ${eid} \n`);
},
before(asyncId) {
const indentStr = ' '.repeat(indent);
fs.writeSync(fd, `${indentStr}before: ${asyncId}\n`);
indent += 2;
},
after(asyncId) {
indent -= 2;
const indentStr = ' '.repeat(indent);
fs.writeSync(fd, `${indentStr}after: ${asyncId}\n`);
},
destroy(asyncId) {
const indentStr = ' '.repeat(indent);
fs.writeSync(fd, `${indentStr}destroy: ${asyncId}\n`);
},
}).enable();
function callback(err, data) {
console.log('callback', data)
}
fs.readFile("a.txt", callback)
console.log('after a')
fs.readFile("b.txt", callback)
console.log('after b')
FSREQCALLBACK