深入理解 Node 非同步與事件迴圈

語言: CN / TW / HK

Node 最初是為打造高效能的 Web 伺服器而生,作為 JavaScript 的服務端執行時,具有事件驅動、非同步 I/O、單執行緒等特性。基於事件迴圈的非同步程式設計模型使 Node 具備處理高併發的能力,極大地提升伺服器的效能,同時,由於保持了 JavaScript 單執行緒的特點,Node 不需要處理多執行緒下狀態同步、死鎖等問題,也沒有執行緒上下文切換所帶來的效能上的開銷。基於這些特性,使 Node  具備高效能、高併發的先天優勢,並可基於它構建各種高速、可伸縮網路應用平臺。

本文將深入 Node 非同步和事件迴圈的底層實現和執行機制,希望對你有所幫助。

為什麼要非同步?

Node 為什麼要使用非同步來作為核心程式設計模型呢?

前面說過,Node 最初是為打造高效能的 Web 伺服器而生,假設業務場景中有幾組互不相關的任務要完成,現代主流的解決方式有以下兩種:

  • 單執行緒序列依次執行。

  • 多執行緒並行完成。

單執行緒序列依次執行,是一種同步的程式設計模型,它雖然比較符合程式設計師按順序思考的思維方式,易寫出更順手的程式碼,但由於是同步執行 I/O,同一時刻只能處理單個請求,會導致伺服器響應速度較慢,無法在高併發的應用場景下適用,且由於是阻塞 I/O,CPU 會一直等待 I/O 完成,無法做其他事情,使 CPU 的處理能力得不到充分利用,最終導致效率的低下,

而多執行緒的程式設計模型也會因為程式設計中的狀態同步、死鎖等問題讓開發人員頭疼。儘管多執行緒在多核 CPU 上能夠有效提升 CPU 的利用率。

雖然單執行緒序列依次執行和多執行緒並行完成的程式設計模型有其自身的優勢,但是在效能、開發難度等方面也有不足之處。

除此之外,從響應客戶端請求的速度出發,如果客戶端同時獲取兩個資源,同步方式的響應速度會是兩個資源的響應速度之和,而非同步方式的響應速度會是兩者中最大的一個,效能優勢相比同步十分明顯。隨著應用複雜度的增加,該場景會演變成同時響應 n 個請求,非同步相比於同步的優勢將會凸顯出來。

綜上所述,Node 給出了它的答案:利用單執行緒,遠離多執行緒死鎖、狀態同步等問題;利用非同步 I/O,讓單執行緒遠離阻塞,以更好地使用 CPU。這就是 Node 使用非同步作為核心程式設計模型的原因。

此外,為了彌補單執行緒無法利用多核 CPU 的缺點,Node 也提供了類似瀏覽器中 Web Workers 的子程序,該子程序可以通過工作程序高效地利用 CPU。

如何實現非同步?

聊完了為什麼要使用非同步,那要如何實現非同步呢?

我們通常所說的非同步操作總共有兩類:一是像檔案 I/O、網路 I/O 這類與 I/O 有關的操作;二是像 setTimeOutsetInterval 這類與 I/O 無關的操作。很明顯我們所討論的非同步是指與 I/O 有關的操作,即非同步 I/O。

非同步 I/O 的提出是期望 I/O 的呼叫不會阻塞後續程式的執行,將原有等待 I/O 完成的這段時間分配給其餘需要的業務去執行。要達到這個目的,就需要用到非阻塞 I/O。

阻塞 I/O 是 CPU 在發起 I/O 呼叫後,會一直阻塞,等待 I/O 完成。知道了阻塞 I/O,非阻塞 I/O 就很好理解了,CPU 在發起 I/O 呼叫後會立即返回,而不是阻塞等待,在 I/O 完成之前,CPU 可以處理其他事務。顯然,相比於阻塞 I/O,非阻塞 I/O 多於效能的提升是很明顯的。

那麼,既然使用了非阻塞 I/O,CPU 在發起 I/O 呼叫後可以立即返回,那它是如何知道 I/O 完成的呢?答案是輪詢。

為了及時獲取 I/O 呼叫的狀態,CPU 會不斷重複呼叫 I/O 操作來確認 I/O 是否已經完成,這種重複呼叫判斷操作是否完成的技術就叫做輪詢。

