譯者注:這是我看過最好的解釋NodeJS事件迴圈的系列文章。點選檢視原文(請自備梯子)
作為開篇第一章,作者非常詳細認真甚至有點囉嗦地介紹了事件迴圈的基本工作流程,解釋了libuv主要解決的問題,同時從應用層JavaScript的角度出發,將事件迴圈的所有階段區分為lbuv原生的和NodeJS額外新增的(事實也是這樣。很多時候我們並不知道需要區分這兩者),我覺得有了這些基礎,會更加容易理解事件迴圈的其餘部分和細節。不管是新手還是資深NodeJS程式設計師,這都是一篇不可多得值得一讀的文章。
NodeJS與其他程式設計平臺的區別在於它如何處理I / O。我們經常聽到NodeJS被稱為“基於谷歌的v8 javascript引擎的非阻塞事件驅動平臺”。什麼意思?“非阻塞”和“事件驅動”是什麼意思?所有這些答案都在NodeJS的事件迴圈的核心。 在本專題中,我將介紹什麼是事件迴圈,它是如何工作的,它如何影響我們的應用程式,如何充分利用它以及更多。為什麼是專題而不就一篇文章?那樣的話,將會是一篇非常長的文章,我肯定會忽略某些地方,因此我將撰寫一個關於NodeJS事件迴圈的專題。 在第一篇文章中,我將介紹NodeJS如何工作,如何訪問I / O以及如何與不同的平臺一起工作等。
本專題目錄
- Event Loop and the Big Picture (本文)
- Timers, Immediates and Next Ticks
- Promises, Next-Ticks and Immediates
- Handling I / O
- Event Loop Best Practices
Reactor模式
NodeJS以事件驅動模型執行,涉及到Event Demultiplexer 和 Event Queue。所有I / O請求最終都會產生一個完成或失敗的事情,或者其他觸發器,這些即稱為“事件”。這些事件按照以下演算法進行處理。
- Event Demultiplexer 接收I / O請求並將這些請求委託給適當的硬體。
- 一旦I / O請求被處理(例如,來自檔案的資料可被讀取,來自套接字的資料可被讀取等),Event Demultiplexer 將針對特定動作的已註冊的回撥新增到佇列中等待處理。這些回撥稱為事件,新增事件的佇列稱為
事件佇列
。 - 當事件可以在
事件佇列
中處理時,它們按照它們接收的順序依次執行,直到佇列為空。 - 如果
事件佇列
中沒有事件,或 Event Demultiplexer 沒有任何等待中的請求,則程式將完成。否則,該過程將從第一步繼續下去。
編排整個機制的程式稱為事件迴圈。
事件迴圈是一個單執行緒和半無限的迴圈。之所以叫半無限的迴圈,是因為當沒有任務執行時,該迴圈實際上會停止。從開發人員的角度來看,這也是程式退出的地方。
注意:不要把事件迴圈和NodeJS Event Emitter 混淆。Event Emitter 與此機制完全不同。在後面的文章中,我將解釋Event Emitter 如何通過事件迴圈影響事件處理過程。
上圖是對NodeJS如何工作的抽象概括,同時展示了Reactor模式的主要組成部分。 但實際情況比這要複雜。那麼這有多複雜?
Event Demultiplexer 不是一個可以在所有作業系統平臺中執行所有型別I / O的單個元件。
事件佇列
不是像這裡顯示的、所有型別的事件都在其中排隊出隊的單個佇列。而I / O也不是唯一一種需要排隊的事件型別。
所以,讓我們深入挖掘。
Event Demultiplexer
Event Demultiplexer 不是現實世界中存在的元件,而是 Reactor 模式中的抽象概念。在現實世界中,Event Demultiplexer 已經在不同的系統中以不同的名稱實現,例如Linux中的epoll,BSD系統中的kqueue(MacOS),Solaris中的事件埠,Windows中的IOCP(輸入輸出完成埠)等。而NodeJS利用底層非阻塞非同步的硬體I / O功能。
檔案I / O的複雜性
但令人困惑的是,並非所有型別的I / O都可以使用這些實現來執行。即使在同一個作業系統平臺上,支援不同型別的I / O也很複雜。通常,使用這些epoll,kqueue,事件埠和IOCP可以以非阻塞的方式執行網路I / O,但是檔案I / O要複雜得多。某些系統(如Linux)不支援檔案系統訪問的完全非同步。MacOS系統中的檔案系統事件通知和kqueue訊號存在侷限性(您可以在這裡檢視更多)。解決所有這些檔案系統的複雜性以提供完全的非同步是非常複雜,幾乎不可能的。
DNS中的複雜性
與檔案I / O類似,Node API提供的某些DNS功能也具有一定的複雜性。因為NodeJS的DNS功能(諸如dns.lookup
)需要訪問系統配置檔案(如nsswitch.conf
,resolv.conf
和/etc/hosts
),上述檔案系統的複雜性也適用於dns.resolve
。
解決方案?
因此,為了支援那些不能由硬體非同步I / O實用程式(如epoll、kqueue、event埠或IOCP)直接處理的I / O功能,引入了執行緒池。現在我們知道並非所有的I / O功能都發生線上程池中。NodeJS已經盡最大努力使用非阻塞和非同步硬體I / O來完成大部分I / O,但對於阻塞或複雜的I / O型別,它使用執行緒池。
聚集在一起
正如我們所看到的,在現實世界中,在所有不同型別的作業系統平臺中支援所有不同型別的I / O(檔案I / O,網路I / O,DNS等)是非常困難的。一些I / O可以使用本機硬體實現來執行,保持完全非同步,還有一些I / O型別線上程池中執行,以確保是非同步的。
開發人員對Node的一個常見誤解是Node線上程池中執行所有I / O。
為了在支援跨平臺I / O的同時管理整個流程,應該有一個抽象層,它封裝了這些平臺間和平臺內的複雜性,併為Node的上層公開了一個通用的API。
那麼,誰呢?女士們,先生們,歡迎...。
從官方libuv文件中,
libuv是最初為NodeJS編寫的跨平臺支援庫。它圍繞事件驅動的非同步I / O模型進行設計。
該庫提供的不僅僅是對不同I / O輪詢機制的簡單抽象:'handles'和'streams'為套接字和其他實體提供了高階抽象, 還提供了跨平臺的檔案I / O和執行緒功能。
現在讓我們看看libuv是如何組成的。下圖來自官方的libuv文件,描述了在暴露廣義API時如何處理不同型別的I / O。
現在我們知道 Event Demultiplexer 不是單個實體,而是由Libuv提取並暴露給NodeJS上層的處理I / O的API集合。它不僅是libuv為Node提供的 Event Demultiplexer。而且Libuv為NodeJS提供了整個事件迴圈功能,包括事件排隊機制。
現在讓我們看看事件佇列
。
事件佇列
事件佇列
應該是一個資料結構,其中所有的事件都被順序排列並由事件迴圈處理,直到佇列為空。
但是這個過程在Node中的實際發生情況和 Reactor 模式描述的完全不同。那它有什麼不同?
NodeJS中有多個佇列,其中不同型別的事件在自己的佇列中排隊。
在處理完一個階段後,在進入下一個階段之前,事件迴圈將處理兩個中間佇列,直到中間佇列清空。
那麼有幾個佇列呢?中間佇列是什麼?
原生的libuv事件迴圈處理的佇列有4種主要型別。
- 過期的定時器和間隔(timers and intervals)佇列:由通過
setTimeout
和setInterval
新增的過期的定時器的回撥。 - IO事件佇列: 完成的IO事件
Immediates佇列
:使用setImmediate
函式新增的回撥close handlers佇列
:任何close
事件處理。
請注意,儘管為了簡單起見,我提到所有這些都是“ 佇列 ”,但其中一些實際上是不同型別的資料結構(例如,定時器儲存在最小堆中)
除了這4個主要佇列之外,還有2個有趣的佇列,我之前提到這些佇列是“中間佇列”並由Node處理。雖然這些佇列不是libuv本身的一部分,但它們是NodeJS的一部分。他們是,
- Next Ticks佇列:使用
process.nextTick
函式新增的回撥 - Other Microtasks佇列:包括其他 microtask,如 resolved promise回撥
它是如何工作的?
如下圖所示,Node通過檢查定時器佇列中的任何過期定時器來啟動事件迴圈,在每個步驟中經過每個佇列,同時維護一個引用計數器,表示要處理的總專案數。處理完close handlers佇列
後,如果在任何佇列中沒有要處理的專案,則迴圈將退出。執行事件迴圈中的每個佇列可以被視為事件迴圈的一個階段。
紅色描述的中間佇列的有趣之處在於,只要一個階段完成,事件迴圈就會檢查這兩個中間佇列中的任何可執行項。如果中間佇列中有任何項可執行,則事件迴圈將立即開始處理它們,直到兩個直接佇列被清空。一旦它們是空的,事件迴圈將繼續到下一個階段。
例如,事件迴圈當前正在處理具有5個
handler
的immediates佇列
。同時,兩個回撥被新增到next tick佇列
中。一旦事件迴圈完成了immediates佇列
中的5個handler
,事件迴圈將檢測到,在移動到close handlers佇列
之前,有兩個專案要在next tick佇列
中處理。然後它將執行next tick佇列
中的所有回撥,然後再往前移動處理close handlers佇列
。
Next tick佇列 vs Other Microtasks
Next tick佇列
比microtasks佇列
具有更高的優先順序。不過,它們都在事件迴圈的兩個階段之間進行處理,也就是在結束一個階段後libuv通訊回傳到上層的時候【譯者注:這裡其實是NodeJS在libuv觸發每個階段執行的hook上注入了這個邏輯,詳情可見作者的另一篇部落格】。您會注意到,我已經以深紅色顯示Next tick佇列
,這意味著在開始處理microtasks佇列
中的 resolved promise之前,先清空Next tick佇列
。
Next tick佇列
優先於 resolved promise 僅適用於v8提供的原生JS promise。如果你正在使用像q
、bluebird
這樣的庫,你會觀察到一個完全不同的結果,因為它們比原生 promise 早出現,而且具有不同的語義。
q
和bluebird
在處理 resolved promise 方面也有所不同,我將在稍後的文章中解釋。
這些所謂的“中間”佇列的慣例引入了一個新問題,即IO飢餓。使用process.nextTick
函式不斷地填充Next tick佇列
,將強制事件迴圈無限期地繼續處理Next tick佇列
,而不向前移動進入一個階段。這將導致IO飢餓,因為如果不清空Next tick佇列
,事件迴圈無法繼續。
為了防止這種情況發生,以前可以設定
process.maxTickDepth
引數限制Next tick佇列
,但是由於某種原因,它已經從NodeJS v0.12中刪除。
我將在後面的帖子中用例項深入描述每個佇列。
最後,現在您知道什麼是事件迴圈,它是如何實現的以及Node如何處理非同步I / O。現在我們來看看Libuv在NodeJS架構中的位置。
NodeJS架構中的Libuv我希望這篇文章對你有幫助,在後面的文章中,我將闡述:
- timers,immediates和
process.nextTick
- resolved promise 和
process.nextTick
- I / O處理
- 事件迴圈的最佳實踐
還有更多細節。如果有任何需要更正或新增的內容,請隨時新增評論。
參考文獻:
- NodeJS API文件https://nodejs.org/api
- NodeJS Github https://github.com/nodejs/node/
- Libuv官方檔案http://docs.libuv.org/
- NodeJS設計模式https://www.packtpub.com/mapt/book/web-development/9781783287314
- 關於Node.js事件迴圈需要了解的一切,Bert Belder,IBM https://www.youtube.com/watch?v=PNa9OMajw9w
- Node的事件迴圈,Sam Roberts,IBM https://www.youtube.com/watch?v=P9csgxBgaZ8
- 非同步磁碟I / O http://blog.libtorrent.org/2012/10/asynchronous-disk-io/
- JavaScript中的事件迴圈https://acemood.github.io/2016/02/01/event-loop-in-javascript/