作者簡介:五月(yuè)君,Software Designer,公衆号「Nodejs技術棧」作者。
Async Hooks 功能是 Node.js v8.x 版本新增加的(de)一個(gè)核心模塊,它提供了(le) API 用(yòng)來(lái)追蹤 Node.js 程序中異步資源的(de)聲明(míng)周期,可(kě)在多(duō)個(gè)異步調用(yòng)之間共享數據,本文從最基本入門篇開始學習(xí),之後會有在某些場(chǎng)景下(xià)具體應用(yòng)實踐篇介紹。
executionAsyncId 和(hé) triggerAsyncId
async hooks 模塊提供了(le) executionAsyncId() 函數标志當前執行上下(xià)文的(de)異步資源 Id,下(xià)文使用(yòng) asyncId 表示。還(hái)有一個(gè) triggerAsyncId() 函數來(lái)标志當前執行上下(xià)文被觸發的(de)異步資源 Id,也(yě)就是當前異步資源是由哪個(gè)異步資源創建的(de)。每個(gè)異步資源都會生成 asyncId,該 id 會呈遞增的(de)方式生成,且在 Node.js 當前實例裏全局唯一。
const asyncHooks = require('async_hooks');
const fs = require('fs');
const asyncId = () => asyncHooks.executionAsyncId();
const triggerAsyncId = () => asyncHooks.triggerAsyncId();
console.log(`Global asyncId: ${asyncHooks.executionAsyncId()}, Global triggerAsyncId: ${triggerAsyncId()}`);
fs.open('hello.txt', (err, res) => {
console.log(`fs.open asyncId: ${asyncId()}, fs.open triggerAsyncId: ${triggerAsyncId()}`);
});
下(xià)面是我們運行的(de)結果,全局的(de) asyncId 爲 1,fs.open 回調裏打印的(de) triggerAsyncId 爲 1 由全局觸發。
Global asyncId: 1, Global triggerAsyncId: 0
fs.open asyncId: 5, fs.open triggerAsyncId: 1
默認未開啓的(de) Promise 執行跟蹤
默認情況下(xià),由于 V8 提供的(de) promise introspection API 相對(duì)消耗性能,Promise 的(de)執行沒有分(fēn)配 asyncId。這(zhè)意味著(zhe)默認情況下(xià),使用(yòng)了(le) Promise 或 Async/Await 的(de)程序将不能正确的(de)執行和(hé)觸發 Promise 回調上下(xià)文的(de) ID。即得(de)不到當前異步資源 asyncId 也(yě)得(de)不到當前異步資源是由哪個(gè)異步資源創建的(de) triggerAsyncId,如下(xià)所示:
Promise.resolve().then(() => {
// Promise asyncId: 0. Promise triggerAsyncId: 0
console.log(`Promise asyncId: ${asyncId()}. Promise triggerAsyncId: ${triggerAsyncId()}`);
})
通(tōng)過 asyncHooks.createHook 創建一個(gè) hooks 對(duì)象啓用(yòng) Promise 異步跟蹤。
const hooks = asyncHooks.createHook({});
hooks.enable();
Promise.resolve().then(() => {
// Promise asyncId: 7. Promise triggerAsyncId: 6
console.log(`Promise asyncId: ${asyncId()}. Promise triggerAsyncId: ${triggerAsyncId()}`);
})
異步資源的(de)生命周期
asyncHooks 的(de) createHook() 方法返回一個(gè)用(yòng)于啓用(yòng)(enable)和(hé)禁用(yòng)(disable)hooks 的(de)實例,該方法接收 init/before/after/destory 四個(gè)回調來(lái)标志一個(gè)異步資源從初始化(huà)、回調調用(yòng)之前、回調調用(yòng)之後、銷毀整個(gè)生命周期過程。
init(初始化(huà))
當構造一個(gè)可(kě)能發出異步事件的(de)類時(shí)調用(yòng)。
async:異步資源唯一 id
type:異步資源類型,對(duì)應于資源的(de)構造函數名稱,更多(duō)類型參考 async_hooks_type
triggerAsyncId:當前異步資源由哪個(gè)異步資源創建的(de)異步資源 id
resource:初始化(huà)的(de)異步資源
/**
* Called when a class is constructed that has the possibility to emit an asynchronous event.
* @param asyncId a unique ID for the async resource
* @param type the type of the async resource
* @param triggerAsyncId the unique ID of the async resource in whose execution context this async resource was created
* @param resource reference to the resource representing the async operation, needs to be released during destroy
*/
init?(asyncId: number, type: string, triggerAsyncId: number, resource: object): void;
before(回調函數調用(yòng)前)
當啓動異步操作(例如 TCP 服務器接收新鏈接)或完成異步操作(例如将數據寫入磁盤)時(shí),系統将調用(yòng)回調來(lái)通(tōng)知用(yòng)戶,也(yě)就是我們寫的(de)業務回調函數。在這(zhè)之前會先觸發 before 回調。
/**
* When an asynchronous operation is initiated or completes a callback is called to notify the user.
* The before callback is called just before said callback is executed.
* @param asyncId the unique identifier assigned to the resource about to execute the callback.
*/
before?(asyncId: number): void;
after(回調函數調用(yòng)後)
當回調處理(lǐ)完成之後觸發 after 回調,如果回調出現未捕獲異常,則在觸發 uncaughtException 事件或域(domain)處理(lǐ)之後觸發 after 回調。
/**
* Called immediately after the callback specified in before is completed.
* @param asyncId the unique identifier assigned to the resource which has executed the callback.
*/
after?(asyncId: number): void;
destory(銷毀)
當 asyncId 對(duì)應的(de)異步資源被銷毀後調用(yòng) destroy 回調。一些資源的(de)銷毀依賴于垃圾回收,因此如果對(duì)傳遞給 init 回調的(de)資源對(duì)象有引用(yòng),則有可(kě)能永遠(yuǎn)不會調用(yòng) destory 從而導緻應用(yòng)程序中出現内存洩漏。如果資源不依賴垃圾回收,這(zhè)将不會有問題。
/**
* Called after the resource corresponding to asyncId is destroyed
* @param asyncId a unique ID for the async resource
*/
destroy?(asyncId: number): void;
promiseResolve
當傳遞給 Promise 構造函數的(de) resolve() 函數執行時(shí)觸發 promiseResolve 回調。
/**
* Called when a promise has resolve() called. This may not be in the same execution id
* as the promise itself.
* @param asyncId the unique id for the promise that was resolve()d.
*/
promiseResolve?(asyncId: number): void;
以下(xià)代碼會觸發兩次 promiseResolve() 回調,第一次是我們直接調用(yòng)的(de) resolve() 函數,第二次是在 .then() 裏雖然我們沒有顯示的(de)調用(yòng),但是它也(yě)會返回一個(gè) Promise 所以還(hái)會被再次調用(yòng)。
const hooks = asyncHooks.createHook({
promiseResolve(asyncId) {
syncLog('promiseResolve: ', asyncId);
}
});
new Promise((resolve) => resolve(true)).then((a) => {});
// 輸出結果
promiseResolve: 2
promiseResolve: 3
注意 init 回調裏寫日志造成 “棧溢出” 問題
一個(gè)異步資源的(de)生命周期中第一個(gè)階段 init 回調是當構造一個(gè)可(kě)能發出異步事件的(de)類時(shí)會調用(yòng),要注意由于使用(yòng) console.log() 輸出日志到控制台是一個(gè)異步操作,在 AsyncHooks 回調函數中使用(yòng)類似的(de)異步操作将會再次觸發 init 回調函數,進而導緻無限遞歸出現 RangeError: Maximum call stack size exceeded 錯誤,也(yě)就是 “ 棧溢出”。
調試時(shí),一個(gè)簡單的(de)記錄日志的(de)方式是使用(yòng) fs.writeFileSync() 以同步的(de)方式寫入日志,這(zhè)将不會觸發 AsyncHooks 的(de) init 回調函數。
const syncLog = (...args) => fs.writeFileSync('log.txt', `${util.format(...args)}\n`, { flag: 'a' });
const hooks = asyncHooks.createHook({
init(asyncId, type, triggerAsyncId, resource) {
syncLog('init: ', asyncId, type, triggerAsyncId)
}
});
hooks.enable();
fs.open('hello.txt', (err, res) => {
syncLog(`fs.open asyncId: ${asyncId()}, fs.open triggerAsyncId: ${triggerAsyncId()}`);
});
輸出以下(xià)内容,init 回調隻會被調用(yòng)一次,因爲 fs.writeFileSync 是同步的(de)是不會觸發 hooks 回調的(de)。
init: 2 FSREQCALLBACK 1
fs.open asyncId: 2, fs.open triggerAsyncId: 1
異步之間共享上下(xià)文
Node.js v13.10.0 增加了(le) async_hooks 模塊的(de) AsyncLocalStorage 類,可(kě)用(yòng)于在一系列異步調用(yòng)中共享數據。
如下(xià)例所示,asyncLocalStorage.run() 函數第一個(gè)參數是存儲我們在異步調用(yòng)中所需要訪問的(de)共享數據,第二個(gè)參數是一個(gè)異步函數,我們在 setTimeout() 的(de)回調函數裏又調用(yòng)了(le) test2 函數,這(zhè)一系列的(de)異步操作都不影(yǐng)響我們在需要的(de)地方去獲取 asyncLocalStorage.run() 函數中存儲的(de)共享數據。
const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();
asyncLocalStorage.run({ traceId: 1 }, test1);
async function test1() {
setTimeout(() => test2(), 2000);
}
async function test2() {
console.log(asyncLocalStorage.getStore().traceId);
}
AsyncLocalStorage 用(yòng)途很多(duō),例如在服務端必不可(kě)少的(de)日志分(fēn)析,一個(gè) HTTP 從請求到響應整個(gè)系統交互的(de)日志輸出如果能通(tōng)過一個(gè) traceId 來(lái)關聯,在分(fēn)析日志時(shí)也(yě)就能夠清晰的(de)看到整個(gè)調用(yòng)鏈路。
下(xià)面是一個(gè) HTTP 請求的(de)簡單示例,模拟了(le)異步處理(lǐ),并且在日志輸出時(shí)去追蹤存儲的(de) id
const http = require('http');
const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();
function logWithId(msg) {
const id = asyncLocalStorage.getStore();
console.log(`${id !== undefined ? id : '-'}:`, msg);
}
let idSeq = 0;
http.createServer((req, res) => {
asyncLocalStorage.run(idSeq++, () => {
logWithId('start');
setImmediate(() => {
logWithId('processing...');
setTimeout(() => {
logWithId('finish');
res.end();
}, 2000)
});
});
}).listen(8080);
下(xià)面是運行結果,我在第一次調用(yòng)之後直接調用(yòng)了(le)第二次,可(kě)以看到我們存儲的(de) id 信息與我們的(de)日志一起成功的(de)打印了(le)出來(lái)。
image.png
在下(xià)一節會詳細介紹, 如何在 Node.js 中使用(yòng) async hooks 模塊的(de) AsyncLocalStorage 類處理(lǐ)請求上下(xià)文, 也(yě)會詳細講解 AsyncLocalStorage 類是如何實現的(de)本地存儲。