Like Share Discussion Bookmark Smile

J.J. Huang   2020-01-21   Node.js   瀏覽次數:

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 APILIBUV

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中一個獨立的階段。它使用libuvAPI來設定在poll階段結束後立即執行回調。
  • 通常上來講,隨著程式執行,event loop終將進入poll階段,在這個階段等待incoming connection, request等等。但是,只要有被setImmediate()設定了回調,一旦poll階段空閒,那麼程序將結束poll階段並進入check階段,而不是繼續等待poll事件們(poll events)。

close callbacks 階段:

如果一個sockethandle被突然關掉(比如 socket.destroy()),close事件將在這個階段被觸發,否則將通過process.nextTick()觸發。

範例解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const fs = require('fs');
let counts = 0;

// 定義一個 wait 方法
function wait (mstime) {
  let date = Date.now();
  while (Date.now() - date < mstime) {
    // do nothing
  }
}

// 讀取本地文件 操作IO
function asyncOperation (callback) {
  fs.readFile(__dirname + '/' + __filename, callback);
}

const lastTime = Date.now();

// setTimeout
setTimeout(() => {
  console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

// process.nextTick
process.nextTick(() => {
  // 進入event loop
  // timers階段之前執行
  wait(20);
  asyncOperation(() => {
    console.log('poll');
  });
});

/** 輸出結果
 * timers 21ms
 * poll
 */

這為了讓這個setTimeout優先於fs.readFile回調,執行了process.nextTick,表示在進入timers階段前,等待20ms後執行文件讀取。

1.nextTicksetImmediate

  • process.nextTick不屬於事件循環的任何一個階段,它屬於該階段與下階段之間的過渡,即本階段執行結束,進入下一個階段前,所要執行的回調。有給人一種插隊的感覺。
  • setImmediate的回調處於check階段,當poll階段的隊列為空,且check階段的事件隊列存在的時候,切換到check階段執行。

nextTick遞歸的危害

由於nextTick具有插隊的機制,nextTick的遞歸會讓事件循環機制無法進入下一個階段,導致I/O處理完成或者定時任務超時後仍然無法執行,導致了其它事件處理程序處於飢餓狀態。為了防止遞歸產生的問題,Node.js提供了一個process.maxTickDepth(默認1000)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const fs = require('fs');
let counts = 0;

function wait (mstime) {
let date = Date.now();
while (Date.now() - date < mstime) {
// do nothing
}
}

function nextTick () {
process.nextTick(() => {
wait(20);
counts++;
console.log('nextTick:'+counts);
nextTick();
});
}

const lastTime = Date.now();

setTimeout(() => {
console.log('timers', Date.now() - lastTime + 'ms');
}, 0);

nextTick();

此時永遠無法跳到timer階段去執行setTimeout裡面的回調方法,因為在進入timers階段前有不斷的nextTick插入執行。除非執行了1000次到了執行上限,所以上面這個範例會不斷地印出出nextTick字符串。

2.setImmediate

如果在一個I/O週期內進行調度,setImmediate()將始終在任何定時器(setTimeoutsetInterval)之前執行。

3.setTimeoutsetImmediate

  • setImmediate()被設計在poll階段結束後立即執行回調。
  • setTimeout()被設計在指定下限時間到達後執行回調。

I/O處理情況下:

1
2
3
4
5
6
7
setTimeout(function timeout () {
console.log('timeout');
},0);

setImmediate(function immediate () {
console.log('immediate');
});

執行結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ node main.js
timeout
immediate
$ node main.js
immediate
timeout
$ node main.js
immediate
timeout
$ node main.js
immediate
timeout
$ node main.js
timeout
immediate
$ node main.js
timeout
immediate

從結果,我們可以發現,這裡印出輸出出來的結果,並沒有什麼固定的先後順序,偏向於隨機,為什麼會發生這樣的情況呢?

答:首先進入的是timers階段,如果我們的機器性能一般,那麼進入timers階段,1ms已經過去了(setTimeout(fn, 0)等價於setTimeout(fn, 1)),那麼setTimeout的回調會首先執行。

如果沒有到1ms,那麼在timers階段的時候,下限時間沒到,setTimeout回調不執行,事件循環來到了poll階段,這個時候隊列為空,於是往下繼續,先執行了setImmediate()的回調函數,之後在下一個事件循環再執行setTimemout的回調函數。

問題總結:而我們在執行啟動程式的時候,進入timers的時間延遲其實是隨機的,並不是確定的,所以會出現兩個函數執行順序隨機的情況。

再看一段程式:

1
2
3
4
5
6
7
8
9
10
var fs = require('fs')

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});

執行結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ node main.js
immediate
timeout
$ node main.js
immediate
timeout
$ node main.js
immediate
timeout
$ node main.js
immediate
timeout
$ node main.js
immediate
timeout