顯然,輪詢會讓 CPU 不斷重複地執行狀態判斷,是對 CPU 資源的浪費。並且,輪詢的間間隔很難控制,如果間隔太長,I/O 操作的完成得不到及時的響應,間接降低應用程式的響應速度;如果間隔太短,難免會讓 CPU 花在輪詢的耗時變長,降低 CPU 資源的利用率。

因此,輪詢雖然滿足了非阻塞 I/O 不會阻塞後續程式的執行的要求,但是對於應用程式而言,它仍然只能算是一種同步,因為應用程式仍然需要等待 I/O 完全返回,依舊花費了很多時間來等待。

我們所期望的完美的非同步 I/O,應該是應用程式發起非阻塞呼叫,無須通過輪詢的方式不斷查詢 I/O 呼叫的狀態,而是可以直接處理下一個任務,在 I/O 完成後通過訊號量或回撥將資料傳遞給應用程式即可。

如何實現這種非同步 I/O 呢?答案是執行緒池。

雖然本文一直提到,Node 是單執行緒執行的,但此處的單執行緒是指 JavaScript 程式碼是執行在單執行緒上的,對於 I/O 操作這類與主業務邏輯無關的部分,通過執行在其他執行緒的方式實現,並不會影響或阻塞主執行緒的執行,反而可以提高主執行緒的執行效率,實現非同步 I/O。

通過執行緒池,讓主執行緒僅進行 I/O 的呼叫,讓其他多個執行緒進行阻塞 I/O 或者非阻塞 I/O 加輪詢技術完成資料獲取,再通過執行緒之間的通訊將 I/O 得到的資料進行傳遞,這就輕鬆實現了非同步 I/O:

image-20220703233325903.png

主執行緒進行 I/O 呼叫,而執行緒池進行 I/O 操作,完成資料的獲取,然後通過執行緒之間的通訊將資料傳遞給主執行緒,即可完成一次 I/O 的呼叫,主執行緒再利用回撥函式,將資料暴露給使用者,使用者再利用這些資料來完成業務邏輯層面的操作,這就是 Node 中一次完整的非同步 I/O 流程。而對於使用者來說,不必在意底層這些繁瑣的實現細節,只需要呼叫 Node 封裝好的非同步 API,並傳入處理業務邏輯的回撥函式即可,如下所示:

``` const fs = require("fs");

fs.readFile('example.js', (data) => { // 進行業務邏輯的處理 }); ```

Node 的非同步底層實現機制在不同平臺下有所不同:Windows 下主要通過 IOCP 來向系統核心傳送 I/O 呼叫和從核心獲取已完成的 I/O 操作,配以事件迴圈,以此完成非同步 I/O 的過程;Linux 下通過 epoll 實現這個過程;FreeBSD下通過 kqueue 實現,Solaris 下通過 Event ports 實現。執行緒池在 Windows 下由核心(IOCP)直接提供,*nix 系列則由 libuv 自行實現。

由於 Windows 平臺和 *nix 平臺的差異,Node 提供了 libuv 作為抽象封裝層,使得所有平臺相容性的判斷都由這一層來完成,保證上層的 Node 與下層的自定義執行緒池及 IOCP 之間各自獨立。Node 在編譯期間會判斷平臺條件,選擇性編譯 unix 目錄或是 win 目錄下的原始檔到目標程式中:

image.png

以上就是 Node 對非同步的實現。

(執行緒池的大小可以通過環境變數 UV_THREADPOOL_SIZE 設定,預設值為 4,使用者可結合實際情況來調整這個值的大小。)

那麼問題來了,在得到執行緒池傳遞過來的資料後,主執行緒是如何、何時呼叫回撥函式的呢?答案是事件迴圈。

基於事件迴圈的非同步程式設計模型

既然使用回撥函式來進行對 I/O 資料的處理,就必然涉及到何時、如何呼叫回撥函式的問題。在實際開發中,往往會涉及到多個、多類非同步 I/O 呼叫的場景,如何合理安排這些非同步 I/O 回撥的呼叫,確保非同步回撥的有序進行是一個難題,而且,除了非同步 I/O 之外,還存在定時器這類非 I/O 的非同步呼叫,這類 API 實時性強,優先順序相應地更高,如何實現不同優先順序回撥地排程呢?

