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事件循环机制
