nodejs從版本8以上提供async_hooks奄容,async_hooks很有意思雕擂,可以完成許多高級(jí)的功能啡邑,但使用起來有點(diǎn)費(fèi)解。
一井赌、背景
要了解asnyc_hooks谤逼,我們需要先從nodejs的異步IO談起
nodejs是一種單線程的語言,單線程并不是說nodejs進(jìn)程中真的只包含一個(gè)線程仇穗,而是nodejs只有一個(gè)主線程用于處理業(yè)務(wù)邏輯流部。余下的線程是為支持nodejs的異步io功能,被叫做io線程纹坐,io線程主要用于接收主線程的io事件請求枝冀,然后發(fā)起io調(diào)用,并將io調(diào)用的結(jié)果傳遞到主線程耘子。如下圖所示:
nodejs中進(jìn)行異步io調(diào)用通常類似于:
function callback(err, data){
}
obj.ioMethod(arg, callback);
例如異步讀取文件的代碼示例如下所示:
var fs = require("fs");
fs.readFile("a.txt", function(err, data){
if(err){
console.log(err);
} else {
console.log(data);
}
})
從上面異步io的流程中可以看出果漾, readFile方法完成(返回)時(shí),數(shù)據(jù)并沒有真正讀回來谷誓,等io線程完成文件讀取之后跨晴,主線程檢測到此事件之后,才會(huì)執(zhí)行callback處理讀取回來的數(shù)據(jù)(此處只是直接輸出到console)片林。由此可知端盆,readFile和callback雖然同樣是主線程執(zhí)行的,但其實(shí)并沒有多大的關(guān)聯(lián)费封,也就是說焕妙,我們無法從主線程的執(zhí)行順序來判定這兩個(gè)操作是否是同一個(gè)調(diào)用鏈。所以當(dāng)callback拋出異常時(shí)弓摘,我們其實(shí)得不到一個(gè)完整的調(diào)用棧焚鹊。或者韧献,我們需要追蹤一個(gè)調(diào)用鏈末患,并且分別統(tǒng)計(jì)每個(gè)調(diào)用環(huán)節(jié)的耗時(shí)研叫,因?yàn)楫惒絠o的原因,就無法完成了璧针。
當(dāng)然嚷炉,上述問題,還是有一些解決方法的探橱,下面介紹一些可行的方案申屹。
二. AsyncListener + Continuation-Local Storage
AsyncListener提供了在異步調(diào)用時(shí)添加listener功能的機(jī)制,并且隧膏,AsyncListener能保證調(diào)用鏈的完整哗讥。
Continuation-Local Storage(簡稱CLS),在AsyncListener的基礎(chǔ)上胞枕,提供了更宜于使用的api杆煞。
三. async_hooks模塊介紹
從nodejs8開始,原生提供了類似于AsyncListener的功能腐泻,官方API文檔 索绪。async_hooks,模塊提供了一個(gè)用于注冊回調(diào)函數(shù)的 API贫悄,這些回調(diào)函數(shù)可追蹤在 Node.js 應(yīng)用中創(chuàng)建的異步資源的生命周期瑞驱。
async_hooks提供了有限的幾個(gè)api,createHook
窄坦,enable
唤反,disable
,executionAsyncId
鸭津,triggerAsyncId
彤侍。這幾個(gè)api即可為調(diào)用鏈的追蹤提供可能。其中的enable和disable非常容易理解逆趋,即開啟或者停止此功能盏阶。createHook比較復(fù)雜,在理解了此方法之后闻书,executionAsyncId
和triggerAsyncId
就很好理解了名斟。createHook方法定義如下:
async_hooks.createHook(callbacks)
Added in: v8.1.0
-
callbacks
<Object> the callbacks to register - Returns:
{AsyncHook}
instance used for disabling and enabling hooks
createHook方法主要用于注冊一系列回調(diào)函數(shù),這些回調(diào)函數(shù)會(huì)在異步操作的各個(gè)生命周期的事件中被調(diào)用魄眉∨檠危回調(diào)函數(shù)包含4個(gè),init()
/before()
/after()
/destroy()
坑律,當(dāng)然視業(yè)務(wù)不同岩梳,有時(shí)候并不需要提供完整的4個(gè)回調(diào)函數(shù)。
在開始說明這些回調(diào)函數(shù)的作用之前,先說明一件事件冀值,我們可能試圖在回調(diào)函數(shù)中調(diào)用console.log來打印出一些信息也物,但是console.log本身就是一個(gè)異步操作, 這會(huì)導(dǎo)致另一個(gè)異步事件的產(chǎn)生列疗,而另一個(gè)異步事件又會(huì)試圖調(diào)用console.log打印信息滑蚯,所以會(huì)得到一個(gè)死循環(huán)。為了解決這個(gè)問題作彤,官方給出了方法,可以使用fs的writeSync方法來輸出打印數(shù)據(jù)乌逐,例如:
fs.writeSync(1, `${type}(${asyncId}): trigger: ${triggerAsyncId} execution: ${eid}\n`);
下面我們依次來看幾個(gè)回調(diào)函數(shù)的定義竭讳,以及參數(shù)的含義:
init(asyncId, type, triggerAsyncId, resource)
-
asyncId
<number> 異步資源的唯一ID -
type
<string> 異步資源的類型,這是nodejs內(nèi)部定義好的浙踢,當(dāng)然也提供了自定義的方案 -
triggerAsyncId
<number> 創(chuàng)建此異步資源的執(zhí)行上下文的唯一ID绢慢,即此資源調(diào)用鏈上的父ID -
resource
<Object> reference to the resource representing the async operation, needs to be released during destroy
asyncId
: 當(dāng)一個(gè)可能觸發(fā)異步事件的類初始化的時(shí)候,init方法將會(huì)被調(diào)用洛波。然而隨后的before()
/after()
/destroy()
并不是一會(huì)被調(diào)用胰舆。每個(gè)異步資源,會(huì)被分配一個(gè)唯一的id蹬挤,即: asyncId缚窿。
type
: 一個(gè)表示引發(fā)init
被調(diào)用的異步資源的類型,目前內(nèi)置了部分焰扳,也可以自定義倦零,詳見官方api文檔。
triggerAsyncId
: 表示創(chuàng)建引發(fā)init
回調(diào)的異步資源的異步資源id吨悍,可以理解為目前異步資源的父id扫茅。
resource
: 一個(gè)代表異步資源的對(duì)象,可以從此對(duì)象中獲得一些異步資源相關(guān)的數(shù)據(jù)育瓜。比如: GETADDRINFOREQWRAP
類型的異步資源對(duì)象葫隙,提供了hostname。
before(asyncId)
asyncId
<number>
當(dāng)一個(gè)異步操作初始化(例如 TCP服務(wù)端接收到一個(gè)新的連接)或者完成時(shí)(例如寫數(shù)據(jù)到磁盤)其對(duì)應(yīng)的回調(diào)函數(shù)將被調(diào)用躏仇。before
回調(diào)將會(huì)在異步操作的回調(diào)執(zhí)行之前被調(diào)用恋脚。asyncId
表示執(zhí)行回調(diào)函數(shù)的異步資源的唯一ID。理論上異步資源的回調(diào)將會(huì)被執(zhí)行0或者多次焰手,因此before回調(diào)也可能被執(zhí)行0到多次慧起。
after(asyncId)
asyncId
<number>
after回調(diào),會(huì)在異步資源的回調(diào)被執(zhí)行之后立即調(diào)用册倒。destroy(asyncId)
asyncId
<number>
當(dāng)asyncId代表的資源被銷毀的時(shí)候蚓挤,destrory回調(diào)被調(diào)用。或者當(dāng)通過內(nèi)置的api emitDestroy()進(jìn)行異步調(diào)用時(shí)灿意。
四估灿、async_hooks使用示例
知道了async_hooks模塊中的主要方法,以及方法的參數(shù)之后缤剧,我們以讀取文件為例馅袁,將其中的調(diào)用鏈顯示出來如下:
'use strict';
const async_hooks = require('async_hooks');
const fs = require('fs');
let indent = 0;
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
const eid = async_hooks.executionAsyncId();
const indentStr = ' '.repeat(indent);
fs.writeSync(
1,
`${indentStr}${type}(${asyncId}):` +
` trigger: ${triggerAsyncId} execution: ${eid}\n`);
},
before(asyncId) {
const indentStr = ' '.repeat(indent);
fs.writeSync(1, `${indentStr}before: ${asyncId}\n`);
indent += 2;
},
after(asyncId) {
indent -= 2;
const indentStr = ' '.repeat(indent);
fs.writeSync(1, `${indentStr}after: ${asyncId}\n`);
},
destroy(asyncId) {
const indentStr = ' '.repeat(indent);
fs.writeSync(1, `${indentStr}destroy: ${asyncId}\n`);
},
}).enable();
fs.readFile("a.txt", 'utf8', function(err, data){
});
fs.readFile("b.txt", 'utf8', function(err, data){
});
上面的代碼,并行讀取a.txt和b.txt荒辕,從nodejs的異步特性來看汗销,a.txt和b.txt應(yīng)該是兩個(gè)不同的調(diào)用鏈,運(yùn)行上面代碼抵窒,輸出的結(jié)果如下:
peachcat@peachcat:~/nodejs $ node async_hooks.js
FSREQWRAP(2): trigger: 1 execution: 1
FSREQWRAP(3): trigger: 1 execution: 1
before: 2
FSREQWRAP(4): trigger: 2 execution: 2
after: 2
before: 3
FSREQWRAP(5): trigger: 3 execution: 3
after: 3
destroy: 2
destroy: 3
before: 4
FSREQWRAP(6): trigger: 4 execution: 4
after: 4
before: 5
FSREQWRAP(7): trigger: 5 execution: 5
after: 5
destroy: 4
destroy: 5
before: 6
FSREQWRAP(8): trigger: 6 execution: 6
after: 6
before: 7
FSREQWRAP(9): trigger: 7 execution: 7
after: 7
destroy: 6
destroy: 7
before: 8
after: 8
before: 9
after: 9
destroy: 8
destroy: 9
從輸出結(jié)果弛针,可以看出,a.txt的調(diào)用鏈路為:
1 -> 2 -> 4 -> 6 -> 8 李皇,
而b.txt的調(diào)用鏈路為:
1 -> 3 -> 5 -> 7 -> 9削茁,
可以看出,根結(jié)構(gòu)是一樣的掉房,從第二個(gè)操作開始茧跋,就是各自不同的鏈路了。我們將a和b的操作卓囚,修改為在a.txt的回調(diào)中去讀取文件b.txt瘾杭,相應(yīng)的修改代碼為:
fs.readFile("a.txt", 'utf8', function(err, data){
fs.readFile("b.txt", 'utf8', function(err, data){
});
});
調(diào)用輸出結(jié)果如下:
FSREQWRAP(2): trigger: 1 execution: 1
before: 2
FSREQWRAP(3): trigger: 2 execution: 2
after: 2
destroy: 2
before: 3
FSREQWRAP(4): trigger: 3 execution: 3
after: 3
destroy: 3
before: 4
FSREQWRAP(5): trigger: 4 execution: 4
after: 4
destroy: 4
before: 5
FSREQWRAP(6): trigger: 5 execution: 5
after: 5
destroy: 5
before: 6
FSREQWRAP(7): trigger: 6 execution: 6
after: 6
destroy: 6
before: 7
FSREQWRAP(8): trigger: 7 execution: 7
after: 7
destroy: 7
before: 8
FSREQWRAP(9): trigger: 8 execution: 8
after: 8
destroy: 8
before: 9
after: 9
destroy: 9
由上面輸出可以得知,此次調(diào)用的鏈路為:
1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9
五哪亿、使用async_hooks傳遞上下文信息
async_hooks提供的這些功能富寿,可以擴(kuò)展出一些非常實(shí)用的應(yīng)用場景。比如可以將原來由于異步回調(diào)锣夹,導(dǎo)致異常錯(cuò)誤棧無法顯示完全页徐,可以使用async_hooks功能進(jìn)行修復(fù)。更進(jìn)一步银萍,我們可以使用async_hooks完成類似于java中的ThreadLocal的功能”溆拢現(xiàn)在已經(jīng)有現(xiàn)成的npm包使用async_hooks,提供了在調(diào)用上下文中保存一些數(shù)據(jù)的功能贴唇。比如:asyncctx搀绣,其核心代碼非常少,我們來分析一下實(shí)現(xiàn)原理戳气。
class ContinuationLocalStorage {
constructor() {
this.initMap();
this.hookFuncs = {
init: (id, type, triggerId) => {
// a new async handle gets initialized:
const oriTriggerId = triggerId;
if (triggerId == null) {
triggerId = this._currId;
}
let triggerHook = this.idHookMap.get(triggerId);
if (!triggerHook) {
triggerId = ROOT_ID;
triggerHook = this.idHookMap.get(triggerId);
}
else {
while (triggerHook.type === 'PROMISE' && !triggerHook.activated &&
this.idHookMap.has(triggerHook.triggerId)) {
triggerId = triggerHook.triggerId;
triggerHook = this.idHookMap.get(triggerId);
}
}
this.idHookMap.set(id, { id, type, triggerId, oriTriggerId, triggerHook, activated: false });
},
before: (id) => {
// an async handle starts
this._currId = id;
let hi = this.idHookMap.get(id);
if (hi) {
if (!hi.activated) {
hi.data = hi.triggerHook ? hi.triggerHook.data : undefined;
}
hi.activated = true;
}
else {
this._currId = ROOT_ID;
}
},
after: (id) => {
// an async handle ends
if (id === this._currId) {
this._currId = ROOT_ID;
}
},
destroy: (id) => {
// an async handle gets destroyed
if (this.idHookMap.has(id)) {
if (id === this._currId) {
nodeproc._rawDebug(`asyncctx: destroy hook called for current context (id: ${this.currId})!`);
}
this.idHookMap.delete(id);
}
}
};
this.hookInstance = asyncHooks.createHook(this.hookFuncs);
this.enable();
}
get currId() { return this._currId; }
getContext() {
let hi = this.idHookMap.get(this.currId);
return hi ? hi.data : undefined;
}
setContext(value) {
let hi = this.idHookMap.get(this.currId);
if (!hi) {
throw new Error('setContext must be called in an async context!');
}
hi.data = value;
return value;
}
getRootContext() {
let hi = this.idHookMap.get(ROOT_ID);
if (!hi) {
throw new Error('internal error: root node not found (1)!');
}
return hi ? hi.data : undefined;
}
setRootContext(value) {
let hi = this.idHookMap.get(ROOT_ID);
if (!hi) {
throw new Error('internal error: root node not found (2)!');
}
hi.data = value;
return value;
}
initMap(value) {
this.idHookMap = new Map();
this.idHookMap.set(ROOT_ID, { id: ROOT_ID, type: 'C++', triggerId: 0, activated: true });
this._currId = ROOT_ID;
if (value) {
this.setRootContext(value);
}
}
}
上面的代碼链患,使用了一個(gè)Map來保存每個(gè)調(diào)用鏈的上下文數(shù)據(jù),由initMap方法進(jìn)行初始化瓶您,并且生成ROOT_ID(值為1)麻捻,保存在Map中的數(shù)據(jù)纲仍,以當(dāng)前操作的id為key,值為一個(gè)對(duì)象贸毕,其中triggerId表示其父操作的id郑叠,由此,可以在Map中記錄一個(gè)樹形結(jié)構(gòu)的上下文鏈明棍。
六乡革、使用asyncctx,實(shí)現(xiàn)調(diào)用鏈時(shí)間統(tǒng)計(jì)
通過上面的async_hooks原理和擴(kuò)展的npm包的說明摊腋,我們已經(jīng)可以考慮使用async_hooks功能沸版,來完成一些實(shí)際的事情的。下面我們使用asyncctx來完成一個(gè)統(tǒng)計(jì)調(diào)用鏈中兴蒸,統(tǒng)計(jì)各個(gè)異步資源耗時(shí)的功能视粮。
- 首先,我們需要定義一個(gè)數(shù)據(jù)結(jié)構(gòu)类咧,用于保存調(diào)用鏈中各節(jié)點(diǎn)的耗時(shí)和節(jié)點(diǎn)的父子關(guān)系馒铃,以及調(diào)用的開始和結(jié)束時(shí)間蟹腾,為此我們定義一個(gè)Span類:
'use strict';
const {ContinuationLocalStorage} = require('asyncctx');
const fs = require('fs');
const cls = new ContinuationLocalStorage();
class Span {
constructor(operationName, parent) {
this.operationName = operationName;
this.parent = parent;
this.start = (new Date()).getTime();
this.end = 0;
}
finish(){
if(this.parent){
this.parent.finish();
}
this.end = (new Date()).getTime();
}
toString() {
let str = `Operation: ${this.operationName}, ${this.end - this.start}ms`;
if(this.parent){
str += `, Parent: ${this.parent.operationName}\n`;
str += this.parent.toString();
}
return str;
}
}
Span的構(gòu)造方法中痕惋,需要傳遞一個(gè)操作名稱,以及其父節(jié)點(diǎn)娃殖,我們可以想像到值戳,父節(jié)點(diǎn)的獲取需要使用asyncctx來完成,因?yàn)椴僮髦g是異步的炉爆,我們必須要使用asynccxt來傳遞父子關(guān)系堕虹。初始化Span對(duì)象時(shí),記錄當(dāng)前的時(shí)間芬首。其中的finish方法赴捞,用于設(shè)置結(jié)束時(shí)間,并且同時(shí)結(jié)束父節(jié)點(diǎn)的耗時(shí)統(tǒng)計(jì)(我們演示的例子郁稍,為單一嵌套的赦政,若有并行請求的時(shí)候,此做法是不可行的)耀怜。toString()方法用于輸出節(jié)點(diǎn)統(tǒng)計(jì)的耗時(shí)恢着,以及父節(jié)點(diǎn)的信息。
我們打算繼續(xù)使用fs.readFile來做為演示的例子财破,為了更接近于真實(shí)的使用場景掰派,我們對(duì)fs.readFile做一下改造。
- 改造fs.readFile
let originReadFile = fs.readFile;
fs.readFile = function(file, callback){
let ctx = cls.getContext();
let span = new Span(`Read ${file}`, ctx);
cls.setContext(span);
originReadFile(file, function(err, data){
callback(err, data);
span.finish();
});
}
為了使用fs.readFile時(shí)左痢,無代碼入侵靡羡,我們重寫了readFile方法系洛,在調(diào)用原始方法之前,開啟統(tǒng)計(jì)亿眠,調(diào)用結(jié)束之后碎罚,結(jié)束統(tǒng)計(jì)。由代碼中可知纳像,span的父節(jié)點(diǎn)是直接通過ctx.getContext()獲取的荆烈。
- 調(diào)用代碼
let rootSpan = new Span('root', null); //開啟一個(gè)rootSpan做為根節(jié)點(diǎn)
cls.setRootContext(rootSpan);
fs.readFile("a.txt", function(err, data){
fs.readFile("b.txt", function(err, data){
//完成所有的讀取操作,獲取最后一個(gè)span竟趾,結(jié)束掉憔购,并且打印出調(diào)用鏈結(jié)果
let span = cls.getContext();
span.finish();
console.log(span.toString());
});
});
運(yùn)行代碼,輸出的結(jié)果如下:
peachcat@peachcat:~/nodejs $ node async.js
Operation: Read b.txt, 1ms, Parent: Read a.txt
Operation: Read a.txt, 2ms, Parent: root
Operation: root, 2ms
由上面輸出結(jié)果可以看出來岔帽,同我們預(yù)想的一樣玫鸟,root -> a.txt -> b.txt的調(diào)用順序,并且root的耗時(shí)犀勒,包含了后面兩個(gè)操作的時(shí)間屎飘,a.txt的讀取時(shí)間,包含了b.txt的時(shí)間贾费。
七钦购、參考文檔
- nodejs 官方API: http://nodejs.cn/api/async_hooks.html
- asyncctx: https://github.com/gms1/node-async-context