因此,必須存在一個排程機制,對不同優先順序、不同型別的非同步任務進行協調,確保這些任務在主執行緒上有條不紊地執行。與瀏覽器一樣,Node 選擇了事件迴圈來承擔這項重任。

Node 根據任務的種類和優先順序將它們分為七類:Timers、Pending、Idle、Prepare、Poll、Check、Close。對於每類任務,都存在一個先進先出的任務佇列來存放任務及其回撥(Timers 是用小頂堆存放)。基於這七個型別,Node 將事件迴圈的執行分為如下七個階段:

timers

這個階段的執行優先順序是最高的。

事件迴圈在這個階段會檢查存放定時器的資料結構(最小堆),對其中的定時器進行遍歷,逐個比較當前時間和過期時間,判斷該定時器是否過期,如果過期的話,就將該定時器的回撥函式取出並執行。

pending

該階段會執行網路、IO 等異常時的回撥。一些 *nix 上報的錯誤,在這個階段會得到處理。另外,一些應該在上輪迴圈的 poll 階段執行的 I/O 回撥會被推遲到這個階段執行。

idle、prepare

這兩個階段僅在事件迴圈內部使用。

poll

檢索新的 I/O 事件;執行與 I/O 相關的回撥(除了關閉回撥、定時器排程的回撥和 之外幾乎所有回撥setImmediate());節點會在適當的時候阻塞在這裡。

poll,即輪詢階段是事件迴圈最重要的階段,網路 I/O、檔案 I/O 的回撥都主要在這個階段被處理。該階段有兩個主要功能:

  1. 計算該階段應該阻塞和輪詢 I/O 的時間。

  2. 處理 I/O 佇列中的回撥。

當事件迴圈進入 poll 階段並且沒有設定定時器時:

  • 如果輪詢佇列不為空,則事件迴圈將遍歷該佇列,同步地執行它們,直到佇列為空或達到可執行的最大數量。

  • 如果輪詢佇列為空,則會發生另外兩種情況之一:

    • 如果有 setImmediate() 回撥需要執行,則立即結束 poll 階段,並進入 check 階段以執行回撥。

    • 如果沒有 setImmediate() 回撥需要執行,事件迴圈將停留在該階段以等待回撥被新增到佇列中,然後立即執行它們。在超時時間到達前,事件迴圈會一直停留等待。之所以選擇停留在這裡是因為 Node 主要是處理 IO 的,這樣可以更及時地響應 IO。

一旦輪詢佇列為空,事件迴圈將檢查已達到時間閾值的定時器。如果有一個或多個定時器達到時間閾值,事件迴圈將回到 timers 階段以執行這些定時器的回撥。

check

該階段會依次執行 setImmediate() 的回撥。

close

該階段會執行一些關閉資源的回撥,如 socket.on('close', ...)。該階段晚點執行也影響不大,優先順序最低。

當 Node 程序啟動時,它會初始化事件迴圈,執行使用者的輸入程式碼,進行相應非同步 API 的呼叫、計時器的排程等等,然後開始進入事件迴圈:

┌───────────────────────────┐ ┌─>│ timers │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ ┌───────────────┐ │ ┌─────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └─────────────┬─────────────┘ │ data, etc. │ │ ┌─────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │ └───────────────────────────┘

事件迴圈的每一輪迴圈(通常被稱為 tick),會按照如上給定的優先順序順序進入七個階段的執行,每個階段會執行一定數量的佇列中的回撥,之所以只執行一定數量而不全部執行完,是為了防止當前階段執行時間過長,避免下一個階段得不到執行。

OK,以上就是事件迴圈的基本執行流程。現在讓我們來看另外一個問題。

對於以下這個場景:

``` const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {}); ```

當服務成功繫結到 8000 埠,即 listen() 成功呼叫時,此時 listening 事件的回撥還沒有繫結,因此埠成功繫結後,我們所傳入的 listening 事件的回撥並不會執行。

