Node.js async 高级编程

622 阅读8分钟

由于 Node.js 应用中遍地都是异步操作,并且 Node.js 的异步操作在 EventLoop 主线程与 Worker 线程中穿插执行,因此想要对整个调用链(比如一个 HTTP 请求从接收到响应的整个过程)进行追踪、故障快速定位等操作将变得异常繁琐,为此 Node.js 提供了 async_hooks 模块来实现对异步操作的追踪;本文将通过异步资源(AsyncResource)、 异步钩子(AsyncHooks)、AsyncLocalStorage 三个方面来为大家详细介绍 async_hooks 模块的使用。

AsyncResource

在详细介绍 async_hooks 模块之前,我们需要对即异步资源进行阐述说明:

  • 异步资源是在调用异步操作时创建的,比如下面的 setTimeout 调用:

    const res = setTimeout(() => {}, 1000);
    console.log(res);
    

    上例中 res 的结构如下所示:

    Timeout {
      _idleTimeout: 1000,
      _idlePrev: [TimersList],
      _idleNext: [TimersList],
      _idleStart: 28,
      _onTimeout: [Function (anonymous)],
      _timerArgs: undefined,
      _repeat: null,
      _destroyed: false,
      [Symbol(refed)]: true,
      [Symbol(kHasPrimitive)]: false,
      [Symbol(asyncId)]: 2,
      [Symbol(triggerId)]: 1
    }
    
  • 异步资源主要用于跟踪异步操作,以便异步操作完成后,EventLoop 能够正确地执行相关回调;

  • 等到相关异步操作完成并且其回调被成功触发后,异步资源也会像其它对象一样被系统回收,比如:

    const res = setTimeout(() => {
      console.log(res);
    }, 1000);
    console.log(res);
    setTimeout(() => {
      console.log(res);
    }, 1000);
    

    执行上面的代码,我们会发现只有在 res 关联的异步操作完成并且其回调被成功触发后,res_destroyed 才会被设置为 true

    Timeout {
      _destroyed: false,
    }
    Timeout {
      _destroyed: false,
    }
    Timeout {
      _destroyed: true,
    }
    

除了 Timeout 这些 Node.js 内置的异步资源外,我们也可以通过 async_hooks 模块下的 AsyncResource 类来创建自己的异步资源,其构造函数的参数如下:

  • type:资源类型(即名称);

  • options:选项设置,相关属性如下:

    • triggerAsyncId:创建该异步资源的异步资源上下文编号,默认取 async_hooks.executionAsyncId() 的值;

    • requireManualDestroy

      • 值为 false 时,Node.js 在对资源进行垃圾回收时,会自动触发该资源所关联的 destroy 钩子;
      • 值为 true 时,Node.js 在对资源进行垃圾回收时,不会自动触发该资源所关联的 destroy 钩子,需要显示调用 AsyncResourceemitDestroy 方法来触发;
      • 默认值为 false

AsyncResource 的主要接口如下:

  • AsyncResource.bind:AsyncResource 的静态方法,把指定的 fn 函数绑定在当前所属的异步资源上下文中(即在调用 AsyncResource.bind 时所在的异步资源上下文);该方法的参数如下:

    • fn:要绑定到当前所属的异步资源上下文中的执行函数;
    • type:异步资源类型,通过该属性与相关的 AsyncResource 进行关联,该参数为非必填参数;
    • thisArg:指定 fn 调用时 this 的值,该参数为非必填参数。
  • bind:把指定的 fn 函数绑定在当前 AsyncResource 实例所属的异步资源上下文中;该方法的参数如下:

    • fn:要绑定到当前 AsyncResource 实例所属的异步资源上下文中的执行函数;
    • thisArg:指定 fn 调用时 this 的值,该参数为非必填参数。
  • runInAsyncScope:在当前 AsyncResource 实例所属的异步上下文中执行指定的 fn 函数;该方法的参数如下:

    • fn:在全新的异步资源上下文中执行的函数;
    • thisArg:指定 fn 调用时 this 的值,该参数为非必填参数;
    • ...args:指定 fn 调用时传递给 fn 的参数列表,该参数为非必填参数。
  • emitDestroy:调用 destroy 钩子,该方法只能被调用一次,多次调用将抛出异常;

  • asyncId:获取分配给当前 AsyncResource 实例的唯一编号;

  • triggerAsyncId:获取创建当前 AsyncResource 实例的异步资源上下文编号。

