Node-js 異步延續模型

異步執行在 Node.js 中是非常基本的操作,但是一個 Uncaught Exception 的報錯就可能讓我們摸不着頭腦,是什麼地址的 TLS 訪問 ECONNRESET 了?

[node:12345] Uncaught Exception: Error: read ECONNRESET
   at TLSWrap.onStreamRead (internal/stream_base_commons.js:111:27)
   // 真的沒了

異步延續模型

Node.js 使用的 JavaScript 單線程執行模型簡化了非常多的問題。而爲了防止 IO 阻塞 JavaScript 執行線程,IO 操作在關聯了 JavaScript 回調函數後就被放入了後臺處理。當 IO 操作完成後,與其關聯的 JavaScript 回調會被放入事件隊列等待在 JavaScript 線程調用,可以在這裏 [鏈接 1] 瞭解更多 Node.js 事件循環的詳情。

這個模型有很多好處,但是也有一個關鍵的挑戰:異步資源與操作的上下文管理。什麼是異步操作上下文?異步操作的上下文就是給定一個異步操作,我們能夠通過異步上下文知道這個異步操作是因爲什麼觸發執行的,接下來可以觸發其他什麼異步操作。Semantics of Asynchronous JavaScript [鏈接 2] 通過非常精確的描述方法描述了異步資源的 “上下文”,但是我們只想回答一個問題,在程序的任意一個執行時間點,“我們是通過什麼樣的異步函數執行路徑執行到現在這個代碼位置的”?

爲了回答這個問題,我們先明確幾個關鍵點:

而上述幾個關鍵點可以總結爲以下幾個事件:

這裏我們以下面這段代碼舉個例子:

這段代碼可以通過以下的事件流描述整個異步執行過程:

{ "event": "executionBegin", "executeID": 0 } // 程序開始執行
// starting
{ "event": "link", "executeID": 0, "linkID": 1} // `f1()` 已經被鏈接到了 "setTimeout()" 的調用上
{ "event": "link", "executeID": 0, "linkID": 2} // `f2()` 已經被鏈接到了 "p.then()" 的調用上
{ "event": "executionEnd", "executeID": 0 } // 程序外層代碼執行完畢
{ "event": "ready", "executeID": 0, "linkID": 1, "readyID": 3 } // 100ms 計時到時,執行就緒
{ "event": "executionBegin", "executeID": 4, "readyID": 3 } // f1() 回調開始執行
// resolving promise
{ "event": "ready", "executeID": 4, "linkID": 2, "readyID": 5 } // promise p 被 resolve,標記了 "then(function f2()..." 就緒
{ "event": "executionEnd", "executeID": 4 } // f1() 回調執行完畢
{ "event": "executionBegin", "executeID": 6, "readyID": 5 } // f2() 回調開始執行
// in then
{ "event": "executionEnd", "executeID": 6 } // f2() 回調執行完畢

現有技術

async_hooks

async_hooks 即是 Node.js 對上述模型的實現。其中 async_hooks API 提供了幾個異步階段的鉤子回調可以註冊:

domain 的區別

部分了解、使用過 domain 模塊的同學可能會有一個疑問,async_hooks API 與被廢棄的 domain 有什麼區別?

async_hooks 作爲上述異步模型中將各個異步資源鏈接起來的黏合劑,其本身並不提供任何錯誤處理相關的 API,他的 API 語義也非常清晰,只是對於異步資源的執行事件的描述。而 domain 的主要用途是異步錯誤的處理,但是因爲在 domain 提出的時候還不存在 async_hooks,並且對於異步資源、異步執行的語義定義並不清晰,從而導致實際生產中 domain 的使用非常容易導致錯誤並且難以排查(多個 domain 的使用方其中如果使用了不是那麼正確的方法,會將 domain 的狀態攪得一團糟)。

而在 async_hooks 實現了明確的異步資源與執行的語義後,domain 的實現也進行了遷移、使用 async_hooks 來實現對於異步資源回調的追蹤(實現詳情可以瞭解 PR[鏈接 3])。

Node.js Add-on 的兼容性

雖然 Node.js 提供的 IO 操作的異步回調都已經被妥善地封裝了異步調用的上下文切換,但是 Node.js 還提供了 C/C++ Add-on 的 API,這些 Add-on 普通的 napi_call_function 調用並不會被當成是一個新的執行楨,就如同一個 JavaScript 函數中調用另一個 JavaScript 函數。但是如果 Add-on 在異步回調中也簡單地使用 napi_call_function 就有可能導致 async_hooks 所提供的異步資源 API 出現漏洞。所以 Add-on 需要按照 async_hooks 提供的鉤子的語義,在各個關鍵時間點通過異步資源 API 註冊上,即可完善整個異步調用鏈路。但是這樣會給 Add-on 開發過程造成了一定的負擔,而爲了降低 Add-on 開發過程出現紕漏的可能。N-API 提供了線程安全的回調 JavaScript 線程的 napi_threadsafe_function 機制,並且已經與異步資源綁定,不需要我們再關心異步資源的事件管理。

