事件循環是 NodeJS 處理非阻塞 I/O 操作的和核心機制。NodeJS 的事件循環脫胎於 libuv 的事件循環,因此,要搞清楚 NodeJS 的事件循環,還需要先了解 libuv 的事件循環是如何工作的。
libuv 的事件循環
我們先來了解兩個基本概念:句柄(handle)和請求(request).
- 句柄是指在整個事件循環活躍時間內能夠執行某些操作的長期對象。比如一個 TCP 服務句柄,每當有新的聯接建立時,這個句柄的
connected
回調就會被調用。 - 請求是通常指短期操作。比如向某個句柄中寫入數據的操作。
了解了這兩個概念以后,我們來看看 libuv 的事件循環是如何工作的。
下面這張圖可以清楚的展示事件循環的執行過程:
結合這張圖我們簡單描述一下一次循環過程中各個步驟做了什么。
- 首先更新循環內的當前時間(now),避免在循環過程中多次發生與時間相關的系統調用。
- 檢查當前事件循環是否還是活躍(active)的。檢查的表示是當前事件循環是否還有活躍的句柄、活躍的請求操作,或者還有“關閉”回調的話,就視為是活躍的。如果判斷當前循環不是活躍的,則直接退出。
- 執行所有的到期回調。即所有的到期時間在循環當前時間之前的回調都會被執行。
- 執行所有的掛起回調(pending callbacks)。所謂掛起回調,就是在上一個循環周期中設置的到下一循環周期在執行的回調。
- 執行空閑句柄回調(idle handle callbacks)。雖然名字中包含空閑二字,實際上每個循環周期都會執行。
- 執行准備句柄回調(prepare handle callbacks)。
- 在這一步會暫停循環,輪詢等待 I/O 事件一段時間。這個時間長度是根據一個算法算出,這里不做詳細說明。在輪詢期間,所有 I/O 相關的回調會被執行(前提是系統通知到 libuv)。
- 執行檢查句柄回調(check handle callbacks)。檢查句柄回調往往與准備句柄回調相對應。這兩個回調可以方便我們在 I/O 之前做一些准備工作,然后在 I/O 之后做相應的檢查。
- 執行關閉回調(close callbacks)。比如通過
uv_close()
設置的回調。
整個事件循環就是 1 - 9 的循環執行。
值得說明的是,libuv 會在輪詢階段中斷事件循環,等待系統通知。比如某個文件 I/O 已經完成,或者接收到一個網絡連接等。在接收到系統通知后,事件循環會調用相關的回調執行操作。
不同的平台(windows\linux 等),異步 I/O 的機制不同,libuv 底層會根據不同平台,采用不同的 I/O 輪詢機制,比如 epoll(linux)、kqueue(OSX)、IOCP(windows)等,上層不需要關注異步 I/O 的實現機制。
NodeJS 的事件循環
現在我們來看 NodeJS 的事件循環。同樣,我們放一張 NodeJS 事件循環的過程圖。
在 NodeJS 中,事件循環的每一步成為一個階段,每個階段都有一個 FIFO 隊列來執行回調。通常情況下,當事件循環進入給定的階段時,它將執行特定於該階段的任何操作,然后執行該階段隊列中的回調,直到隊列清空或達到最大回調數限制。當隊列清空或者達到最大限制,事件循環進入下一階段。
對比兩個事件循環的圖,我們可以看到,具體過程基本相同。因此,NodeJS 的事件循環過程我們簡述如下:
- 定時器階段,執行已經被
setTimeout()
和setInterval()
調度的回調函數。 - 掛起的回調,執行(在上一個循環中被設置)延遲到下一個循環迭代的 I/O 回調。
- idle, prepare 階段,僅 NodeJS 系統內部使用。
- 輪詢階段,檢索新的 I/O 事件,執行與 I/O 相關的回調。與 libuv 一樣,NodeJS 還在這個階段暫停循環一段時間。
- 檢測階段,執行被
setImmediate()
調度的回調函數。 - 關閉的回調函數,執行一些關閉的回調函數,如:
socket.on('close', ...)
。
我們對輪詢階段做個詳細說明。
輪詢階段有兩個重要的功能:
- 計算應該阻塞和輪詢 I/O 的時間。
- 處理輪詢隊列里的事件。
一旦事件循環進入輪詢階段並且沒有到期的定時器回調時,事件循環將做如下判斷:
- 如果輪詢隊列不是空的,那么事件循環將循環訪問回調隊列並同步執行它們,直到清空隊列,或者達到了最大限制。
- 如果輪詢隊列是空的,則再做如下判斷:
- 如果有代碼是被
setImmediate()
調度的,那么事件循環將結束輪詢階段,並到檢查階段以執行那些被調度的代碼。 - 如果沒有代碼被
setImmediate()
調度,那么事件循環將等待回調被添加到隊列中,然后立即執行。
- 如果有代碼是被
在輪詢階段的執行過程中,一旦輪詢隊列為空,事件循環將檢查是否有到期的定制器。如果一個或多個定時器已准備就緒,則事件循環將繞回定時器階段以執行這些定時器的回調。
這里要特別對 setImmediate()
進行一些說明。
在 libuv 的事件循環中,允許開發人員在輪詢階段之前做些准備操作,然后在輪詢階段之后立即對這些操作進行檢查。NodeJS 中 setImmediate()
實際上是一個在事件循環的單獨階段運行的特殊定時器。它使用一個 libuv API 來安排回調在輪詢階段完成后執行。
setImmediate
、setTimeout
和 process.nextTick
setImmediate()
被設計為一旦在當前輪詢階段完成,就執行代碼。setTimeout()
是在最小閾值(ms 單位)過后執行代碼。process.nextTick()
嚴格意義上講並不屬於事件循環的一部分。它不管事件循環的當前階段如何,它都將在當前操作完成后處理nextTickQueue
中排隊的代碼。
setImmediate()
和 setTimeout()
很類似,但是基於被調用的時機,他們也有不同表現。
我們看下面這段代碼:
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
這兩個函數調用都在主模塊中被調用,則他們的回調執行順序是不定的,受進程的性能影響很大(進程會受到系統中運行其他應用程序影響)。
但是一旦將這兩個函數放到 I/O 輪詢調用內,那么 setImmediate()
一定會在 setTimeout()
之前被執行,不管有多個定制器已經到期。比如下面這段代碼,總是會先輸出 "immediate"。
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
process.nextTick()
和 setImmediate()
嚴格意義上來說,應該將名稱互換。因為 process.nextTick()
比 setImmediate()
觸發得更快。
任何時候在給定的階段中調用 process.nextTick()
,所有傳遞到 process.nextTick()
的回調將在事件循環繼續之前解析。之所以這么設計,是考慮到這些使用場景:
- 允許開發者處理錯誤,清理任何不需要的資源,或者在事件循環繼續之前重試請求。
- 有時有讓回調在棧展開后,但在事件循環繼續之前運行的必要。
比如下面這段代碼:
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
只有傳遞端口時,端口才會立即被綁定,然后立即調用 'listening'
回調。問題是 .on('listening')
的回調在那個時間點尚未被設置。
為了繞過這個問題,'listening'
事件被排在 nextTick()
中,以允許腳本運行完成。這讓用戶設置所想設置的任何事件處理器。
Promise
這里在補充說明一下 NodeJS 中 Promise 是如何處理的。我們之前說過,在瀏覽器的事件循環里,會有一個微任務的隊列來防止所有的微任務,並且在每個操作之后,都嘗試清空微任務隊列。
在 NodeJS 中,做法類似,NodeJS 的事件循環中也有一個微任務隊列,工作機制與 process.nextTick()
類似,在每個操作之后,事件循環都會嘗試清空微任務隊列。
總結
我們結合 libuv 的事件循環,詳細說明了 NodeJS 事件循環的每一階段的具體職能。同時,我們還分析了常用的幾個異步代碼函數的原理。
我們用一張圖歸納如下:
常見面試知識點、技術解決方案、教程,都可以掃碼關注公眾號“眾里千尋”獲取,或者來這里 https://everfind.github.io 。