Node異步和事件循環的深入講解

前言

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:

主線程進行 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 目錄下的源文件到目標程序中:

以上就是 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 的回調都主要在這個階段被處理。該階段有兩個主要功能:

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

  • 處理 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 事件循環的實現原理,並提到一些需要註意的相關事項,希望對你有所幫助。

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

參考資料

  • The Node.js Event Loop, Timers, and process.nextTick()。

  • 《深入淺出 Node.js》第 3 章。

到此這篇關於Node異步和事件循環的文章就介紹到這瞭,更多相關Node異步和事件循環內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: