【譯】NodeJS事件循環Part 1
譯者註:這是我看過最好的解釋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
的立即队列
。同時,兩個handler
被添加到next tick队列
中。一旦事件循環完成了immediate队列
中的5個handler
,事件循環將檢測到,在移動到close handlers队列
之前,有兩個項目要在next tick队列
中處理。然後它將執行next tick
隊列中的所有`handler`,然後再往前移動處理close handlers队列
。
Next tick隊列vs Other Microtasks
Next tick队列
比Other 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,immediate和
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/201 2/10/asynchronous-disk-io/
- JavaScript中的事件循環https:// acemood.github.io/2016/ 02/01/event-loop-in-javascript/