NodeJS 于其它任何平臺(tái)的區(qū)別在于它如何處理 I/O。當(dāng)我們聽到 NodeJS 被一些人介紹的時(shí)候總是說:非阻塞,基于 google v8 js 引擎的事件驅(qū)動(dòng)平臺(tái)。這些意味著什么?”非阻塞“和”事件驅(qū)動(dòng)“是什么意思?
所有的這些都在于 NodeJS 的核心,即 Event Loop.在接下來的一些列內(nèi)容中,我將描述event loop 是什么,它如何工作,它如何作用于我們的應(yīng)用,如何更好去應(yīng)用它以及更多。為什么是一系列內(nèi)容而不是一個(gè)?因?yàn)檫@個(gè)真的說來話長(zhǎng)我肯定會(huì)有一些遺漏,所以我將寫一個(gè)系列。在這第一篇文章中,我將描述 NodeJS 如何工作,它如何獲取 I/O 以及如何運(yùn)行在不同的平臺(tái)上等等。
Reactor 模式:
NodoJS 在事件驅(qū)動(dòng)模型工作時(shí)需要 Event Demultiplexer 和事件隊(duì)列。所有的 I/O 請(qǐng)求將會(huì)最終生成一個(gè)完成或者失敗的事件或者其它的觸發(fā),這些都叫做 Event(事件)。這些事件的進(jìn)行是遵循一定的算法規(guī)則的。
- Event demultiplexer 接收 I/O 請(qǐng)求然后將他們分發(fā)到合適的硬件上。
- 一旦處理了 I/O 請(qǐng)求(比如 可以讀取來自文件的數(shù)據(jù), 可以讀取來自套接字的數(shù)據(jù)等等),event demultiplexer 將為隊(duì)列中的特定動(dòng)作添加已注冊(cè)的回調(diào)處理程序。這些回調(diào)事件被稱為 events 以及這個(gè)被添加事件的隊(duì)列被稱作事件隊(duì)列。
- 當(dāng)事件在隊(duì)列中準(zhǔn)備好被執(zhí)行的時(shí)候,它們會(huì)按照接收順序依次執(zhí)行,直到隊(duì)列為空。
- 如果事件隊(duì)列中沒有事件了,或者 Event Demultiplexer 沒有任何待處理的請(qǐng)求了,程序?qū)?huì)完成,否則,進(jìn)程會(huì)從第一步繼續(xù)執(zhí)行。
編排整個(gè)機(jī)制的程序被稱作事件循環(huán)(Event Loop)
事件循環(huán)是單線程(single threaded)并且半無限的循環(huán)。它被稱為半無限循環(huán)的原因是因?yàn)楫?dāng)沒有更多的工作要做時(shí),實(shí)際上會(huì)在某個(gè)時(shí)刻退出。 從開發(fā)人員的角度來看,這是程序退出的地方。
note: 不要混淆了事件循環(huán)和事件派發(fā)(Event Emitter)。事件派發(fā)完全和這個(gè)循環(huán)機(jī)制是不一樣的概念。接下去的一篇文章中,我將解釋事件派發(fā)如何通過事件循環(huán)去影響事件處理過程。