再思考另外一個問題,我們在開發中可能會有一些需求,如處理錯誤、清理不需要的資源等等優先順序不是那麼高的任務,如果以同步的方式執行這些邏輯,就會影響當前任務的執行效率;如果以非同步的方式,比如以回撥的形式傳入 setImmediate() 又無法保證它們的執行時機,實時性不高。那麼要如何處理這些邏輯呢?

基於這幾個問題,Node 參考了瀏覽器,也實現了一套微任務的機制。在 Node 中,除了呼叫 new Promise().then() 所傳入的回撥函式會被封裝成微任務外,process.nextTick() 的回撥也會被封裝成微任務,並且後者的執行優先順序比前者高。

有了微任務後,事件迴圈的執行流程又是怎麼樣的呢?換句話說,微任務的執行時機在什麼時候?

  • 在 node 11 及 11 之後的版本,一旦執行完一個階段裡的一個任務就立刻執行微任務佇列,清空該佇列。

  • 在 node11 之前執行完一個階段後才開始執行微任務。

因此,有了微任務後,事件迴圈的每一輪迴圈,會先執行 timers 階段的一個任務,然後按照先後順序清空 process.nextTick()new Promise().then() 的微任務佇列,接著繼續執行 timers 階段的下一個任務或者下一個階段,即 pending 階段的一個任務,按照這樣的順序以此類推。

利用 process.nextTick(),Node 就可以解決上面的埠繫結問題:在 listen() 方法內部,listening 事件的發出會被封裝成回撥傳入 process.nextTick() 中,如下虛擬碼所示:

function listen() { // 進行監聽埠的操作 ... // 將 `listening` 事件的發出封裝成回撥傳入 `process.nextTick()` 中 process.nextTick(() => { emit('listening'); }); };

在當前程式碼執行完畢後便會開始執行微任務,從而發出 listening 事件,觸發該事件回撥的呼叫。

一些注意事項

由於非同步本身的不可預知性和複雜性,在使用 Node 提供的非同步 API 的過程中,儘管我們已經掌握了事件迴圈的執行原理,但是仍可能會有一些不符合直覺或預期的現象產生。

比如定時器(setTimeoutsetImmediate)的執行順序會因為呼叫它們的上下文而有所不同。如果兩者都是從頂層上下文中呼叫的,那麼它們的執行時間取決於程序或機器的效能。

我們來看以下這個例子:

``` setTimeout(() => { console.log('timeout'); }, 0);

setImmediate(() => { console.log('immediate'); }); ```

以上程式碼的執行結果是什麼呢?按照我們剛才對事件迴圈的描述,你可能會有這樣的答案:由於 timers 階段會比 check 階段先執行,因此 setTimeout() 的回撥會先執行,然後再執行 setImmediate() 的回撥。

實際上,這段程式碼的輸出結果是不確定的,可能先輸出 timeout,也可能先輸出 immediate。這是因為這兩個定時器都是在全域性上下文中呼叫的,當事件迴圈開始執行並執行到 timers 階段時,當前時間可能大於 1 ms,也可能不足 1 ms,具體取決於機器的執行效能,因此 setTimeout() 在第一個 timers 階段是否會被執行實際上是不確定的,因此才會出現不同的輸出結果。

(當 delaysetTimeout 的第二個引數)的值大於 2147483647 或小於 1 時, delay 會被設定為 1。)

我們接著看下面這段程式碼:

``` const fs = require('fs');

fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); }); ```

可以看到,在這段程式碼中兩個定時器都被封裝成回撥函式傳入 readFile 中,很明顯當該回撥被呼叫時當前時間肯定大於 1 ms 了,所以 setTimeout 的回撥會比 setImmediate 的回撥先得到呼叫,因此列印結果為:timeout immediate

以上是在使用 Node 時需要注意的與定時器相關的事項。除此之外,還需注意 process.nextTick()new Promise().then() 還有 setImmediate() 的執行順序,由於這部分比較簡單,前面已經提到過,就不再贅述了。

總結

文章開篇從為什麼要非同步、如何實現非同步兩個角度出發,較詳細地闡述了 Node 事件迴圈的實現原理,並提到一些需要注意的相關事項,希望對你有所幫助。

如果覺得這篇文章寫的不錯的話,就請給我點個贊吧!

參考資料