NodeJS 中的事件循環,讀了這篇就全懂了

  • 2021 年 8 月 16 日
  • 筆記

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

libuv 的事件循環

我們先來了解兩個基本概念:句柄(handle)和請求(request).

  • 句柄是指在整個事件循環活躍時間內能夠執行某些操作的長期對象。比如一個 TCP 服務句柄,每當有新的聯接建立時,這個句柄的 connected 回調就會被調用。
  • 請求是通常指短期操作。比如向某個句柄中寫入數據的操作。

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

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

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 的事件循環

在 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 事件循環的每一階段的具體職能。同時,我們還分析了常用的幾個非同步程式碼函數的原理。

我們用一張圖歸納如下:

事件循環

常見面試知識點、技術解決方案、教程,都可以掃碼關注公眾號「眾里千尋」獲取,或者來這裡 //everfind.github.io

眾里千尋