事件迴圈是 NodeJS 處理非阻塞 I/O 操作的和核心機制。NodeJS 的事件迴圈脫胎於 libuv 的事件迴圈,因此,要搞清楚 NodeJS 的事件迴圈,還需要先了解 libuv 的事件迴圈是如何工作的。

libuv 的事件迴圈

我們先來了解兩個基本概念:控制代碼(handle)和請求(request).

  • 控制代碼是指在整個事件迴圈活躍時間內能夠執行某些操作的長期物件。比如一個 TCP 服務控制代碼,每當有新的聯接建立時,這個控制代碼的 connected 回撥就會被呼叫。
  • 請求是通常指短期操作。比如向某個控制代碼中寫入資料的操作。

瞭解了這兩個概念以後,我們來看看 libuv 的事件迴圈是如何工作的。

下面這張圖可以清楚的展示事件迴圈的執行過程:

結合這張圖我們簡單描述一下一次迴圈過程中各個步驟做了什麼。

  1. 首先更新迴圈內的當前時間(now),避免在迴圈過程中多次發生與時間相關的系統呼叫。
  2. 檢查當前事件迴圈是否還是活躍(active)的。檢查的表示是當前事件迴圈是否還有活躍的控制代碼、活躍的請求操作,或者還有“關閉”回撥的話,就視為是活躍的。如果判斷當前迴圈不是活躍的,則直接退出。
  3. 執行所有的到期回撥。即所有的到期時間在迴圈當前時間之前的回撥都會被執行。
  4. 執行所有的掛起回撥(pending callbacks)。所謂掛起回撥,就是在上一個迴圈週期中設定的到下一迴圈週期在執行的回撥。
  5. 執行空閒控制代碼回撥(idle handle callbacks)。雖然名字中包含空閒二字,實際上每個迴圈週期都會執行。
  6. 執行準備控制代碼回撥(prepare handle callbacks)。
  7. 在這一步會暫停迴圈,輪詢等待 I/O 事件一段時間。這個時間長度是根據一個演算法算出,這裡不做詳細說明。在輪詢期間,所有 I/O 相關的回撥會被執行(前提是系統通知到 libuv)。
  8. 執行檢查控制代碼回撥(check handle callbacks)。檢查控制代碼回撥往往與準備控制代碼回撥相對應。這兩個回撥可以方便我們在 I/O 之前做一些準備工作,然後在 I/O 之後做相應的檢查。
  9. 執行關閉回撥(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 的事件迴圈過程我們簡述如下:

  1. 定時器階段,執行已經被 setTimeout()setInterval() 排程的回撥函式。
  2. 掛起的回撥,執行(在上一個迴圈中被設定)延遲到下一個迴圈迭代的 I/O 回撥。
  3. idle, prepare 階段,僅 NodeJS 系統內部使用。
  4. 輪詢階段,檢索新的 I/O 事件,執行與 I/O 相關的回撥。與 libuv 一樣,NodeJS 還在這個階段暫停迴圈一段時間。
  5. 檢測階段,執行被 setImmediate() 排程的回撥函式。
  6. 關閉的回撥函式,執行一些關閉的回撥函式,如:socket.on('close', ...)

我們對輪詢階段做個詳細說明。

輪詢階段有兩個重要的功能:

  • 計算應該阻塞和輪詢 I/O 的時間。
  • 處理輪詢佇列裡的事件。

一旦事件迴圈進入輪詢階段並且沒有到期的定時器回撥時,事件迴圈將做如下判斷:

  • 如果輪詢佇列不是空的,那麼事件迴圈將迴圈訪問回撥佇列並同步執行它們,直到清空佇列,或者達到了最大限制。
  • 如果輪詢佇列是空的,則再做如下判斷:
    • 如果有程式碼是被 setImmediate() 排程的,那麼事件迴圈將結束輪詢階段,併到檢查階段以執行那些被排程的程式碼。
    • 如果沒有程式碼被 setImmediate() 排程,那麼事件迴圈將等待回撥被新增到佇列中,然後立即執行。

在輪詢階段的執行過程中,一旦輪詢佇列為空,事件迴圈將檢查是否有到期的定製器。如果一個或多個定時器已準備就緒,則事件迴圈將繞回定時器階段以執行這些定時器的回撥。

這裡要特別對 setImmediate() 進行一些說明。

在 libuv 的事件迴圈中,允許開發人員在輪詢階段之前做些準備操作,然後在輪詢階段之後立即對這些操作進行檢查。NodeJS 中 setImmediate() 實際上是一個在事件迴圈的單獨階段執行的特殊定時器。它使用一個 libuv API 來安排回撥在輪詢階段完成後執行。

setImmediatesetTimeoutprocess.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