【譯】NodeJS事件迴圈 Part 1

ZHUECJCVHAPSQZMH發表於2018-06-23

譯者注:這是我看過最好的解釋NodeJS事件迴圈的系列文章。點選檢視原文(請自備梯子)

作為開篇第一章,作者非常詳細認真甚至有點囉嗦地介紹了事件迴圈的基本工作流程,解釋了libuv主要解決的問題,同時從應用層JavaScript的角度出發,將事件迴圈的所有階段區分為lbuv原生的和NodeJS額外新增的(事實也是這樣。很多時候我們並不知道需要區分這兩者),我覺得有了這些基礎,會更加容易理解事件迴圈的其餘部分和細節。不管是新手還是資深NodeJS程式設計師,這都是一篇不可多得值得一讀的文章。

NodeJS與其他程式設計平臺的區別在於它如何處理I / O。我們經常聽到NodeJS被稱為“基於谷歌的v8 javascript引擎的非阻塞事件驅動平臺”。什麼意思?“非阻塞”和“事件驅動”是什麼意思?所有這些答案都在NodeJS的事件迴圈的核心。 在本專題中,我將介紹什麼是事件迴圈,它是如何工作的,它如何影響我們的應用程式,如何充分利用它以及更多。為什麼是專題而不就一篇文章?那樣的話,將會是一篇非常長的文章,我肯定會忽略某些地方,因此我將撰寫一個關於NodeJS事件迴圈的專題。 在第一篇文章中,我將介紹NodeJS如何工作,如何訪問I / O以及如何與不同的平臺一起工作等。

本專題目錄

Reactor模式

NodeJS以事件驅動模型執行,涉及到Event Demultiplexer 和 Event Queue。所有I / O請求最終都會產生一個完成或失敗的事情,或者其他觸發器,這些即稱為“事件”。這些事件按照以下演算法進行處理。

  1. Event Demultiplexer 接收I / O請求並將這些請求委託給適當的硬體。
  2. 一旦I / O請求被處理(例如,來自檔案的資料可被讀取,來自套接字的資料可被讀取等),Event Demultiplexer 將針對特定動作的已註冊的回撥新增到佇列中等待處理。這些回撥稱為事件,新增事件的佇列稱為事件佇列
  3. 當事件可以在事件佇列中處理時,它們按照它們接收的順序依次執行,直到佇列為空。
  4. 如果事件佇列中沒有事件,或 Event Demultiplexer 沒有任何等待中的請求,則程式將完成。否則,該過程將從第一步繼續下去。

編排整個機制的程式稱為事件迴圈。

event loop

事件迴圈是一個單執行緒和半無限的迴圈。之所以叫半無限的迴圈,是因為當沒有任務執行時,該迴圈實際上會停止。從開發人員的角度來看,這也是程式退出的地方。

注意:不要把事件迴圈和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.confresolv.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 logo

從官方libuv文件中,

libuv是最初為NodeJS編寫的跨平臺支援庫。它圍繞事件驅動的非同步I / O模型進行設計。

該庫提供的不僅僅是對不同I / O輪詢機制的簡單抽象:'handles'和'streams'為套接字和其他實體提供了高階抽象, 還提供了跨平臺的檔案I / O和執行緒功能。

現在讓我們看看libuv是如何組成的。下圖來自官方的libuv文件,描述了在暴露廣義API時如何處理不同型別的I / O。

libuv

現在我們知道 Event Demultiplexer 不是單個實體,而是由Libuv提取並暴露給NodeJS上層的處理I / O的API集合。它不僅是libuv為Node提供的 Event Demultiplexer。而且Libuv為NodeJS提供了整個事件迴圈功能,包括事件排隊機制。

現在讓我們看看事件佇列

事件佇列

事件佇列應該是一個資料結構,其中所有的事件都被順序排列並由事件迴圈處理,直到佇列為空。 但是這個過程在Node中的實際發生情況和 Reactor 模式描述的完全不同。那它有什麼不同?

NodeJS中有多個佇列,其中不同型別的事件在自己的佇列中排隊。

在處理完一個階段後,在進入下一個階段之前,事件迴圈將處理兩個中間佇列,直到中間佇列清空。

那麼有幾個佇列呢?中間佇列是什麼?

原生的libuv事件迴圈處理的佇列有4種主要型別。

  1. 過期的定時器和間隔(timers and intervals)佇列:由通過setTimeoutsetInterval新增的過期的定時器的回撥。
  2. IO事件佇列: 完成的IO事件
  3. Immediates佇列:使用setImmediate函式新增的回撥
  4. close handlers佇列:任何close事件處理。

請注意,儘管為了簡單起見,我提到所有這些都是“ 佇列 ”,但其中一些實際上是不同型別的資料結構(例如,定時器儲存在最小堆中)

除了這4個主要佇列之外,還有2個有趣的佇列,我之前提到這些佇列是“中間佇列”並由Node處理。雖然這些佇列不是libuv本身的一部分,但它們是NodeJS的一部分。他們是,

  • Next Ticks佇列:使用process.nextTick函式新增的回撥
  • Other Microtasks佇列:包括其他 microtask,如 resolved promise回撥

它是如何工作的? 如下圖所示,Node通過檢查定時器佇列中的任何過期定時器來啟動事件迴圈,在每個步驟中經過每個佇列,同時維護一個引用計數器,表示要處理的總專案數。處理完close handlers佇列後,如果在任何佇列中沒有要處理的專案,則迴圈將退出。執行事件迴圈中的每個佇列可以被視為事件迴圈的一個階段。

how libuv work

紅色描述的中間佇列的有趣之處在於,只要一個階段完成,事件迴圈就會檢查這兩個中間佇列中的任何可執行項。如果中間佇列中有任何項可執行,則事件迴圈將立即開始處理它們,直到兩個直接佇列被清空。一旦它們是空的,事件迴圈將繼續到下一個階段。

例如,事件迴圈當前正在處理具有5個handlerimmediates佇列。同時,兩個回撥被新增到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。如果你正在使用像qbluebird這樣的庫,你會觀察到一個完全不同的結果,因為它們比原生 promise 早出現,而且具有不同的語義。

qbluebird在處理 resolved promise 方面也有所不同,我將在稍後的文章中解釋。

這些所謂的“中間”佇列的慣例引入了一個新問題,即IO飢餓。使用process.nextTick函式不斷地填充Next tick佇列,將強制事件迴圈無限期地繼續處理Next tick佇列,而不向前移動進入一個階段。這將導致IO飢餓,因為如果不清空Next tick佇列,事件迴圈無法繼續。

為了防止這種情況發生,以前可以設定process.maxTickDepth引數限制Next tick佇列,但是由於某種原因,它已經從NodeJS v0.12中刪除。

我將在後面的帖子中用例項深入描述每個佇列。

最後,現在您知道什麼是事件迴圈,它是如何實現的以及Node如何處理非同步I / O。現在我們來看看Libuv在NodeJS架構中的位置。

libuv in 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/

相關文章