探索 Node-js 異步 Hooks

你聽說過 Node.js 的 async hooks [1] 模塊嗎?如果沒有,那你應該瞭解一下。

儘管它是與 Node.js 9 一起發佈的新特性,但是因爲該模塊仍處於測試階段,我並不建議將其用於生產環境,不過你仍然應該對它有所瞭解。

簡而言之,Node.js 中的異步掛鉤,具體來說是 async_hooks 模塊,提供了一個清晰易用的 API 去追蹤 Node.js 中的異步資源。

該 API 最簡單的使用方式就是用 JS 中的 require import:

const async_hooks = require('async_hooks');

我們在這裏討論的異步特性指的是 Node.js 創建的具有關聯回調的對象,與回調可能被調用多少次沒有關係。這就有很多種類了例如:Promises、創建服務的操作、超時等。

請記住,大多數語言都可以關閉資源。其中一些通過容器關閉,其他的則是通過語言本身關閉。所以你的回調函數可能自始至終都沒有被調用過。但是沒有關係,AsyncHook 不會區分這些不同的情況。

這篇文章的目的是爲了更深入的探討 hooks,並且嘗試通過一些示例幫助你更深入的理解。準備好了嗎?

👋 在探索異步掛鉤時,你可能還希望瞭解 AppSignal forNode.js[2]。我們爲你提供對 Node.js Core,Express,Next.js,Apollo Server,node-postgres 和 node-redis 的現成支持 [3]。

API 使用

我總是覺得官方文檔過於複雜以及苛刻。這就是爲什麼我通常會選擇傳統、友好的博客文章。

讓我們首先了解一下 Async Hooks API 提供的 5 個可用事件函數:

非常的簡單直接,接下來讓我們看一個基本的例子:

const myFirstAsyncHook = async_hooks.createHook({ init, before, after, destroy, promiseResolve });

是的,你必須先創建每個事件函數,然後再將其分配給 createHook 函數。另外,必須顯式啓用該掛鉤:

myFirstAsyncHook.enable();

讓我們繼續看一個更加完整的例子:

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

// Sync write to the console
const writeSomething = (phase, more) ={
   fs.writeSync(
      1,
      `Phase: "${phase}", Exec. Id: ${async_hooks.executionAsyncId()} ${
            more ? ", " + more : ""
        }\n`
   );
};

// Create and enable the hook
const timeoutHook = async_hooks.createHook({
   init(asyncId, type, triggerAsyncId) {
      writeSomething(
         "Init",
         `asyncId: ${asyncId}, type: "${type}", triggerAsyncId: ${triggerAsyncId}`
      );
   },
   before(asyncId) {
      writeSomething("Before"`asyncId: ${asyncId}`);
   },
   destroy(asyncId) {
      writeSomething("Destroy"`asyncId: ${asyncId}`);
   },
   after(asyncId) {
      writeSomething("After"`asyncId: ${asyncId}`);
   },
});
timeoutHook.enable();

writeSomething("Before call");

// Set the timeout
setTimeout(() ={
   writeSomething("Exec. Timeout");
}, 1000);

這個例子通過衆所周知的原生函數 setTimeout 去追蹤超時的異步執行過程。

在我們深入研究之前,先快速瀏覽一下第一個函數 writeSomething 。你也許很好奇爲什麼在我們已經有函數可以在控制檯輸出的情況下仍然創建了一個新的函數去完成相同的功能。

原因是你不能使用任何 console 函數去測試異步鉤子,因爲它們本身就是異步的。因此當我們在下面提供了一個 init 函數時,它會產生一個無限循環。該函數會調用 consolelog ,此日誌又會再次觸發初始化,以此類推,陷入死循環。

這就是爲什麼我們需要重新寫一個 “同步” 日誌功能。

好了,現在我們回過頭去看代碼。我們的異步鉤子提供了四個功能:initbeforeafter 以及 destory。而且,我們還在超時之前和執行期間打印一條消息,所以你可以看到整個過程是如何線性進行的。

在你的命令行執行 node index.js,你會得到如下圖所示的結果:

觀察下鉤子是如何一步一步執行追蹤的。看起來是一種很有趣的跟蹤方式,尤其是當你考慮將數據輸入到監視工具中或者是你已經使用的日誌追蹤工具。

一個 Promise 例子

讓我們看看我們的示例在 Promise 下的執行效果。思考下面這些代碼片段:

const calcPow = async(n, exp) ={
   writeSomething("Exec. Promise");

   return Math.pow(n, exp);
};

(async() ={
   await calcPow(3, 4);
})();

你也可以用之前的 setTimeout 示例來替代這個例子。在這段代碼中,我們有一個異步函數用來進行冪運算。同時也有一個相同的函數在異步塊中被調用。到目前爲止,Node.js 創建了兩個 Promise。

下圖是日誌記錄的結果:

奇怪的是,我們有兩個 Promise,卻調用了三次 init 函數。不用擔心,這是因爲 Node.js 團隊在版本 12 中引入了異步執行性能方面的一些最新改進。你可以點擊此處 [4] 瞭解更多信息。

儘管如此,執行過程依然符合我們的預期。

解析:鉤子函數的性能與度量

Node.js 提供的另一個非常有趣的 API 是性能評估 API[5],既然我們在這裏討論度量,爲什麼不結合兩者的功能來了解我們可以收穫什麼呢?

可以通過 perf_hooks 獲得該 API, 該 API 讓我們能夠用與 W3C Web Performance API[6] 相似的方式來獲得性能 / 用戶時間軸指標。

將它與異步鉤子相結合我們可以做一些事情,比如追蹤異步函數執行完畢需要的時間。讓我們看另外一個例子:

const async_hooks = require("async_hooks");
const {
   performance,
   PerformanceObserver
} = require("perf_hooks");

const hook = async_hooks.createHook({
   init(asyncId) {
      performance.mark(`init-${asyncId}`);
   },
   destroy(asyncId) {
      performance.mark(`destroy-${asyncId}`);
      performance.measure(
         `entry-${asyncId}`,
         `init-${asyncId}`,
         `destroy-${asyncId}`
      );
   },
});
hook.enable();

const observer = new PerformanceObserver((data) =>
   console.log(data.getEntries())
);
observer.observe({
   entryTypes: ["measure"],
   buffered: true
});

setTimeout(() ={
   console.log("I'm a timeout");
}, 1200);

既然我們只是追蹤記錄執行時間,就沒有必要用之前用的中間事件函數。用 initdestroy 就足夠了。

就像異步鉤子那樣,性能 API 通過創建觀察者來工作。不過,無論什麼時候開始或者結束,你都必須明確標記每個事件的 id。這樣,當我們調用 API 的 measure 函數時,它將彙總收集到的數據並將其立即發送給觀察者,觀察者將爲我們記錄全部的日誌。

注意了,這裏我們使用了兩次 console.log 函數。第一次是無影響的因爲它包含在觀察者中執行。但是第二次它在 setTimeout 函數中執行,另一個異步中的異步,這意味着在最後它會產生不同的輸出。

下圖是日誌記錄:

本示例本並沒有考慮事件類型之間的差異。在這裏,我們在同一測量場景中發生了超時和異步日誌操作。

但是,考慮到生產環境,建議你創建一個更強大的機制在每次調用 init 時存儲事件類型,並在稍後調用 destroy 函數,倒黴的沒有接收到參數類型時檢查存儲是否依然存在。

異步資源

Async Hooks 中的另一個有用功能是 AsyncResource[7] 類。每當你爲框架或庫創建自己的資源時,它都會爲你提供幫助。

只需輸入以下代碼即可使用:

const AsyncResource = require('async_hooks').AsyncResource;

用這種方式,你可以使用它實例化一個新對象,並手動定義其每個階段在整個代碼中何時開始。舉個例子:

const resource = new AsyncResource('MyOwnResource');

someFunction(function someCallback() {
   resource.emitBefore();

   // do your stuff...

   resource.emitAfter();
});

someOnClose() {
   resource.emitDestroy();
}

這仍是資源生命週期的一個示例,如果要綁定本地的 C++ 代碼,我們更建議使用它。我將爲你提供官方文檔中的一個很好的例子 [8] 來簡化它。

結論

就像我們討論的那樣,異步鉤子仍處於實驗階段。因此,要謹慎使用它。

由於 hooks 僅在 Node.js 8 及更高版本中可用,因此你可以考慮遷移 Node.js 版本(很多時候這是不太合適的方法)或使用社區中的替代工具,例如 async-tracer[9]。

參考資料

[1]

async hooks : https://nodejs.org/dist/latest-v13.x/docs/api/async_hooks.html

[2]

AppSignal forNode.js: https://appsignal.com/nodejs

[3]

我們爲你提供對 Node.js Core,Express,Next.js,Apollo Server,node-postgres 和 node-redis 的現成支持: https://blog.appsignal.com/2020/10/07/launching-appsignal-monitoring-for-nodejs.html

[4]

點擊此處: https://v8.dev/blog/fast-async

[5]

性能評估 API: https://nodejs.org/dist/latest-v12.x/docs/api/perf_hooks.html

[6]

W3C Web Performance API: https://w3c.github.io/perf-timing-primer/

[7]

AsyncResource: https://nodejs.org/dist/latest-v13.x/docs/api/async_hooks.html#async_hooks_class_asyncresource

[8]

很好的例子: https://nodejs.org/dist/latest-v13.x/docs/api/async_hooks.html#async_hooks_using_asyncresource_for_a_worker_thread_pool

[9]

async-tracer: https://github.com/davidmarkclements/async-tracer

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CHEd168oWvCaL90juv2YBw