上面的示意圖是一個(gè)描述 NodeJS 如何工作的高度概覽,以及展示了這個(gè)設(shè)計(jì)模式中的主要組成。該設(shè)計(jì)模式稱為 Reactor Pattern ,實(shí)際上會(huì)更復(fù)雜。那么到底有多復(fù)雜呢?
tips:
- Event demultiplexer 并不是一個(gè)單一的組件去做所有操作系統(tǒng)上的所有類型的 I/O 操作。
- 事件隊(duì)列也不是一件單一的像這邊展示的隊(duì)列一樣。其中所有類型的事件都會(huì)被放入以及出隊(duì)列,I/O 不是唯一的會(huì)被放進(jìn)隊(duì)列的事件類型。
所以讓我們?cè)偕钊肓私庖幌隆?/p>
Event Demultiplexer
Event Demultiplexer 不是一個(gè)現(xiàn)實(shí)世界中真是存在的組件實(shí)物,而是一個(gè)在 reactor 模式中的抽象概念。在現(xiàn)實(shí)世界中,event dumultiplexer 在不同的系統(tǒng)中的實(shí)現(xiàn)被叫做不同的名字,比如 Linux 中被叫做 epoll,BSD 系統(tǒng)(MacOS)中的 kqueue,Solaris 的 event ports,Windows 中的 IOCP 等等。NodeJS 使用這些實(shí)現(xiàn)的低級(jí)非阻塞,異步硬件 I/O 功能。
復(fù)雜的文件 I/O(Complexities in File I/O)
令人困惑的事實(shí)是,不是所有的類型的 I/O 都可以使用這些實(shí)現(xiàn)來執(zhí)行。即使是在同樣的操作系統(tǒng)平臺(tái)上,也存在很多復(fù)雜的對(duì)不同 I/O 類型的支持。通常,網(wǎng)絡(luò) I/O 可以使用這些 epoll、kqueue、事件端口和 IOCP以非阻塞方式執(zhí)行,但文件 I/O 卻要復(fù)雜得多。某些系統(tǒng)(Certain systems)如 Linux 不支持文件系統(tǒng)訪問的完全異步。在 MacOS 系統(tǒng)中,使用 kqueue 處理文件系統(tǒng)事件通知/信令會(huì)存在一些存在限制(您可以在此處閱讀有關(guān)這些復(fù)雜情況的更多信息here)解決所有這些文件系統(tǒng)復(fù)雜性以提供完全異步是非常復(fù)雜且?guī)缀醪豢赡艿摹?/p>
復(fù)雜的 DNS
與文件 I/O 類似,Node API 提供的特定 DNS 功能也有特定的復(fù)雜性,由于 NodeJS DNS 函數(shù)比如 dns.lookup 獲取系統(tǒng)配置文件比如 nsswitch.conf, resolv.conf 以及 /etc/hosts,上述描述的文件系統(tǒng)復(fù)雜性一樣也適用于 dxn.resolve 功能。
解決方式?
因此,引入了一個(gè)線程池被用于支持 I/O 函數(shù),但是這些函數(shù)不能由硬件異步 I/O 工具比如 epoll/kqueue/event ports 或者 IOCP 直接尋址?,F(xiàn)在我們知道不是所有的 I/O 函數(shù)會(huì)在線程池中執(zhí)行。NodeJS 已盡最大努力使用非阻塞和異步硬件 I/O 來完成大部分 I/O,但是對(duì)于阻塞或者更復(fù)雜的 I/O 類型【注:此處有一個(gè) complex to address,更復(fù)雜的尋址?】,需要用到線程池。
總結(jié)
正如我們所見,在現(xiàn)實(shí)世界中,在所有的不同類型的操作系統(tǒng)平臺(tái)上支持所有類型的 I/O(文件 I/O,網(wǎng)絡(luò) I/O,DNS 等等)是非常難的,一些 I/O 可以直接在原生的硬件設(shè)施上被支持,并且保留完全的異步,但是也有一些 I/O 類型需要在線程池中被執(zhí)行來達(dá)到異步。
tips: 關(guān)于 Node,很多開發(fā)者都有一個(gè)常見的誤解是 Node 執(zhí)行線程池中的所有 I/O.
為了在支持跨平臺(tái) I/O 的同時(shí)管理整個(gè)過程,應(yīng)該有一個(gè)抽象層,它封裝了這些平臺(tái)間和平臺(tái)內(nèi)的復(fù)雜性,并為Node 的上層暴露出一個(gè)通用的 API。
所以誰做了這個(gè),女士們先生們,有請(qǐng):libuv
從 libuv 的官方文檔中,提到:
- libuv 是最初為 NodeJS 編寫的跨平臺(tái)支持庫(kù),它是圍繞事件驅(qū)動(dòng)異步 I/O 模型來設(shè)計(jì)的。
- 該庫(kù)提供的不僅僅是對(duì)不同 I/O 輪詢機(jī)制(polling mechanisms)的簡(jiǎn)單抽象:'handle' 和 'streams' 為套接字和其他實(shí)體(entities)提供高級(jí)抽象; 除此之外,還提供跨平臺(tái)文件 I/O 和線程功能。
現(xiàn)在讓我看看 libuv 的組成。下面的示意圖來自于 libuv 的官網(wǎng)文檔,描述了不同類型的 I/O 是如何通過被暴露的通用 API 處理的。

