【譯】NodeJS事件循環Part 1

blank

【譯】NodeJS事件循環Part 1

譯者註:這是我看過最好的解釋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沒有任何等待中的請求,則程序將完成。否則,該過程將從第一步繼續下去。

編排整個機制的程序稱為事件循環。

blank

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

注意:不要把事件循環和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。

那麼,誰呢?女士們,先生們,歡迎...。

blank

從官方libuv文檔中,

libuv是最初為NodeJS編寫的跨平台支持庫。它圍繞事件驅動的異步I / O模型進行設計。
該庫提供的不僅僅是對不同I / O輪詢機制的簡單抽象:'handles'和'streams'為套接字和其他實體提供了高級抽象, 還提供了跨平台的文件I / O和線程功能。

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

blank

現在我們知道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队列後,如果在任何隊列中沒有要處理的項目,則循環將退出。執行事件循環中的每個隊列可以被視為事件循環的一個階段。

blank

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

例如,事件循環當前正在處理具有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。如果你正在使用像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架構中的位置。

blank

NodeJS架構中的Libuv

我希望這篇文章對你有幫助,在後面的文章中,我將闡述:

  • timers,immediate和process.nextTick
  • resolved promise和process.nextTick
  • I / O處理
  • 事件循環的最佳實踐

還有更多細節。如果有任何需要更正或添加的內容,請隨時添加評論。

參考文獻:

What do you think?

Written by marketer

blank

導讀《React Native at Airbnb》—— 為什麼Airbnb 放棄了React Native?

blank

Rust & WebAssembly 初探