Node.js v8.x 新特性 Async Hook 简介

1,207
原文链接: zhuanlan.zhihu.com

Async Hook 的出现简单来说有两个目的,一是提供了一个处理异步任务机制的抽象;二是暴露了方便追踪 handle objects 生命周期的 Hook。本文主要从以下几个方面来讨论:

  • Hook 的起因
  • Overview
  • Handle Objects
  • 一些意外

Hook 的起因

Node.js 是因异步的特性而流行,然而异步的特性本身却有一定的缺陷。从笔者的角度看来简单来说有三个:1、思路差异;2、复杂的场景控制困难;3、难以调试/监控。其中的 1、2 在这几年层出不穷的轮子以及最终 async/await 特性的发布下已经日趋稳定。而第三个问题则是 Node.js 最近两个大版本中努力的方向。Async Hook 的出现在笔者看来一定程度上完善了异步的监控机制。

早期 domain 出现的时候就有同学想要按照 domain 的方式设计一个监听异步行为的 hook (参见 issue: implement domain-like hooks (asynclistener) for userland),基于这个出现了一个叫做 async-listener 的模块。该模块在 process 对象上 pollyfill 了几个监控异步行为的接口。而 8.0 中出现的 Async Hook 则可以说是 Node.js 对 async-listener 的官方支持。

const async_hooks = require('async_hooks');

setTimeout(() => {
  console.log('first setTimeout id', async_hooks.currentId()); // 2
});

setTimeout(() => {
  console.log('second setTimeout id', async_hooks.currentId()); // 4
});

Async hook 对每一个函数(不论异步还是同步)提供了一个 Async scope,你可以通过 async_hooks.currentId() 获取当前函数的 Async ID。

const async_hooks = require('async_hooks');

console.log('default Async Id', async_hooks.currentId()); // 1

process.nextTick(() => {
  console.log('nextTick Async Id', async_hooks.currentId()); // 5
  test();
});

function test () {
  console.log('nextTick Async Id', async_hooks.currentId()); // 5
}

在同一个 Async scope 中,你会拿到相同的 Async ID。

Overview

const async_hooks = require('async_hooks');

// 获取当前执行上下文的异步的 Async ID
const cid = async_hooks.currentId();

// 获取调用当前异步的异步的 Async ID
const tid = async_hooks.triggerId();

// 创建一个新的 AsyncHook 实例. 所有回调都是可选项
const asyncHook = async_hooks.createHook({ init, before, after, destroy });

// 允许该实例中异步函数启用 hook 不会自动生效需要手动启用。
asyncHook.enable();

// 关闭监听异步事件
asyncHook.disable();

//
// 以下是可以监控的几个事件的 callback
//

// 对象构造时会触发 init 事件(此时资源可能还没初始化完 )
// 因此 asyncId 引用的资源 (resource) 的所有字段都可能还未填充
function init(asyncId, type, triggerId, resource) { }

// before is called just before the resource's callback is called. It can be
// called 0-N times for handles (e.g. TCPWrap), and will be called exactly 1
// time for requests (e.g. FSReqWrap).
function before(asyncId) { }

// after is called just after the resource's callback has finished.
function after(asyncId) { }

// destroy is called when an AsyncWrap instance is destroyed.
function destroy(asyncId) { }

Handle Objects

Async Hooks 可以触发事件来通知我们关于 handle object 的变化。所以要了解 Async Hook 同时也需要了解 handle objects。

Node.js 的核心 API 大部分是用 JavaScript 定义的,然而 ECMAScript 标准却并没有规范 JavaScript 要如何使用 TCP socket、读取文件等等的一些列操作。这些操作是通过 C++ 调用 libuv 和 V8 来实现的,而 node 内部与这些 C++ 层交互的对象被称之为 handle objects。

8.0 之后使用 Async hook 可以对 handle object 的生命周期进行追踪:

const fs = require('fs');
const async_hooks = require('async_hooks');

let indent = 0;

async_hooks.createHook({
  init(asyncId, type, triggerId) {
    const cId = async_hooks.currentId();
    print(`${getIndent(indent)}${type}(${asyncId}): trigger: ${triggerId} scope: ${cId}`);
  },
  before(asyncId) {
    print(`${getIndent(indent)}before:  ${asyncId}`);
    indent += 2;
  },
  after(asyncId) {
    indent -= 2;
    print(`${getIndent(indent)}after:   ${asyncId}`);
  },
  destroy(asyncId) {
    print(`${getIndent(indent)}destroy: ${asyncId}`);
  },
}).enable();

let server = require('net').createServer((sock) => {
  sock.end('hello world\n');
  server.close();
});

server.listen(8080, () => print('server started'));

function print(str) {
  fs.writeSync(1, str + '\n');
}

function getIndent(n) {
  return ' '.repeat(n);
}

使用 nc localhost 8080 来调用。可以看到 server 端的输出:

TCPWRAP(2): trigger: 1 scope: 1
TickObject(3): trigger: 2 scope: 1
before:  3
server started
after:   3
destroy: 3
TCPWRAP(4): trigger: 2 scope: 0
before:  2
  TickObject(5): trigger: 2 scope: 2
after:   2
before:  5
  SHUTDOWNWRAP(6): trigger: 4 scope: 5
after:   5
destroy: 5
destroy: 2
before:  6
after:   6
destroy: 6
before:  4
  TTYWRAP(7): trigger: 4 scope: 4
  SIGNALWRAP(8): trigger: 4 scope: 4
  TickObject(9): trigger: 4 scope: 4
  TickObject(10): trigger: 4 scope: 4
after:   4
before:  9
after:   9
before:  10
after:   10
before:  4
after:   4
destroy: 9
destroy: 10
destroy: 4

其中形如 TCPWRAP 的描述则是 handle object,而 scope 即当前异步操作的 Async ID,如果在当前异步中又触发了异步,那么新的异步的 Trigger ID 即当前的 Async ID。当某个异步即将被执行 before hook 会被触发,当某个异步执行完成 after hook 会被触发,当异步流程结束则会触发 destroy hook。

在 Node.js 中一些如 socket 之类的 handle objects 原本并不能准确的确认其释放,而现在则可以通过 destroy hook 来确认其释放了。

一些意外

虽然看起来是有前景,然而这个特性还处于试验中。可能会出现意外或者一些神奇的情况。

比如在 Async Hook 监控的异步中,console.log 也是基于异步实现的,所以如果在 Hook 中使用 console.log 来打印信息就会出现死循环。只能使用非常原始的方式在 Hook 中输出信息,即类似上文中大家看到的直接 fs.writeSync 的方式:

const fs = require('fs');
const util = require('util');

function print(...args) {
  // use a function like this one when debugging inside an AsyncHooks callback
  fs.writeSync(1, `${util.format(...args)}\n`);
}

Async Hook 是对目前的异步进行 Hook,如果 Hook 中的代码如果出现异常, node 的进程会打出 stack trace 然后退出进程。所以不建议在这些 hook 中加入逻辑,如果加入 try/catch 可能会增加正常代码的维护成本,各位也最好谨慎使用这个试验性质的黑魔法。

最后附上一些引用链接:

nodejs/diagnostics

context: core module to manage generic contexts for async call chains · Issue #5243 · nodejs/node-v0.x-archive

Node.js v8.1.1 Documentation