現(xiàn)在我們知道 Event Demultiplexer,不是一個(gè)原子實(shí)體【注:其實(shí)這里就是指不是最小的粒度了】,而是一個(gè)由 Libuv 抽象并暴露給 NodeJS 上層的 I/O 處理 API 的集合。它不僅是 libuv 提供給 Node 的 event demultiplexer。Libuv 為 NodeJS 提供了完整的事件循環(huán)功能,包括事件隊(duì)列機(jī)制。
現(xiàn)在讓我們看一下事件隊(duì)列。
事件隊(duì)列(Event Queue)
事件隊(duì)列被認(rèn)為是一個(gè)所有事件被排列以及通過事件循環(huán)機(jī)制執(zhí)行直到隊(duì)列清空的數(shù)據(jù)結(jié)構(gòu),但是在 Node 中是如何發(fā)生的和抽象 reactor 模式的描述完全不一致的。所以哪里不一樣呢?
tips:
- NodeJS 中有多個(gè)隊(duì)列,不同類型的事件在不同的隊(duì)列中排隊(duì)。
- 在處理一個(gè)階段后并且在移動(dòng)到下一個(gè)階段之前,事件循環(huán)將會(huì)處理兩個(gè)中間隊(duì)列,直到?jīng)]有任何一個(gè)項(xiàng)目被遺留在中間隊(duì)列中。
所以到底有多少個(gè)隊(duì)列呢?這些中間隊(duì)列是什么呢?
原生的 Libuv 事件循環(huán)中主要處理四個(gè)隊(duì)列類型
- 過期的計(jì)時(shí)器及 intervals 隊(duì)列(Expired timers and intervals queue)-- 通過使用 setTimeout 添加的 expired timers 或者由 interval 函數(shù)添加 setInterval 組成的 interval 函數(shù)。
- IO 事件隊(duì)列(IO Event Queue) — 完整的 IO 事件
- Immediates 隊(duì)列(Immediates Queue) — 通過 setImmediate 函數(shù)添加的回調(diào)函數(shù)
- Close Handlers 隊(duì)列(Close Handlers Queue) -- 任何 Close Handlers
tips: 請(qǐng)注意,盡管我為了簡(jiǎn)單起見,將所有的這些都稱為“隊(duì)列”,但是實(shí)際上他們都會(huì)有不同的數(shù)據(jù)結(jié)構(gòu)(比如計(jì)時(shí)器是存儲(chǔ)在最小堆中(min-heap))
除了這四種隊(duì)列,還有2個(gè)我剛才提到的有趣的中間隊(duì)列被 Node 執(zhí)行。雖然這些隊(duì)列不是 libuv 自己的一部分,但是是 NodeJS 的一部分,它們是:
- Next Ticks Queue -- 通過 process.nextTick 函數(shù)添加的回調(diào)函數(shù)
- 其它微服務(wù)隊(duì)列(Other Microtasks Queue) -- 包括其它微服務(wù)比如 peomise 里的 resolved 回調(diào)函數(shù)
它是如何工作的呢?
正如你所見下面的示例圖,Node 通過在計(jì)時(shí)器中檢查任何過期計(jì)時(shí)器開始事件循環(huán),并在每個(gè)步驟中瀏覽每個(gè)隊(duì)列,同時(shí)保持要處理的總項(xiàng)目的參考計(jì)數(shù)器(and go through each queue in each step while maintaining a reference counter of total items to be processed)。在處理完關(guān)閉處理隊(duì)列(close handlers queue)的進(jìn)程后,如果隊(duì)列中沒有任何項(xiàng)目要執(zhí)行,這個(gè)循環(huán)將會(huì)退出。每個(gè)在事件循環(huán)中執(zhí)行的隊(duì)列可以被看做是事件循環(huán)中的一個(gè)階段。

