Node.js 的微任務處理(基于Node.js V17)
前言:Node.js 的事件循環(huán)已經(jīng)老生常談,但是在 Node.js 的執(zhí)行流程中,事件循環(huán)并不是全部,在事件循環(huán)之外,微任務的處理也是核心節(jié)點,比如 nextTick 和 Promise 任務的處理。本文介紹 Node.js 中微任務處理的相關(guān)內(nèi)容。網(wǎng)上文章和很多面試題中有很多關(guān)于 Promise、nextTick、setTimeout 和 setImmediate 執(zhí)行順序的內(nèi)容。通過本文,讓你從原理上理解他們,碰到相關(guān)的問題就引刃而解,不再拘泥于背誦和記錄。
1 事件循環(huán)
本文不打算詳細地講解事件循環(huán),因為已經(jīng)有很多相關(guān)文章,而且本身也不是很復雜的流程。事件循環(huán)本質(zhì)上是一個消費者和生產(chǎn)者的模型,我們可以理解事件循環(huán)的每一個階段都維護了一個任務隊列,然后在事件循環(huán)的每一輪里就會去消費這些任務,那就是執(zhí)行回調(diào),然后在回調(diào)里又可以生產(chǎn)任務,從而驅(qū)動整個事件循環(huán)的運行。當事件循環(huán)里沒有生產(chǎn)者的時候,系統(tǒng)就會退出。而有些生產(chǎn)者會 hold 住事件循環(huán)從而讓整個系統(tǒng)不會退出,比如我們啟動了一個 TCP 服務器。事件循環(huán)處理了 Node.js 中大部分的執(zhí)行流程,但是并不是全部。
2 微任務
Node.js 中,典型的微任務包括 nexiTick 和 Promise。官網(wǎng)說 nextTick 任務會在繼續(xù)事件循環(huán)之前被處理,描述得比較宏觀,下面我們來看一下具體的實現(xiàn)細節(jié)。微任務的處理時機分為兩個時間點。1. 定義 C++ InternalCallbackScope 對象,在對象析構(gòu)時。2. 主動調(diào) JS 函數(shù) runNextTicks。
2.1 InternalCallbackScope
下面先看一下 InternalCallbackScope。通常在需要處理微任務的地方定義一個 InternalCallbackScope 對象,然后執(zhí)行一些其他的代碼,最后退出作用域。
- {
- InternalCallbackScope scope
- // some code
- } // 退出作用域,析構(gòu)
下面看一下 InternalCallbackScope 析構(gòu)函數(shù)的邏輯。
- InternalCallbackScope::~InternalCallbackScope() {
- Close();
- }
- void InternalCallbackScope::Close() {
- tick_callback->Call(context, process, 0, nullptr);
- }
在析構(gòu)函數(shù)里會執(zhí)行 tick_callback 函數(shù)。我們看看這個函數(shù)是什么。
- static void SetTickCallback(const FunctionCallbackInfo<Value>& args) {
- Environment* env = Environment::GetCurrent(args);
- CHECK(args[0]->IsFunction());
- env->set_tick_callback_function(args[0].As<Function>());
- }
tick_callback 是由 SetTickCallback 設置的。
- setTickCallback(processTicksAndRejections);
我們可以看到通過 setTickCallback 設置的這個函數(shù)是 processTicksAndRejections。
- function processTicksAndRejections() {
- let tock;
- do {
- while (tock = queue.shift()) {
- const callback = tock.callback;
- callback();
- }
- runMicrotasks();
- } while (!queue.isEmpty() || processPromiseRejections());
- }
processTicksAndRejections 正是處理微任務的函數(shù),包括 tick 和 Promise 任務?,F(xiàn)在我們已經(jīng)了解了 InternalCallbackScope 對象的邏輯。那么下面我們來看一下哪里使用了這個對象。第一個地方是在 Node.js 初始化時,執(zhí)行完用戶 JS 后,進入事件循環(huán)前??纯聪嚓P(guān)代碼。
我們看到在 Node.js 初始化時,執(zhí)行用戶 JS 后,進入事件循環(huán)前會處理一次微任務,所以我們在自己的初始化 JS 里調(diào)用了 nextTick 的話,就會在這時候被處理。第二個地方是每次從 C、C++ 層執(zhí)行 JS 層回調(diào)時。
- MaybeLocal<Value> AsyncWrap::MakeCallback(const Local<Function> cb,
- int argc,
- Local<Value>* argv) {
- ProviderType provider = provider_type();
- async_context context { get_async_id(), get_trigger_async_id() };
- MaybeLocal<Value> ret = InternalMakeCallback(
- env(), object(), object(), cb, argc, argv, context);
- return ret;
- }
MakeCallback 是 C、C++ 層回調(diào) JS 層的函數(shù),這個函數(shù)里又調(diào)用一個 InternalMakeCallback。
- MaybeLocal<Value> InternalMakeCallback(Environment* env,
- Local<Object> resource,
- Local<Object> recv,
- const Local<Function> callback,
- int argc,
- Local<Value> argv[],
- async_context asyncContext) {
- // 定義 InternalCallbackScope
- InternalCallbackScope scope(env, resource, asyncContext, flags);
- // 執(zhí)行 JS 層回調(diào)
- callback->Call(context, recv, argc, argv);
- // 處理微任務
- scope.Close();
- }
我們看到 InternalMakeCallback 里定義了一個 InternalCallbackScope,然后在回調(diào)完 JS 函數(shù)后會調(diào)用 InternalCallbackScope 對象的 Close 進行微任務的處理。
以上是典型的處理時機。另外在某些地方也會定義 InternalCallbackScope 對象,具體可在源碼里搜索。
2.2 runNextTicks
剛才介紹了每次事件循環(huán)消費任務時,就會去遍歷每一個階段的任務隊列,然后逐個執(zhí)行任務節(jié)點對應的回調(diào)。執(zhí)行回調(diào)的時候,就會從 C 到 C++ 層,然后再到 JS 層,執(zhí)行完 JS 代碼后,會再次回調(diào) C++ 層,C++ 層會進行一次微任務的處理,處理完后再回到 C 層,繼續(xù)執(zhí)行下一個任務節(jié)點的回調(diào),以此類推。這看起來覆蓋了所有的情況,但是有兩個地方比較特殊,那就是 setTimeout 和 setImmediate。其他的任務都是一個節(jié)點對應一個 C、C++ 和 JS 回調(diào),所以如果在 JS 回調(diào)里產(chǎn)生的微任務,在回到 C++ 層的時候就會被處理。但是為了提高性能,Node.js 的定時器和 setImmediate 在實現(xiàn)上是一個底層節(jié)點管理多個 JS 回調(diào)。這里以定時器為例,Node.js 在底層使用了一個 Libuv 的定時器節(jié)點管理 JS 層的所有定時器,并在 JS 層里維護了所有的定時器節(jié)點,然后把 Libuv 定時節(jié)點的超時時間設置為 JS 層最快到期的節(jié)點的時間,這樣就會帶來一個問題。就是當有定時器超時,Libuv 從 C、C++ 回調(diào) JS 層時,JS 層會直接處理所有的超時節(jié)點后再回到 C++ 層,這時候才有機會處理微任務。這會導致 setTimeout 里產(chǎn)生的微任務沒有在宏任務(setTimeout 的回調(diào))執(zhí)行完后被處理。這就不符合規(guī)范了。所以這個地方還需要特殊處理一下。我們看看相關(guān)的代碼。
- function processTimers(now) {
- nextExpiry = Infinity;
- let list;
- let ranAtLeastOneList = false;
- while (list = timerListQueue.peek()) {
- if (list.expiry > now) {
- nextExpiry = list.expiry;
- return refCount > 0 ? nextExpiry : -nextExpiry;
- }
- // 處理 listOnTimeout 最后一個回調(diào)里產(chǎn)生的微任務
- if (ranAtLeastOneList)
- runNextTicks();
- else
- ranAtLeastOneList = true;
- listOnTimeout(list, now);
- }
- return 0;
- }
- function listOnTimeout(list, now) {
- let ranAtLeastOneTimer = false;
- let timer;
- while (timer = L.peek(list)) {
- // 處理微任務
- if (ranAtLeastOneTimer)
- runNextTicks();
- else
- ranAtLeastOneTimer = true;
- // 執(zhí)行 setTimeout 回調(diào)
- timer._onTimeout();
- }
- }
定時器的架構(gòu)如下。
Node.js 在 JS 層維護了一個樹,每個節(jié)點管理一個列表,處理超時事件時,就會遍歷這棵樹的每個節(jié)點,然后再遍歷這個節(jié)點對應隊列里的每個節(jié)點。而上面的代碼就是保證在每次調(diào)用完一個 setTimeout 回調(diào)時,都會處理一次微任務。同樣 setImmediate 任務也是類似的。
- let ranAtLeastOneImmediate = false;
- while (immediate !== null) {
- if (ranAtLeastOneImmediate)
- runNextTicks();
- else
- ranAtLeastOneImmediate = true;
- immediate._onImmediate();
- immediate = immediate._idleNext;
- }
以上的補償處理就保證了宏任務和微任務的處理能符合預期。