Node.js | 深入理解事件循環機制(Node.js篇)
什麼是事件循環
首先我們需要了解一下最基礎的一些東西,比如這個事件循環,事件循環是指Node.js
執行非阻塞I/O
操作,儘管JavaScript
是單線程的,但由於大多數內核都是多線程的,Node.js
會盡可能將操作裝載到系統內核。因此它們可以處理在後台執行的多個操作。當其中一個操作完成時,內核會告訴Node.js
,以便Node.js
可以將相應的回調添加到輪詢隊列中以最終執行。
當Node.js
啟動時會初始化event loop
, 每一個event loop
都會包含按如下順序六個循環階段:
timers
階段:這個階段執行timer(setTimeout、setInterval)的回調I/O callbacks
階段:執行一些系統調用錯誤,比如網路通信的錯誤回調idle, prepare
階段:僅node內部使用poll
階段:獲取新的I/O事件, 適當的條件下node將阻塞在這裡check
階段:執行 setImmediate() 的回調close callbacks
階段:執行 socket 的 close 事件回調
循環事件詳解
上圖是整個Node.js
的運行原理,從左到右,從上到下,Node.js
被分為了四層,分別是應用層、V8
引擎層、Node API
層和LIBUV
層。
1
2
3
4 應用層: 即 JavaScript 交互層,常見的就是 Node.js 的模組,比如 http,fs
V8引擎層: 即利用 V8 引擎來解析JavaScript 語法,進而和下層 API 交互
NodeAPI層: 為上層模組提供系統調用,一般是由 C 語言來實現,和操作系統進行交互 。
LIBUV層: 是跨平台的底層封裝,實現了 事件循環、文件操作等,是 Node.js 實現異步的核心 。
各個循環階段內容詳解
timers 階段:
一個timer
指定一個下限時間而不是準確時間,在達到這個下限時間後執行回調。在指定時間過後,timers
會盡可能早地執行回調,但系統調度或者其它回調的執行可能會延遲它們。
- 注意:技術上來說,
poll
階段控制timers
什麼時候執行。 - 注意:這個下限時間有個範圍
[1, 2147483647]
,如果設定的時間不在這個範圍,將被設置為1
。
I/O callbacks 階段:
這個階段執行一些系統操作的回調。比如TCP
錯誤,如一個TCP socket
在想要連接時收到ECONNREFUSED
,類unix
系統會等待以報告錯誤,這就會放到I/O callbacks
階段的隊列執行。名字會讓人誤解為執行I/O
回調處理程序,實際上I/O
回調會由poll
階段處理。
poll 階段:
poll
階段有兩個主要功能:
(1)執行下限時間已經達到的timers
的回調。
(2)然後處理poll
隊列裡的事件。
當event loop
進入poll
階段,並且沒有設定的timers
(there are no timers scheduled
),會發生下面兩件事之一:
如果
poll
隊列不空,event loop
會遍歷隊列並同步執行回調,直到隊列清空或執行的回調數到達系統上限;如果
poll
隊列為空,則發生以下兩件事之一:- 如果程式已經被
setImmediate()
設定了回調,event loop
將結束poll
階段進入check
階段來執行check
隊列(裡面的回調callback
)。 - 如果程式沒有被
setImmediate()
設定回調,event loop
將阻塞在該階段等待回調被加入poll
隊列,並立即執行。
- 如果程式已經被
但是,當
event loop
進入poll
階段,並且有設定的timers
,一旦poll
隊列為空(poll
階段空閒狀態):event loop
將檢查timers
,如果有1
個或多個timers
的下限時間已經到達,event loop
將繞回timers
階段,並執行timer
隊列。
check 階段:
這個階段允許在poll
階段結束後立即執行回調。如果poll
階段空閒,並且有被setImmediate()
設定的回調,event loop
會轉到check
階段而不是繼續等待。
setImmediate()
實際上是一個特殊的timer
,跑在event loop
中一個獨立的階段。它使用libuv
的API
來設定在poll
階段結束後立即執行回調。
- 通常上來講,隨著程式執行,
event loop
終將進入poll
階段,在這個階段等待incoming connection, request
等等。但是,只要有被setImmediate()
設定了回調,一旦poll
階段空閒,那麼程序將結束poll
階段並進入check
階段,而不是繼續等待poll
事件們(poll events
)。
close callbacks 階段:
如果一個socket
或handle
被突然關掉(比如 socket.destroy()
),close
事件將在這個階段被觸發,否則將通過process.nextTick()
觸發。
範例解析
1 | const fs = require('fs'); |
這為了讓這個setTimeout
優先於fs.readFile
回調,執行了process.nextTick
,表示在進入timers
階段前,等待20ms
後執行文件讀取。
1.nextTick與setImmediate
process.nextTick
不屬於事件循環的任何一個階段,它屬於該階段與下階段之間的過渡,即本階段執行結束,進入下一個階段前,所要執行的回調。有給人一種插隊的感覺。setImmediate
的回調處於check
階段,當poll
階段的隊列為空,且check
階段的事件隊列存在的時候,切換到check
階段執行。
nextTick
遞歸的危害
由於nextTick
具有插隊的機制,nextTick
的遞歸會讓事件循環機制無法進入下一個階段,導致I/O
處理完成或者定時任務超時後仍然無法執行,導致了其它事件處理程序處於飢餓狀態。為了防止遞歸產生的問題,Node.js
提供了一個process.maxTickDepth
(默認1000
)。
1 | const fs = require('fs'); |
此時永遠無法跳到timer
階段去執行setTimeout
裡面的回調方法,因為在進入timers
階段前有不斷的nextTick
插入執行。除非執行了1000
次到了執行上限,所以上面這個範例會不斷地印出出nextTick
字符串。
2.setImmediate
如果在一個I/O
週期內進行調度,setImmediate()
將始終在任何定時器(setTimeout
、setInterval
)之前執行。
3.setTimeout與setImmediate
setImmediate()
被設計在poll
階段結束後立即執行回調。setTimeout()
被設計在指定下限時間到達後執行回調。
無I/O
處理情況下:
1 | setTimeout(function timeout () { |
執行結果:
1 | $ node main.js |
從結果,我們可以發現,這裡印出輸出出來的結果,並沒有什麼固定的先後順序,偏向於隨機,為什麼會發生這樣的情況呢?
答:首先進入的是timers
階段,如果我們的機器性能一般,那麼進入timers
階段,1ms
已經過去了(setTimeout(fn, 0)
等價於setTimeout(fn, 1))
,那麼setTimeout
的回調會首先執行。
如果沒有到1ms
,那麼在timers
階段的時候,下限時間沒到,setTimeout
回調不執行,事件循環來到了poll
階段,這個時候隊列為空,於是往下繼續,先執行了setImmediate()
的回調函數,之後在下一個事件循環再執行setTimemout
的回調函數。
問題總結:而我們在執行啟動程式的時候,進入timers
的時間延遲其實是隨機的,並不是確定的,所以會出現兩個函數執行順序隨機的情況。
再看一段程式:
1 | var fs = require('fs') |
執行結果:
1 | $ node main.js |
為什麼和上面的隨機timer
不一致呢,我們來分析下原因:
原因如下:fs.readFile
的回調是在poll
階段執行的,當其回調執行完畢之後,poll
隊列為空,而setTimeout
入了timers
的隊列,此時有程式setImmediate()
,於是事件循環先進入check
階段執行回調,之後在下一個事件循環再在timers
階段中執行回調。
此範例同理可證:
1 | setTimeout(() => { |
以上的程式在timers
階段執行外部的setTimeout
回調後,內層的setTimeout
和setImmediate
入隊,之後事件循環繼續往後面的階段走,走到poll
階段的時候發現隊列為空,此時有程式有setImmedate()
,所以直接進入check
階段執行響應回調(注意這裡沒有去檢測timers
隊列中是否有成員到達下限事件,因為setImmediate()
優先)。之後在第二個事件循環的timers
階段中再去執行相應的回調。
綜上所示範,我們可以總結如下:
- 如果兩者都在主模組中調用,那麼執行先後取決於進程性能,也就是你的電腦好撇,當然也就是隨機。
- 如果兩者都不在主模組調用(被一個異步操作包裹),那麼setImmediate的回調永遠先執行。
4.nextTick與Promise
概念:對於這兩個,我們可以把它們理解成一個微任務。也就是說,它其實不屬於事件循環的一部分。那麼他們是在什麼時候執行呢?不管在什麼地方調用,他們都會在其所處的事件循環最後,事件循環進入下一個循環的階段前執行。
1 | setTimeout(() => { |
執行結果:
1 | $ node main.js |
最後總結:timers
階段執行外層setTimeout
的回調,遇到同步程式先執行,也就有timeout0
、sync
的輸出。
遇到process.nextTick
及Promise
後入微任務隊列,依次nextTick1
、nextTick3
、nextTick2
、resolved
入隊後出隊輸出。
之後,在下一個事件循環的timers
階段,執行微任務Promise
裡面的setTimeout
,輸出timeout resolved
以及setTimeout
回調輸出timeout2
。(這裡要說明的是微任務nextTick
優先級要比Promise
要高)
最後範例
1 | setImmediate(function(){ |
事件循環check
階段執行回調函數輸出setImmediate
,之後輸出nextTick
。嵌套的setImmediate
在下一個事件循環的check
階段執行回調輸出嵌套的setImmediate
。
1 | async function async1(){ |
執行結果:
1 | $ node main.js |
Node.js 與瀏覽器的 Event Loop 差異
回顧上一篇,瀏覽器環境下,microtask
的任務隊列是每個macrotask
執行完之後執行。
而在Node.js
中,microtask
會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask
隊列的任務。
1 | setTimeout(()=>{ |
全局腳本(main()
)執行,將2
個timer
依次放入timer
隊列,main()
執行完畢,調用棧空閒,任務隊列開始執行;
首先進入timers
階段,執行timer1
的回調函數,印出timer1
,並將promise1.then
回調放入microtask
隊列,同樣的步驟執行timer2
,印出timer2
;
至此,timer
階段執行結束,event loop
進入下一個階段之前,執行microtask
隊列的所有任務,依次印出promise1
、promise2
。
註:以上參考了
深入理解js事件循环机制(浏览器篇)
深入理解js事件循环机制(Node.js篇)
深入了解nodejs的事件循环机制
深入理解NodeJS事件循环机制