紅色描述的中間隊(duì)列比較有趣的一點(diǎn)是,一旦一個(gè)階段完成了,事件循環(huán)將會(huì)檢查兩個(gè)中間隊(duì)列是否有任何可用的項(xiàng)目。如何這里有任何可用項(xiàng)目在中間隊(duì)列,事件循環(huán)將會(huì)馬上開始執(zhí)行它們直到兩個(gè)中間隊(duì)列被清空。一旦它們被清空,事件循環(huán)將會(huì)繼續(xù)執(zhí)行執(zhí)行下一個(gè)階段。
栗子:事件循環(huán)當(dāng)前執(zhí)行了有5個(gè)事件的 immediates 隊(duì)列(immediates queue)。同時(shí),兩個(gè)事件被添加到 next tick 隊(duì)列中,一旦事件循環(huán)完成了在 immediates 隊(duì)列中的五個(gè)事件,事件循環(huán)將會(huì)在移動(dòng)到下一個(gè) close handlers 隊(duì)列之前發(fā)現(xiàn)這里有兩個(gè)項(xiàng)目在 next tick 隊(duì)列中需要被執(zhí)行。然后它將會(huì)執(zhí)行所有 next tick 隊(duì)列中的事件,再移動(dòng)到 close handlers 隊(duì)列中。
Next tick 隊(duì)列和其他 Microtasks
Next tick 隊(duì)列比其它微任務(wù)隊(duì)列擁有更高的優(yōu)先級(jí)。雖然它們都是事件循環(huán)中的一環(huán)。當(dāng) libuv 在階段結(jié)束時(shí)回傳給更高層的 Node。你注意到我用暗紅色表示 next tick 隊(duì)列,表示在開始 microtasks 隊(duì)列中的 promise sesolved 進(jìn)程結(jié)束前, next tick 隊(duì)列將會(huì)清空。
tips:
- next tick 隊(duì)列的優(yōu)先級(jí)高于 promises resolved 僅僅是在 v8 引擎提供的原生 JS Promises 適用。如果你使用了如 q 或者 bluebird 之類的庫(kù),你會(huì)發(fā)現(xiàn)會(huì)出現(xiàn)完全不一樣的結(jié)果,因?yàn)樗麄儍?yōu)先于原生的 promise 并且有不同的語義。
- q 和 bluebird 同樣有不同的處理 prosemise resolved 的處理方式,我將會(huì)在之后的文章中解釋。
這些被稱作"中間"隊(duì)列的通常也會(huì)引入一個(gè)新的問題,IO 饑餓(IO starvation)。到處通過 process.nextTick 函數(shù)填充 next tick 隊(duì)列將會(huì)強(qiáng)制事件循環(huán)進(jìn)程一直處理 next tick 隊(duì)列而不是向前移動(dòng)。這個(gè)將會(huì)導(dǎo)致 IO starvation ,因?yàn)槭录h(huán)不能執(zhí)行下一步除非 next tick 隊(duì)列被清空。
tips: 為了避免這個(gè),他們通過用 process.maxTickDepth 參數(shù)給 next tick 隊(duì)列設(shè)置最大限制,但是它由于某些原因在 NodeJS v0.12 被移除了。
我將在接下去的幾篇文章用一些例子深入講解這些隊(duì)列。
最后,現(xiàn)在你知道事件循環(huán)是什么了,以及它如何實(shí)現(xiàn)以及 Node 如何處理異步I/O?,F(xiàn)在讓我們來看一下在 NodeJS 架構(gòu)中 Libuv 處于哪個(gè)位置。