AsyncHook

上文我们对异步资源进行了介绍,async_hooks 提供了一系列的钩子函数来跟踪这些异步资源的整个生命周期(如下图所示),本节我们就对相关钩子函数的使用做简短介绍。

async_hooks.png

由图可知,在一个异步资源的生命周期中,主要包含以下几个钩子:

  • init:对异步资源初始化时触发;

  • promiseResolve

    • Promise 对象执行 resolvereject 操作时触发;
    • 调用 Promise 对象的 thencatch 方法时触发,此时在触发 promiseResolve 的前后会分别触发 beforeafter 钩子。

    我们看下面的例子:

    const fs = require('fs');
    const { createHook } = require('async_hooks');
    const hook = createHook({
      promiseResolve (asyncId) {
        fs.appendFileSync('log.out', `promiseResolve ${asyncId}\n`);
      },
      before(asyncId) {
        fs.appendFileSync('log.out', `before ${asyncId}\n`);
      },
      after(asyncId) {
        fs.appendFileSync('log.out', `after ${asyncId}\n`);
      },
    });
    hook.enable();
    
    new Promise((resolve) => resolve(true)).then((a) => {});
    

    执行上面的代码,然后查看 log.out 的输出:

    promiseResolve 2
    before 3
    promiseResolve 3
    after 3
    

    通过上面的输出可知,promiseResolvePromise 对象构造函数中调用 resolve 时及调用了 Promise 对象的 then 时各触发了一次,并且第二次同时触发了 beforeafter 钩子。

  • before:执行异步操作的回调函数之前触发;

  • after:执行异步操作的回调函数之后触发;

  • destroy:异步资源被销毁之后触发。

如上所述,async_hooks 提供了 initpromiseResolvebeforeafterdestroy 几个钩子,并通过 async_hooks.createHook 来创建并启用相关钩子,比如:

const { createHook } = require('async_hooks');

const hook = createHook({
  init(asyncId, type, triggerAsyncId, resource) {
  },
  promiseResolve(asyncId) {
  },
  before(asyncId) {
  },
  after(asyncId) {
  },
  destroy(asyncId) {
  }
});
hook.enable();

上述钩子的参数解释如下:

  • asyncId:异步资源的唯一编号;
  • type:异步资源的类型(即名称),点击查看 Node.js 内置资源类型
  • triggerAsyncId:创建异步资源的异步资源上下文编号;
  • resource:异步资源的引用。

在使用上述钩子时,需要注意以下几点:

  • 对于每个异步资源,除了 initdestroy 会被调用一次外,其余钩子被调用的次数大于等于一;
  • 在钩子函数的实现体,尽量避免使用异步操作(包括 console 语句),因为这将导致因钩子被频繁调用而陷入死循环;
  • 在执行异步操作之前,要先调用 hook.enable() 方法来启用钩子,不然所执行的异步操作将不会触发相关钩子。

利用这些钩子我们可以做许多有意思的事,比如在异步调用链中实现一个类似线程局部存储的机制:

const http = require('http');
const { AsyncResource, createHook, executionAsyncId } = require('async_hooks');

const authProfiles = {};
const hook = createHook({
  init(asyncId, type, triggerAsyncId, resource) {
    if (type === 'HTTP_PARSER') {
      if (typeof authProfiles[asyncId] === 'undefined') {
        authProfiles[asyncId] = {};
      }
    }
  },
  destroy(asyncId) {
    authProfiles[asyncId] = null;
  }
})
hook.enable();