#include <assert.h>
#include <node_api.h>
void async_call_js(napi_env env,
                  napi_value js_callback,
                  void* context,
                  void* data)
{
 napi_status status;
 // 將 data 轉換成 JavaScript 值
 napi_value value = transform(env, data);
 napi_value recv;
 status = napi_get_null(env, &recv);
 assert(status == napi_ok);
 // N-API 已經爲我們綁定了異步資源,這裏可以安全地使用 `napi_call_function`
 napi_value ret;
 status = napi_call_function(env, recv, js_callback, 1, &value, &ret);
 assert(status == napi_ok);
}
// 會在工作線程被調用的工作函數
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;
 // 創建與 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);
 // 創建工作線程
 create_worker(tsfn, /** 其他參數 */);
 // 返回 JavaScript 值..
 napi_value ret;
 status = napi_get_null(env, &ret);
 assert(status == napi_ok);
 return ret;
}

使用場景

異步任務調度

在單元測試中,如果我們使用了異步任務,一個可能比較常見的場景就是這個異步任務可能會泄漏出我們的測試函數執行楨導致我們後續無法追蹤、或者影響了後續的測試結果。

我們來看一個例子:

在這個例子中,我們可以看到其中 setTimeout 逃逸出了測試執行楨,從而導致測試提早結束,並且可能影響後續測試任務的運行(比如在 setTimeout 中拋出了異常)。現在我們可以通過將全部的方法都使用 callback、promise 給串起來,但是這畢竟需要開發者自行去完成,並且可能出現疏漏,還是會出現例子中的情況。那麼我們有沒有可能從語言運行時層面提供一個 “完美” 的方案來跟蹤所有的異步任務呢?通過 async_hooks 的異步資源追蹤能力,我們就可以標記所有在測試執行過程中創建的異步資源,如果在測試執行結束後,還存在未銷燬的異步資源,就可以更早地將問題暴露。

如我們有下面這個例子:

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.
});

在這個例子中,每一次測試開始執行前,我們都會在爲測試運行註冊一個異步特有數據存儲,然後再開始執行測試,這樣在測試中發起的所有異步資源都會被 async_hooks 捕捉到並被測試模塊標記,直到這個異步資源被銷燬(或者是 Promise Resolve)。隨後在測試結束後,我們再檢查當前測試是否有遺留的異步資源,即可確認我們的測試是乾淨無殘留的。

異步調用棧 / 性能診斷

這也是我們開頭的問題。

在越來越多大型的項目使用 Node.js 作爲研發技術棧後,開發者們也會越來越關注問題的診斷便捷性。除了異常錯誤排查,現在我們也可以通過 Chrome DevTools 的 CPU Profiler 亦或者是生成火焰圖來診斷我們的 Node.js 應用性能表現,但是這些工具現在更多的是隻能查看某一個函數在單個同步執行楨中的調用鏈路與時間佔用比例,並沒有能力將一個異步鏈路上每一個異步操作所花費的時間與百分比描繪出來。

而在能夠串聯異步鏈路中的異步調用棧之後,後續我們也可以在開發中使用更加直觀的性能剖析工具:

或者是提供線上的請求鏈路追蹤能力,就如同現在各種成熟 APM 提供的應用間 RPC 調用鏈路一樣,我們同樣也可以繪製出應用內一個請求到底經歷了什麼流程,每一步分別花費了多少時間:

AsyncLocalStorage

使用線程作爲處理單元的模型中,我們可以使用 ThreadLocal 來存儲對於當前線程特有的數據信息,那麼在 Node.js 的異步模型中,我們有什麼辦法可以方便地存儲對於當前異步任務來說特有的數據信息呢?

Node.js 在 3 月 4 日發行的 v13.10.0 版本第一次發佈了 async_hooks.AsyncLocalStorage,可以在異步回調或者 Promise 中獲取異步調用的狀態信息,比如 HTTP 服務器在處理請求的異步鏈路中的任意一步都可以訪問對於這個請求而言專有的數據。

後續

除了在 Node.js 中我們需要清晰的異步執行模型的定義之外,同樣提供了 JavaScript 執行環境的瀏覽器中在 JavaScript 項目日漸複雜之後同樣也需要更能描寫異步時間線的診斷能力。除此之外,其實 Node.js 的 async_hooks 接口本身並不容易被更多的用戶所使用,他暴露了異步資源非常底層的屬性,雖然這些接口能夠準確描述我們的異步資源,但是想要利用好這些接口並不簡單。

聲明:

本文於網絡整理,版權歸原作者所有,如來源信息有誤或侵犯權益,請聯繫我們刪除或授權事宜。

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