Node.js 異步延續(xù)模型
作者:昭朗
來源:Node地下鐵
異步執(zhí)行在 Node.js 中是非常基本的操作,但是一個(gè) Uncaught Exception 的報(bào)錯(cuò)就可能讓我們摸不著頭腦,是什么地址的 TLS 訪問 ECONNRESET 了?
[node:12345] Uncaught Exception: Error: read ECONNRESET
? ?at TLSWrap.onStreamRead (internal/stream_base_commons.js:111:27)
? ?// 真的沒了
異步延續(xù)模型
Node.js 使用的 JavaScript 單線程執(zhí)行模型簡化了非常多的問題。而為了防止 IO 阻塞 JavaScript 執(zhí)行線程,IO 操作在關(guān)聯(lián)了 JavaScript 回調(diào)函數(shù)后就被放入了后臺處理。當(dāng) IO 操作完成后,與其關(guān)聯(lián)的 JavaScript 回調(diào)會被放入事件隊(duì)列等待在 JavaScript 線程調(diào)用,可以在這里[鏈接1]了解更多 Node.js 事件循環(huán)的詳情。
這個(gè)模型有很多好處,但是也有一個(gè)關(guān)鍵的挑戰(zhàn):異步資源與操作的上下文管理。什么是異步操作上下文?異步操作的上下文就是給定一個(gè)異步操作,我們能夠通過異步上下文知道這個(gè)異步操作是因?yàn)槭裁从|發(fā)執(zhí)行的,接下來可以觸發(fā)其他什么異步操作。Semantics?of Asynchronous?JavaScript [鏈接2] 通過非常精確的描述方法描述了異步資源的“上下文”,但是我們只想回答一個(gè)問題,在程序的任意一個(gè)執(zhí)行時(shí)間點(diǎn),“我們是通過什么樣的異步函數(shù)執(zhí)行路徑執(zhí)行到現(xiàn)在這個(gè)代碼位置的”?
為了回答這個(gè)問題,我們先明確幾個(gè)關(guān)鍵點(diǎn):
執(zhí)行楨 (Execution Frame) - 執(zhí)行楨是程序中后繼函數(shù)的一次執(zhí)行過程。可以把執(zhí)行楨當(dāng)作從一個(gè)特定的函數(shù)執(zhí)行楨被推入執(zhí)行棧開始,直到這個(gè)執(zhí)行楨執(zhí)行結(jié)束被彈出調(diào)用棧,這樣一段時(shí)間片段。不是所有的函數(shù)都是后繼函數(shù) (Continuation),一個(gè)特定的后繼函數(shù)可以被調(diào)用多次,每一次執(zhí)行都對應(yīng)一個(gè)獨(dú)立的執(zhí)行楨。
后繼函數(shù) (Continuation) - 后繼函數(shù)是在執(zhí)行楨中創(chuàng)建的 JavaScript 函數(shù),并且會在后續(xù)被作為異步回調(diào)執(zhí)行。當(dāng)被調(diào)用時(shí),后繼函數(shù)會創(chuàng)建一個(gè)獨(dú)立的執(zhí)行楨。比如當(dāng)我們調(diào)用
setTimeout(function c() {}, 1000),setTimeout是在一個(gè)執(zhí)行楨里被調(diào)用的,并且以一個(gè)后繼函數(shù)c作為參數(shù)。當(dāng)c在計(jì)時(shí)到時(shí)后被執(zhí)行時(shí),會創(chuàng)建一個(gè)新的執(zhí)行楨;當(dāng)c執(zhí)行結(jié)束時(shí),即意味著這個(gè)新創(chuàng)建的執(zhí)行楨執(zhí)行結(jié)束。后繼點(diǎn) (Continuration Point) - 后繼點(diǎn)是接受一個(gè)后繼函數(shù)作為參數(shù)的函數(shù)。通常在 JavaScript 中后繼點(diǎn)都是宿主環(huán)境定義的(ECMAScript 規(guī)范中中不存在與 IO 相關(guān)的操作的定義)。比如
setTimeout,也包括Promise.then。值得注意的是,不是所有接收函數(shù)作為參數(shù)的函數(shù)都是后繼點(diǎn),作為后繼點(diǎn),這些函數(shù)參數(shù)需要被異步調(diào)用,即不在當(dāng)前執(zhí)行楨被調(diào)用執(zhí)行。比如Array.prototype.forEach就不算后繼點(diǎn)。鏈接點(diǎn) (Link Point) - 程序運(yùn)行中,當(dāng)一個(gè)后繼點(diǎn)被調(diào)用時(shí),我們稱為鏈接點(diǎn)。這個(gè)時(shí)候需要在當(dāng)前執(zhí)行楨與被作為參數(shù)傳入的后繼函數(shù)之間創(chuàng)建一個(gè)邏輯連接,作為上下文綁定。
就緒點(diǎn) (Ready Point) - 就緒點(diǎn)是程序運(yùn)行中之前鏈接過的后繼函數(shù)被標(biāo)記為“準(zhǔn)備就緒”,準(zhǔn)備被執(zhí)行。這個(gè)過程會在后繼函數(shù)與當(dāng)前執(zhí)行楨之間建立邏輯連接,這個(gè)邏輯連接可以稱為因果關(guān)系。通常就緒點(diǎn)都需要在鏈接點(diǎn)后才能產(chǎn)生,但是 Promise 在這里的處理不太一樣,promise 可以在他的 Promise 鏈中的前置的 promise 被 resolve 時(shí)產(chǎn)生一個(gè)就緒點(diǎn),而此時(shí)不一定需要已經(jīng)生成鏈接點(diǎn)(綁定回調(diào)函數(shù)),如
new Promise(res => res())立刻創(chuàng)建了一個(gè)已經(jīng) resolve 的 Promise,此時(shí)已經(jīng)觸發(fā)了就緒點(diǎn),但是我們還未通過.then鏈接上下一個(gè) Promise。
而上述幾個(gè)關(guān)鍵點(diǎn)可以總結(jié)為以下幾個(gè)事件:
執(zhí)行開始 (
executionBegin): 表示一個(gè)執(zhí)行楨開始執(zhí)行;鏈接 (
link): 表示一個(gè)后繼點(diǎn)被調(diào)用,一個(gè)后繼函數(shù)被放入等待隊(duì)列等待就緒;就緒 (
ready): 表示一個(gè)就緒點(diǎn)被觸發(fā);執(zhí)行結(jié)束 (
executionEnd): 表示一個(gè)執(zhí)行楨執(zhí)行完畢。
這里我們以下面這段代碼舉個(gè)例子:
console.log('starting');
Promise p = new Promise((reject, resolve) => {
?setTimeout(function f1() {
? ?console.log('resolving promise');
? ?resolve();
}, 100);
});
p.then(function f2() {
?console.log('in then');
}這段代碼可以通過以下的事件流描述整個(gè)異步執(zhí)行過程:
{ "event": "executionBegin", "executeID": 0 } // 程序開始執(zhí)行
// starting
{ "event": "link", "executeID": 0, "linkID": 1} // `f1()` 已經(jīng)被鏈接到了 "setTimeout()" 的調(diào)用上
{ "event": "link", "executeID": 0, "linkID": 2} // `f2()` 已經(jīng)被鏈接到了 "p.then()" 的調(diào)用上
{ "event": "executionEnd", "executeID": 0 } // 程序外層代碼執(zhí)行完畢
{ "event": "ready", "executeID": 0, "linkID": 1, "readyID": 3 } // 100ms 計(jì)時(shí)到時(shí),執(zhí)行就緒
{ "event": "executionBegin", "executeID": 4, "readyID": 3 } // f1() 回調(diào)開始執(zhí)行
// resolving promise
{ "event": "ready", "executeID": 4, "linkID": 2, "readyID": 5 } // promise p 被 resolve,標(biāo)記了 "then(function f2()..." 就緒
{ "event": "executionEnd", "executeID": 4 } // f1() 回調(diào)執(zhí)行完畢
{ "event": "executionBegin", "executeID": 6, "readyID": 5 } // f2() 回調(diào)開始執(zhí)行
// in then
{ "event": "executionEnd", "executeID": 6 } // f2() 回調(diào)執(zhí)行完畢現(xiàn)有技術(shù)
async_hooks
async_hooks 即是 Node.js 對上述模型的實(shí)現(xiàn)。其中 async_hooks API 提供了幾個(gè)異步階段的鉤子回調(diào)可以注冊:
[init(asyncId, type, triggerAsyncId, resource): void](https://nodejs.org/api/async_hooks.html#async_hooks_init_asyncid_type_triggerasyncid_resource)- 表示 asyncId 對應(yīng)的異步資源(可以理解為上文中的異步上下文)初始化完成,后續(xù)這個(gè)資源可以觸發(fā)異步回調(diào)(不絕對會觸發(fā),比如一個(gè) HTTP Server 啟動后沒人來請求);[before(asyncId): void](https://nodejs.org/api/async_hooks.html#async_hooks_before_asyncid)- 表示準(zhǔn)備開始執(zhí)行異步回調(diào)函數(shù),而在這個(gè)異步回調(diào)函數(shù)的執(zhí)行楨中,生成的任意異步資源(相當(dāng)于上文中的后繼函數(shù))都會與 asyncId 參數(shù)對應(yīng)的異步資源鏈接,作為觸發(fā)原因;[after(asyncId): void](https://nodejs.org/api/async_hooks.html#async_hooks_after_asyncid)- 表示異步回調(diào)函數(shù)執(zhí)行完畢,停止將asyncId 參數(shù)關(guān)聯(lián)的異步資源與當(dāng)前執(zhí)行楨新創(chuàng)建的異步資源鏈接;[destroy(asyncId): void](https://nodejs.org/api/async_hooks.html#async_hooks_destroy_asyncid)- 表示 asyncId 參數(shù)對應(yīng)的 異步資源被回收,后續(xù)不可能再通過這個(gè)異步資源觸發(fā)回調(diào)。
與 domain 的區(qū)別
部分了解、使用過 domain 模塊的同學(xué)可能會有一個(gè)疑問,async_hooks API 與被廢棄的 domain 有什么區(qū)別?
async_hooks 作為上述異步模型中將各個(gè)異步資源鏈接起來的黏合劑,其本身并不提供任何錯(cuò)誤處理相關(guān)的 API,他的 API 語義也非常清晰,只是對于異步資源的執(zhí)行事件的描述。而 domain 的主要用途是異步錯(cuò)誤的處理,但是因?yàn)樵?domain 提出的時(shí)候還不存在 async_hooks,并且對于異步資源、異步執(zhí)行的語義定義并不清晰,從而導(dǎo)致實(shí)際生產(chǎn)中 domain 的使用非常容易導(dǎo)致錯(cuò)誤并且難以排查(多個(gè) domain 的使用方其中如果使用了不是那么正確的方法,會將 domain 的狀態(tài)攪得一團(tuán)糟)。
而在 async_hooks 實(shí)現(xiàn)了明確的異步資源與執(zhí)行的語義后,domain 的實(shí)現(xiàn)也進(jìn)行了遷移、使用 async_hooks 來實(shí)現(xiàn)對于異步資源回調(diào)的追蹤(實(shí)現(xiàn)詳情可以了解 PR[鏈接3])。
Node.js Add-on 的兼容性
雖然 Node.js 提供的 IO 操作的異步回調(diào)都已經(jīng)被妥善地封裝了異步調(diào)用的上下文切換,但是 Node.js 還提供了 C/C++ Add-on 的 API,這些 Add-on 普通的 napi_call_function 調(diào)用并不會被當(dāng)成是一個(gè)新的執(zhí)行楨,就如同一個(gè) JavaScript 函數(shù)中調(diào)用另一個(gè) JavaScript 函數(shù)。但是如果 Add-on 在異步回調(diào)中也簡單地使用 napi_call_function 就有可能導(dǎo)致 async_hooks 所提供的異步資源 API 出現(xiàn)漏洞。所以 Add-on 需要按照 async_hooks 提供的鉤子的語義,在各個(gè)關(guān)鍵時(shí)間點(diǎn)通過異步資源 API 注冊上,即可完善整個(gè)異步調(diào)用鏈路。但是這樣會給 Add-on 開發(fā)過程造成了一定的負(fù)擔(dān),而為了降低 Add-on 開發(fā)過程出現(xiàn)紕漏的可能。N-API 提供了線程安全的回調(diào) JavaScript 線程的 napi_threadsafe_function 機(jī)制,并且已經(jīng)與異步資源綁定,不需要我們再關(guān)心異步資源的事件管理。
#include
#include
void async_call_js(napi_env env,
? ? ? ? ? ? ? ? ? napi_value js_callback,
? ? ? ? ? ? ? ? ? void* context,
? ? ? ? ? ? ? ? ? void* data)
{
?napi_status status;
?// 將 data 轉(zhuǎn)換成 JavaScript 值
?napi_value value = transform(env, data);
?napi_value recv;
?status = napi_get_null(env, &recv);
?assert(status == napi_ok);
?// N-API 已經(jīng)為我們綁定了異步資源,這里可以安全地使用 `napi_call_function`
?napi_value ret;
?status = napi_call_function(env, recv, js_callback, 1, &value, &ret);
?assert(status == napi_ok);
}
// 會在工作線程被調(diào)用的工作函數(shù)
void do_work(napi_threadsafe_function tsfn)
{
?/** work, work. */
?napi_status status = napi_call_threadsafe_function(tsfn, data, napi_tsfn_nonblocking);
?assert(status == napi_ok);
}
napi_value some_module_method(napi_env env, napi_callback_info info)
{
?napi_status status;
?// 創(chuàng)建與 AsyncResource 綁定的 ThreadSafe Function
?napi_threadsafe_function tsfn;
?status = napi_create_threadsafe_function(env,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? func,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? async_resource,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? async_resource_name,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? max_queue_size,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? initial_thread_count,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? finalize_data,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? finalize_cb,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? context,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? call_js_cb,
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? &tsfn);
?assert(status == napi_ok);
?// 創(chuàng)建工作線程
?create_worker(tsfn, /** 其他參數(shù) */);
?// 返回 JavaScript 值..
?napi_value ret;
?status = napi_get_null(env, &ret);
?assert(status == napi_ok);
?return ret;
}使用場景
異步任務(wù)調(diào)度
在單元測試中,如果我們使用了異步任務(wù),一個(gè)可能比較常見的場景就是這個(gè)異步任務(wù)可能會泄漏出我們的測試函數(shù)執(zhí)行楨導(dǎo)致我們后續(xù)無法追蹤、或者影響了后續(xù)的測試結(jié)果。
我們來看一個(gè)例子:
it('should wait for async test', () => {
?setTimeout(() => {
? ?Promise.resolve(0).then(() => {
? ? ?// only after this code executes will the test complete.
? ? ?console.log('wait for me'));
? }
}, 0);
});在這個(gè)例子中,我們可以看到其中 setTimeout 逃逸出了測試執(zhí)行楨,從而導(dǎo)致測試提早結(jié)束,并且可能影響后續(xù)測試任務(wù)的運(yùn)行(比如在 setTimeout 中拋出了異常)。現(xiàn)在我們可以通過將全部的方法都使用 callback、promise 給串起來,但是這畢竟需要開發(fā)者自行去完成,并且可能出現(xiàn)疏漏,還是會出現(xiàn)例子中的情況。那么我們有沒有可能從語言運(yùn)行時(shí)層面提供一個(gè)“完美”的方案來跟蹤所有的異步任務(wù)呢?通過 async_hooks 的異步資源追蹤能力,我們就可以標(biāo)記所有在測試執(zhí)行過程中創(chuàng)建的異步資源,如果在測試執(zhí)行結(jié)束后,還存在未銷毀的異步資源,就可以更早地將問題暴露。
如我們有下面這個(gè)例子:
const assert = require('assert');
const {createHook, AsyncLocalStorage} = require('async_hooks');
const als = new AsyncLocalStorage();
const backlog = new Map();
createHook({
?init (asyncId, type, triggerAsyncId, resource) {
? ?const test = als.getStore();
? ?if (test == null) {
? ? ?return;
? }
? ?backlog.set(asyncId, { type, triggerAsyncId, resource });
},
?destroy (asyncId) {
? ?backlog.delete(asyncId);
},
?promiseResolve (asyncId) {
? ?backlog.delete(asyncId);
}
}).enable();
const queue = []
function test(name, callback) {
?queue.push({ name, callback });
}
function run() {
?if (queue.length === 0) {
? ?return;
}
?const { name, callback } = queue.pop();
?als.run(name, async () => {
? ?try {
? ? ?await callback();
? } finally {
? ? ?als.exit(() => {
? ? ? ?setImmediate(() => {
? ? ? ? ?assert(backlog.size === 0, `'${name}' ended with dangling async tasks.`);
? ? ? ? ?run();
? ? ? });
? ? });
? }
});
}
process.nextTick(run);
/** 測試聲明開始 */
test('foo', async () => {
?await new Promise(res => setTimeout(res, 100));
?// Pass.
});
test('bar', async () => {
?setTimeout(res, 100);
?// Assert Failed => 'bar' ended with dangling async tasks.
});在這個(gè)例子中,每一次測試開始執(zhí)行前,我們都會在為測試運(yùn)行注冊一個(gè)異步特有數(shù)據(jù)存儲,然后再開始執(zhí)行測試,這樣在測試中發(fā)起的所有異步資源都會被 async_hooks 捕捉到并被測試模塊標(biāo)記,直到這個(gè)異步資源被銷毀(或者是 Promise Resolve)。隨后在測試結(jié)束后,我們再檢查當(dāng)前測試是否有遺留的異步資源,即可確認(rèn)我們的測試是干凈無殘留的。
異步調(diào)用棧/性能診斷
這也是我們開頭的問題。
在越來越多大型的項(xiàng)目使用 Node.js 作為研發(fā)技術(shù)棧后,開發(fā)者們也會越來越關(guān)注問題的診斷便捷性。除了異常錯(cuò)誤排查,現(xiàn)在我們也可以通過 Chrome DevTools 的 CPU Profiler 亦或者是生成火焰圖來診斷我們的 Node.js 應(yīng)用性能表現(xiàn),但是這些工具現(xiàn)在更多的是只能查看某一個(gè)函數(shù)在單個(gè)同步執(zhí)行楨中的調(diào)用鏈路與時(shí)間占用比例,并沒有能力將一個(gè)異步鏈路上每一個(gè)異步操作所花費(fèi)的時(shí)間與百分比描繪出來。
而在能夠串聯(lián)異步鏈路中的異步調(diào)用棧之后,后續(xù)我們也可以在開發(fā)中使用更加直觀的性能剖析工具:

或者是提供線上的請求鏈路追蹤能力,就如同現(xiàn)在各種成熟 APM 提供的應(yīng)用間 RPC 調(diào)用鏈路一樣,我們同樣也可以繪制出應(yīng)用內(nèi)一個(gè)請求到底經(jīng)歷了什么流程,每一步分別花費(fèi)了多少時(shí)間:

AsyncLocalStorage
使用線程作為處理單元的模型中,我們可以使用 ThreadLocal 來存儲對于當(dāng)前線程特有的數(shù)據(jù)信息,那么在 Node.js 的異步模型中,我們有什么辦法可以方便地存儲對于當(dāng)前異步任務(wù)來說特有的數(shù)據(jù)信息呢?
Node.js 在 3 月 4 日發(fā)行的 v13.10.0 版本第一次發(fā)布了 async_hooks.AsyncLocalStorage,可以在異步回調(diào)或者 Promise 中獲取異步調(diào)用的狀態(tài)信息,比如 HTTP 服務(wù)器在處理請求的異步鏈路中的任意一步都可以訪問對于這個(gè)請求而言專有的數(shù)據(jù)。
后續(xù)
除了在 Node.js 中我們需要清晰的異步執(zhí)行模型的定義之外,同樣提供了 JavaScript 執(zhí)行環(huán)境的瀏覽器中在 JavaScript 項(xiàng)目日漸復(fù)雜之后同樣也需要更能描寫異步時(shí)間線的診斷能力。除此之外,其實(shí) Node.js 的 async_hooks 接口本身并不容易被更多的用戶所使用,他暴露了異步資源非常底層的屬性,雖然這些接口能夠準(zhǔn)確描述我們的異步資源,但是想要利用好這些接口并不簡單。
鏈接
Node.js 事件循環(huán):https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/
Semantics of Asynchronous JavaScript:https://www.microsoft.com/en-us/research/wp-content/uploads/2017/08/NodeAsyncContext.pdf
domain: re-implement domain over async_hook:https://github.com/nodejs/node/pull/16222