## ... 省略 n 多次使用 node main.js 指令 ,結果都輸出 immediate timeout

為什麼和上面的隨機timer不一致呢,我們來分析下原因:

原因如下:fs.readFile的回調是在poll階段執行的,當其回調執行完畢之後,poll隊列為空,而setTimeout入了timers的隊列,此時有程式setImmediate(),於是事件循環先進入check階段執行回調,之後在下一個事件循環再在timers階段中執行回調。

此範例同理可證:

1
2
3
4
5
6
7
8
setTimeout(() => {
setImmediate(() => {
console.log('setImmediate');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
}, 0);

以上的程式在timers階段執行外部的setTimeout回調後,內層的setTimeoutsetImmediate入隊,之後事件循環繼續往後面的階段走,走到poll階段的時候發現隊列為空,此時有程式有setImmedate(),所以直接進入check階段執行響應回調(注意這裡沒有去檢測timers隊列中是否有成員到達下限事件,因為setImmediate()優先)。之後在第二個事件循環的timers階段中再去執行相應的回調。

綜上所示範,我們可以總結如下:

  • 如果兩者都在主模組中調用,那麼執行先後取決於進程性能,也就是你的電腦好撇,當然也就是隨機。
  • 如果兩者都不在主模組調用(被一個異步操作包裹),那麼setImmediate的回調永遠先執行

4.nextTickPromise

概念:對於這兩個,我們可以把它們理解成一個微任務。也就是說,它其實不屬於事件循環的一部分。那麼他們是在什麼時候執行呢?不管在什麼地方調用,他們都會在其所處的事件循環最後,事件循環進入下一個循環的階段前執行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
setTimeout(() => {
console.log('timeout0');
new Promise((resolve, reject) => { resolve('resolved') }).then(res => console.log(res));
new Promise((resolve, reject) => {
setTimeout(()=>{
resolve('timeout resolved')
})
}).then(res => console.log(res));
process.nextTick(() => {
console.log('nextTick1');
process.nextTick(() => {
console.log('nextTick2');
});
});
process.nextTick(() => {
console.log('nextTick3');
});
console.log('sync');
setTimeout(() => {
console.log('timeout2');
}, 0);
}, 0);

執行結果:

1
2
3
4
5
6
7
8
9
$ node main.js
timeout0
sync
nextTick1
nextTick3
nextTick2
resolved
timeout resolved
timeout2

最後總結:
timers階段執行外層setTimeout的回調,遇到同步程式先執行,也就有timeout0sync的輸出。
遇到process.nextTickPromise後入微任務隊列,依次nextTick1nextTick3nextTick2resolved入隊後出隊輸出。
之後,在下一個事件循環的timers階段,執行微任務Promise裡面的setTimeout,輸出timeout resolved以及setTimeout回調輸出timeout2。(這裡要說明的是微任務nextTick優先級要比Promise要高)

最後範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
setImmediate(function(){
console.log("setImmediate");
setImmediate(function(){
console.log("嵌套setImmediate");
});
process.nextTick(function(){
console.log("nextTick");
})
});

/** 輸出結果
 * setImmediate
 * nextTick
 * 嵌套setImmediate
 */

事件循環check階段執行回調函數輸出setImmediate,之後輸出nextTick。嵌套的setImmediate在下一個事件循環的check階段執行回調輸出嵌套的setImmediate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function async1(){
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2(){
console.log('async2')
}
console.log('script start')
setTimeout(function(){
console.log('setTimeout0')
},0)
setTimeout(function(){
console.log('setTimeout3')
},3)
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
async1();
new Promise(function(resolve){
console.log('promise1')
resolve();
console.log('promise2')
}).then(function(){
console.log('promise3')
})
console.log('script end')

執行結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ node main.js
script start
async1 start
async2
promise1
promise2
script end
nextTick
async1 end
promise3
setTimeout0
setImmediate
setTimeout3

Node.js 與瀏覽器的 Event Loop 差異

回顧上一篇,瀏覽器環境下,microtask的任務隊列是每個macrotask執行完之後執行。

而在Node.js中,microtask會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask隊列的任務。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
setTimeout(()=>{
console.log('timer1')

Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)

setTimeout(()=>{
console.log('timer2')

Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)

全局腳本(main())執行,將2timer依次放入timer隊列,main()執行完畢,調用棧空閒,任務隊列開始執行;

首先進入timers階段,執行timer1的回調函數,印出timer1,並將promise1.then回調放入microtask隊列,同樣的步驟執行timer2,印出timer2

至此,timer階段執行結束,event loop進入下一個階段之前,執行microtask隊列的所有任務,依次印出promise1promise2


註:以上參考了
深入理解js事件循环机制(浏览器篇)
深入理解js事件循环机制(Node.js篇)
深入了解nodejs的事件循环机制
深入理解NodeJS事件循环机制