導讀
ALL THE TIME,我們寫的的大部分javascript
程式碼都是在瀏覽器環境下編譯執行的,因此可能我們對瀏覽器的事件迴圈機制瞭解比Node.JS
的事件迴圈更深入一些,但是最近寫開始深入NodeJS學習的時候,發現NodeJS的事件迴圈機制和瀏覽器端有很大的區別,特此記錄來深入的學習了下,以幫助自己及小夥伴們忘記後查閱及理解。
什麼是事件迴圈
首先我們需要了解一下最基礎的一些東西,比如這個事件迴圈,事件迴圈是指Node.js執行非阻塞I/O操作,儘管==JavaScript是單執行緒的==,但由於大多數==核心都是多執行緒==的,Node.js
會盡可能將操作裝載到系統核心。因此它們可以處理在後臺執行的多個操作。當其中一個操作完成時,核心會告訴Node.js
,以便Node.js
可以將相應的回撥新增到輪詢佇列中以最終執行。
當Node.js啟動時會初始化event loop
, 每一個event loop
都會包含按如下順序六個迴圈階段:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
- [x] 1.
timers
階段: 這個階段執行setTimeout(callback)
和setInterval(callback)
預定的 callback; - [x] 2.
I/O callbacks
階段: 此階段執行某些系統操作的回撥,例如TCP錯誤的型別。 例如,如果TCP套接字在嘗試連線時收到 ECONNREFUSED,則某些* nix系統希望等待報告錯誤。 這將操作將等待在==I/O回撥階段==執行; - [x] 3.
idle, prepare
階段: 僅node內部使用; - [x] 4.
poll
階段: 獲取新的I/O事件, 例如操作讀取檔案等等,適當的條件下node將阻塞在這裡; - [x] 5.
check
階段: 執行setImmediate()
設定的callbacks; - [x] 6.
close callbacks
階段: 比如socket.on(‘close’, callback)
的callback會在這個階段執行;
事件迴圈詳解
這個圖是整個 Node.js 的執行原理,從左到右,從上到下,Node.js 被分為了四層,分別是 應用層
、V8引擎層
、Node API層
和 LIBUV層
。
- 應用層: 即 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()觸發
這裡呢,我們透過虛擬碼來說明一下,這個流程:
// 事件迴圈本身相當於一個死迴圈,當程式碼開始執行的時候,事件迴圈就已經啟動了
// 然後順序呼叫不同階段的方法
while(true){
// timer階段
timer()
// I/O callbacks階段
IO()
// idle階段
IDLE()
// poll階段
poll()
// check階段
check()
// close階段
close()
}
// 在一次迴圈中,當事件迴圈進入到某一階段,加入進入到check階段,突然timer階段的事件就緒,也會等到當前這次迴圈結束,再去執行對應的timer階段的回撥函式
// 下面看這裡例子
const fs = require('fs')
// timers階段
const startTime = Date.now();
setTimeout(() => {
const endTime = Date.now()
console.log(`timers: ${endTime - startTime}`)
}, 1000)
// poll階段(等待新的事件出現)
const readFileStart = Date.now();
fs.readFile('./Demo.txt', (err, data) => {
if (err) throw err
let endTime = Date.now()
// 獲取檔案讀取的時間
console.log(`read time: ${endTime - readFileStart}`)
// 透過while迴圈將fs回撥強制阻塞5000s
while(endTime - readFileStart < 5000){
endTime = Date.now()
}
})
// check階段
setImmediate(() => {
console.log('check階段')
})
/*控制檯列印check階段read time: 9timers: 5008透過上述結果進行分析,1.程式碼執行到定時器setTimeOut,目前timers階段對應的事件列表為空,在1000s後才會放入事件2.事件迴圈進入到poll階段,開始不斷的輪詢監聽事件3.fs模組非同步執行,根據檔案大小,可能執行時間長短不同,這裡我使用的小檔案,事件大概在9s左右4.setImmediate執行,poll階段暫時未監測到事件,發現有setImmediate函式,跳轉到check階段執行check階段事件(列印check階段),第一次時間迴圈結束,開始下一輪事件迴圈5.因為時間仍未到定時器截止時間,所以事件迴圈有一次進入到poll階段,進行輪詢6.讀取檔案完畢,fs產生了一個事件進入到poll階段的事件佇列,此時事件佇列準備執行callback,所以會列印(read time: 9),人工阻塞了5s,雖然此時timer定時器事件已經被新增,但是因為這一階段的事件迴圈為完成,所以不會被執行,(如果這裡是死迴圈,那麼定時器程式碼永遠無法執行)7.fs回撥阻塞5s後,當前事件迴圈結束,進入到下一輪事件迴圈,發現timer事件佇列有事件,所以開始執行 列印timers: 5008ps:1.將定時器延遲時間改為5ms的時候,小於檔案讀取時間,那麼就會先監聽到timers階段有事件進入,從而進入到timers階段執行,執行完畢繼續進行事件迴圈check階段timers: 6read time: 50082.將定時器事件設定為0ms,會在進入到poll階段的時候發現timers階段已經有callback,那麼會直接執行,然後執行完畢在下一階段迴圈,執行check階段,poll佇列的回撥函式timers: 2check階段read time: 7 */
參考 前端面試題詳細解答
走進案例解析
我們來看一個簡單的EventLoop
的例子:
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. nextTick
與 setImmediate
process.nextTick
不屬於事件迴圈的任何一個階段,它屬於該階段與下階段之間的過渡, 即本階段執行結束, 進入下一個階段前, 所要執行的回撥。有給人一種插隊的感覺.setImmediate
的回撥處於check階段, 當poll階段的佇列為空, 且check階段的事件佇列存在的時候,切換到check階段執行,
nextTick 遞迴的危害
由於nextTick具有插隊的機制,nextTick的遞迴會讓事件迴圈機制無法進入下一個階段. 導致I/O處理完成或者定時任務超時後仍然無法執行, 導致了其它事件處理程式處於飢餓狀態. 為了防止遞迴產生的問題, Node.js 提供了一個 process.maxTickDepth (預設 1000)。
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);
console.log('nextTick');
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() 將始終在任何定時器(setTimeout、setInterval)之前執行.
3. setTimeout
與 setImmediate
- setImmediate()被設計在 poll 階段結束後立即執行回撥;
- setTimeout()被設計在指定下限時間到達後執行回撥;
無 I/O 處理情況下:
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
執行結果:
C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate
C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate
C:\Users\92809\Desktop\node_test>node test.js
timeout
immediate
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
從結果,我們可以發現,這裡列印輸出出來的結果,並沒有什麼固定的先後順序,偏向於隨機,為什麼會發生這樣的情況呢?
答:首先進入的是timers
階段,如果我們的機器效能一般,那麼進入timers
階段,1ms
已經過去了 ==(setTimeout(fn, 0)等價於setTimeout(fn, 1))==,那麼setTimeout
的回撥會首先執行。
如果沒有到1ms
,那麼在timers
階段的時候,下限時間沒到,setTimeout
回撥不執行,事件迴圈來到了poll
階段,這個時候佇列為空,於是往下繼續,先執行了setImmediate()的回撥函式,之後在下一個事件迴圈再執行setTimemout
的回撥函式。
問題總結:而我們在==執行啟動程式碼==的時候,進入timers
的時間延遲其實是==隨機的==,並不是確定的,所以會出現兩個函式執行順序隨機的情況。
那我們再來看一段程式碼:
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
列印結果如下:
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
C:\Users\92809\Desktop\node_test>node test.js
immediate
timeout
# ... 省略 n 多次使用 node test.js 命令 ,結果都輸出 immediate timeout
這裡,為啥和上面的隨機timer
不一致呢,我們來分析下原因:
原因如下:fs.readFile
的回撥是在poll
階段執行的,當其回撥執行完畢之後,poll
佇列為空,而setTimeout
入了timers
的佇列,此時有程式碼 setImmediate()
,於是事件迴圈先進入check
階段執行回撥,之後在下一個事件迴圈再在timers
階段中執行回撥。
當然,下面的小案例同理:
setTimeout(() => {
setImmediate(() => {
console.log('setImmediate');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
}, 0);
以上的程式碼在timers
階段執行外部的setTimeout
回撥後,內層的setTimeout
和setImmediate
入隊,之後事件迴圈繼續往後面的階段走,走到poll階段
的時候發現佇列為空
,此時有程式碼有setImmedate()
,所以直接進入check階段
執行響應回撥(==注意這裡沒有去檢測timers佇列中是否有成員
到達下限事件,因為setImmediate()優先
==)。之後在第二個事件迴圈的timers
階段中再去執行相應的回撥。
綜上所演示,我們可以總結如下:
- 如果兩者都在主模組中呼叫,那麼執行先後取決於程式效能,也就是你的電腦好撇,當然也就是隨機。
- 如果兩者都不在主模組呼叫(被一個非同步操作包裹),那麼
setImmediate的回撥永遠先執行
。
4. nextTick
與 Promise
概念:對於這兩個,我們可以把它們理解成一個微任務。也就是說,它其實不屬於事件迴圈的一部分。
那麼他們是在什麼時候執行呢?
不管在什麼地方呼叫,他們都會在其所處的事件迴圈最後,事件迴圈進入下一個迴圈的階段前執行。
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);
控制檯列印如下:
C:\Users\92809\Desktop\node_test>node test.js
timeout0
sync
nextTick1
nextTick3
nextTick2
resolved
timeout2
timeout resolved
最總結:timers
階段執行外層setTimeout
的回撥,遇到同步程式碼先執行,也就有timeout0
、sync
的輸出。遇到process.nextTick
及Promise
後入微任務佇列,依次nextTick1
、nextTick3
、nextTick2
、resolved
入隊後出隊輸出。之後,在下一個事件迴圈的timers
階段,執行setTimeout
回撥輸出timeout2
以及微任務Promise
裡面的setTimeout
,輸出timeout resolved
。(這裡要說明的是 微任務nextTick
優先順序要比Promise
要高)
5. 最後案例
程式碼片段1:
setImmediate(function(){
console.log("setImmediate");
setImmediate(function(){
console.log("巢狀setImmediate");
});
process.nextTick(function(){
console.log("nextTick");
})
});
/* C:\Users\92809\Desktop\node_test>node test.js setImmediate nextTick 巢狀setImmediate*/
解析:
事件迴圈check
階段執行回撥函式輸出setImmediate
,之後輸出nextTick
。巢狀的setImmediate
在下一個事件迴圈的check
階段執行回撥輸出巢狀的setImmediate
。
程式碼片段2:
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')
列印結果為:
C:\Users\92809\Desktop\node_test>node test.js
script start
async1 start
async2
promise1
promise2
script end
nextTick
promise3
async1 end
setTimeout0
setTimeout3
setImmediate
大家呢,可以先看著程式碼,默默地在心底走一變程式碼,然後對比輸出的結果,當然最後三位,我個人認為是有點問題的,畢竟在主模組執行,大家的答案,最後三位可能會有偏差;