http.createServer((req, res) => {
  const asyncResource = new AsyncResource('HTTP_PARSER');
  asyncResource.runInAsyncScope((req, res) => {
    const asyncId = executionAsyncId();
    authProfiles[asyncId].key = Date.now();
    // 其它异步操作....
    res.end();
  }, null, req, res);
}).listen(3000);

上例中:

  • 通过 initdestroy 钩子实现了在异步资源初始化时对相关 authProfiles 信息进行初始化,并且在异步资源销毁时移除相关 authProfiles 信息;
  • 在处理用户请求的整个异步调用链中(即 asyncResource.runInAsyncScope 回调内的异步调用链),均可通过变量 asyncId 的值操作相关 authProfiles 信息。

AsyncLocalStorage

上文我们通过异步钩子实现了类似线程局部存储的机制,用以在某一异步资源上下文中的所有异步操作均能对某一数据进行操作。可能是类似需求的使用频度很高,所以 Node.js 提供了更方便的 AsyncLocalStorage,接下来我们就用 AsyncLocalStorage 来改造上文 authProfiles 设置的例子:

const http = require('http');
const { AsyncLocalStorage } = require('async_hooks');

const asyncLocalStorage = new AsyncLocalStorage();

http.createServer((req, res) => {
  asyncLocalStorage.run({
    key: Date.now(),
  }, (req, res) => {
    // 其它异步操作....
    res.end();
  }, req, res)
}).listen(3000);

上例中,我们无需通过钩子来管理请求链路中所共享数据的生命周期,而是交给 AsyncLocalStorage 简单、高效地进行管理。

AsyncLocalStorage 的主要接口如下:

  • run:创建一个独立的上下文并运行指定的函数,在指定函数中的所有操作均可通过 getStore 获得共享数据,但函数外的操作无法获得共享数据;该方法的参数如下:

    • store:共享数据,可以为任意类型;
    • callback:要执行的回调函数;
    • ...args:传递给回调函数的参数列表。
  • enterWith:调用该方法后,之后所有操作均可通过 getStore 获得共享数据;该方法的参数如下:

    • store:共享数据,可以为任意类型。

    这里需要注意的是,如果在异步操作中调用了 enterWith,其影响范围仅限于该异步操作内部,比如下面的例子:

    const { AsyncLocalStorage } = require('async_hooks');
    
    const asyncLocalStorage = new AsyncLocalStorage();
    
    asyncLocalStorage.enterWith(1);
    console.log(asyncLocalStorage.getStore()); // 输出 1
    
    new Promise((resolve) => resolve(true)).then((value) => {
      asyncLocalStorage.enterWith(value);
      console.log(asyncLocalStorage.getStore()); // 输出 true
      setTimeout(() => {
        console.log(asyncLocalStorage.getStore()); // 输出 true
      }, 1000);
    });
    
    setTimeout(() => {
      console.log(asyncLocalStorage.getStore()); // 输出 1
    }, 1000);
    
  • exit:在指定函数中的所有操作调用 getStore 无法获得外部上下文中设置的共享数据,比如下例:

    const { AsyncLocalStorage } = require('async_hooks');
    
    const asyncLocalStorage = new AsyncLocalStorage();
    
    asyncLocalStorage.enterWith(1);
    console.log(asyncLocalStorage.getStore()); // 输出 1
    asyncLocalStorage.exit(() => {
      console.log(asyncLocalStorage.getStore()); // 输出 undefined
    });
    

    该方法的参数如下:

    • callback:要执行的回调函数;
    • ...args:传递给回调函数的参数列表。
  • disable:调用该方法后,后续调用 getStore 将返回 undefined

  • getStore:获取当前上下文中的共享数据;

总结

本文对 Node.js 中异步操作的高阶内容异步资源(AsyncResource)、 异步钩子(AsyncHooks)、AsyncLocalStorage 进行了介绍,通过这些工具,我们可以对应用中的整个调用链(比如一个 HTTP 请求从接收到响应的整个过程)进行追踪,并以此减少应用故障定位所花费的时间与精力。最后,本文若有纰漏之处,还望大家批评指正,最后祝大家快乐编码每一天。

参考链接

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