[NodeJS] NodeJS事件循環
JS是單線程的,如果出現阻塞會嚴重影響代碼執行效率。NodeJS通過事件循環,盡可能地將耗時任務委派給系統內核來實現非阻塞IO。
NodeJS提供了許多和異步相關的API,除了語言標準規定的setTimeout和setInterval,還有setImmediate和process.nextTick。
經常和這幾個出現在面試題里的還有
Promise.resolve().then()。
事件循環流程
當NodeJS啟動時,會先進行事件循環的初始化(事件循環還沒開始),會先完成下面的事情:
- 解析執行同步任務;
- 發出異步請求;
- 注冊定時器回調;
- 執行
process.nextTick();
然后再開始事件循環。
事件循環的操作順序如下圖所示:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending I/O callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
每一個方框對對應著事件循環的一個階段(phase),每一個階段有一個先進先出的回調隊列需要執行。
當事件循環進入到其中一個階段時,它會依次執行并嘗試清空隊列中的回調任務,當隊列被清空或者回調執行數量達到最大限制時,事件循環會進入到下一個階段。
timers:定時器階段,執行setTimeout和setInterval的回調函數;pending I/O callbacks:除了定時器回調、setImmediate回調和關閉回調,其它回調都在這里執行;idle, prepare:這個階段只供libuv內部調用;Poll:這個階段是輪詢時間,用于等待還未返回的 I/O 事件,比如服務器的回應、用戶移動鼠標等等。這個階段的時間會比較長。如果沒有其他異步任務要處理(比如到期的定時器),會一直停留在這個階段,等待 I/O 請求返回結果。check:執行setImmediate回調;close callbacks:執行關閉請求的回調函數,比如socket.on('close', ...)。
事件循環的源碼解析
源碼位置:
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int can_sleep;
// 檢查事件循環是否還活躍(即是否還有活躍的句柄或請求)
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop); // 更新事件循環的當前時間
/* 保持向后兼容性,在進入 UV_RUN_DEFAULT 的 while 循環之前處理定時器。
* 否則定時器只需執行一次,這應在輪詢之后完成,以保持事件循環的正確執行順序。
*/
if (mode == UV_RUN_DEFAULT && r != 0 && loop->stop_flag == 0) {
uv__update_time(loop); // 更新事件循環的當前時間
uv__run_timers(loop); // 運行所有到期的定時器 (Timers)
}
// 主循環,根據不同的模式執行事件循環
while (r != 0 && loop->stop_flag == 0) {
// 檢查是否可以進入睡眠狀態,即是否有掛起的任務或空閑句柄
can_sleep =
uv__queue_empty(&loop->pending_queue) &&
uv__queue_empty(&loop->idle_handles);
// 運行掛起的任務 (Pending Callbacks)
uv__run_pending(loop);
// 運行空閑句柄和預處理句柄 (Idle Prepare)
uv__run_idle(loop);
uv__run_prepare(loop);
timeout = 0;
// 根據模式設置超時時間
if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
timeout = uv__backend_timeout(loop);
// 增加事件循環計數
uv__metrics_inc_loop_count(loop);
// 輪詢I/O事件 (Poll)
uv__io_poll(loop, timeout);
/* 處理立即回調(例如 write_cb)固定次數,以避免循環饑餓。 */
for (r = 0; r < 8 && !uv__queue_empty(&loop->pending_queue); r++)
uv__run_pending(loop);
/*
* 進行最后一次 provider_idle_time 的更新,以防 uv__io_poll
* 因超時返回但未接收到任何事件。如果 provider_entry_time 從未設置
* (即 timeout == 0),或者已經因為接收到事件而更新,則此調用將被忽略。
*/
uv__metrics_update_idle_time(loop);
// 運行check句柄 (Check)
uv__run_check(loop);
// 運行關閉的回調 (Close Callbacks)
uv__run_closing_handles(loop);
// 更新事件循環的當前時間和運行所有到期的定時器 (Timers)
uv__update_time(loop);
uv__run_timers(loop);
// 檢查事件循環是否還活躍
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break; // 如果模式為 UV_RUN_ONCE 或 UV_RUN_NOWAIT,則退出循環
}
/* 這個 if 語句讓 gcc 將其編譯為條件存儲。避免弄臟緩存行。 */
if (loop->stop_flag != 0)
loop->stop_flag = 0; // 清除停止標志
return r; // 返回事件循環是否還活著
}
名詞解釋:
條件存儲:條件存儲是一種優化技術。編譯器可以將
if語句編譯成一種條件存儲操作。這種操作僅在特定條件下才會寫入數據,從而避免不必要的寫操作。在這段代碼中,loop->stop_flag的值只有在其當前值不為零時才會被修改。這避免了不必要的寫操作,因為如果loop->stop_flag已經是零,則不需要再寫一次零。緩存行:緩存行是處理器緩存的基本單位,通常為 64 字節。緩存用于存儲從內存中加載的數據,以加快訪問速度。當處理器需要訪問某個內存地址時,會先檢查緩存中是否存在對應的數據。如果緩存中存在該數據(稱為緩存命中),則可以快速訪問;如果不存在(稱為緩存未命中),則需要從較慢的主存中加載數據。在現代處理器中,緩存寫操作可能會使緩存行變臟(dirty),即緩存中的數據與主存中的數據不一致。每次寫操作都可能導致緩存行的變臟和隨后的寫回操作(將緩存中的數據寫回主存),這些操作會影響性能。
通過條件存儲,如果
loop->stop_flag本來就是零,則不會進行寫操作,避免了緩存行變臟,從而減少了寫回主存的開銷,提高了緩存的利用效率。
process.nextTick和Promise
或許你會疑惑上面的事件循環階段怎么沒有講到process.nextTick和Promise回調(微任務)。
這兩個回調的執行時機不在階段“內部”,而是在階段“之間”,在每個階段結束時被執行。
并且,process.nextTick的執行順序先于Promise回調(微任務)。
微任務除了
nextTick和promise,還有MutationObserver和queueMicrotask。
nextTick屬于特殊的高優先級微任務,而promise、MutationObserver和queueMicrotask的優先級一致。
MutationObserver是用來監聽DOM的,是瀏覽器獨有的;而nextTick是NodeJS獨有的;
promise和queueMicrotask在兩種環境下都有。
setTimeout和setImmediate
setTimeout在timers階段執行,setImmediate的回調在check階段執行,因此setTimeout會早于setImmediate完成。
案例:
setTimeout(()=>console.log(1));
setImmediate(()=>console.log(2));
理論上上面這段代碼會先輸出1再輸出2,但實際是順序不確定。
因為在NodeJS中,setTimeout的第二個參數delay缺省值為1,根據官方文檔,這個參數的取值范圍為1到2147483647之間,超出這個范圍會被設置為1,而非整數會被截去小數部分變為整數。
并且實際執行的時候,進入事件循環之后,可能到了1毫秒,也可能還沒到,因此timers階段的隊列可能是空的,于是就先執行了check階段的setImmediate回調,而到了下一階段,才是setTimeout的回調。
另一個案例:
const fs = require('fs');
fs.readFile('test.js', () => {
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
});
這個例子中,則一定是先輸出2,然后才是1.
因為readFile的回調會在pending I/O callbacks階段被執行,此時的setTimeout回調最快也只能在下一個loop中被執行,而setImmediate的回調被添加到check階段的隊列,當當前這個loop執行到check階段的時候,就會被執行。
測試題
setImmediate(() => {
console.log(1)
setTimeout(() => {
console.log(2)
}, 100)
setImmediate(() => {
console.log(3)
})
process.nextTick(() => {
console.log(4)
})
})
process.nextTick(() => {
console.log(5)
setTimeout(() => {
console.log(6)
}, 100)
setImmediate(() => {
console.log(7)
})
process.nextTick(() => {
console.log(8)
})
})
console.log(9)
答案:
往下滑
9 5 8 1 4 7 3 6 2
解析:
- 同步代碼:注冊
setImmediate,等待事件循環到達check階段;注冊nextTick回調;同步代碼輸出9; - 事件循環啟動后,在到達
check階段之前nextTick肯定是先被執行的,于是先輸出5;輸出之后依次注冊setTimeout,setImmediate和nextTick; - 在到達
check階段之前的階段之間,nextTick回調被再次執行,輸出8; - 中間階段的隊列都是空的,直到事件循環來到
check階段,執行最頂層的setImmediate回調,先輸出1,然后依次注冊setTimeout,setImmediate,nextTick回調; - 離開
setImmediate,再次執行nextTick回調,輸出4; - 到達
timers階段,但是通常這時候還沒到達100ms,于是跳過; - 再次到達
check階段,輸出隊列中的7和3; - 在下次循環的
poll階段等待,直到定時器完成,依次輸出6和2。
參考文章
[1] Node 定時器詳解 - 阮一峰的網絡日志
[2] The Node.js Event Loop
[3] Understanding process.nextTick()
[4] Understanding setImmediate()

浙公網安備 33010602011771號