最近在看《Node.js調試指南》的時候遇到有意思的幾道題,是關於setTimeout, promise.then, process.nextTick, setImmediate的執行順序。今天抽空記錄下這道題的分析過程及背后的原理與知識點。
題目如下:
// 題目一:
setTimeout(()=>{
console.log('setTimeout')
},0)
setImmediate(()=>{
console.log('setImmediate')
})
// 題目二:
const promise = Promise.resolve()
promise.then(()=>{
console.log('promise')
})
process.nextTick(()=>{
console.log('nextTick')
})
// 題目三:
setTimeout (() => {
console.log(1)
},0)
new Promise((resolve,reject) => {
console.log(2)
for(let i = 0; i <10000; i++) {
i === 9999 && resolve()
}
console.log(3)
}).then(() => {
console.log(4)
})
console.log(5)
// 題目四
setInterval(()=>{
console.log('setInterval')
},100)
process.nextTick(function tick(){
process.nextTick(tick)
})
在分析這幾道題之前先有必要了解下node.js的事件循環
事件循環 Event Loop
我們可以簡單理解Event Loop如下:
- 所有任務都在主線程上執行,形成一個執行棧(Execution Context Stack)
- 在主線程之外還存在一個任務隊列(Task Queen),系統把異步任務放到任務隊列中,然后主線程繼續執行后續的任務
- 一旦執行棧中所有的任務執行完畢,系統就會讀取任務隊列。如果這時異步任務已結束等待狀態,就會從任務隊列進入執行棧,恢復執行
- 主線程不斷重復上面的第三步
上面第三步中的讀取任務隊列包括以下6個階段
- timers:執行setTimeout()和setInterval()中到期的callback
- I/O callbacks:上一輪循環中有少數的I/O callback會被延遲到這一輪的這一階段
- idle,prepare:僅內部調用
- poll:最重要的階段,執行I/O callback,在某些條件下node會阻塞在這個階段
- check:執行setImmediate()的callback
- close callbacks:執行close事件的callback,例如socket.on('close',func)
每個階段都有一個FIFO的回調隊列,當Event Loop執行到這個階段時,就會從當前階段的隊列里拿出一個任務放到執行棧中執行,在隊列任務清空或者執行的回調數量達到上限后,Event Loop就會進入下一個階段
poll階段
poll階段主要有兩個功能,如下所述:
- 當timers的定時器到期后,執行定時器(setTimeout和setInterval)的callback
- 執行poll隊列里面的I/O callback
如果Event Loop進入了poll階段,且代碼未設定timer,則可能發生以下的情況:
- 如果poll queue不為空,則Event Loop將同步執行queue里的callback,直至queue為空,或者執行的callback達到系統上限
- 如果poll queue為空,則可能發生以下情況:
- 如果代碼中使用了setImmediate(),則Event Loop將結束poll階段並進入check階段,執行check階段的代碼
- 如果代碼中沒有使用setImmediate(),則Event Loop將阻塞在該階段,等待callback加入poll queue,如果有callback進來則立即執行
一旦poll queue為空,則Event Loop將檢查timers,如果有timer的時間到期,則Event Loop將回到timers階段,然后執行timer queue
事件循環原理
- node 的初始化
- 初始化 node 環境。
- 執行輸入代碼。
- 執行 process.nextTick 回調。
- 執行 microtasks。
- 進入 event-loop
-
進入 timers 階段
- 檢查 timer 隊列是否有到期的 timer 回調,如果有,將到期的 timer 回調按照 timerId 升序執行。
- 檢查是否有 process.nextTick 任務,如果有,全部執行。
- 檢查是否有microtask,如果有,全部執行。
- 退出該階段。
-
進入IO callbacks階段。
- 檢查是否有 pending 的 I/O 回調。如果有,執行回調。如果沒有,退出該階段。
- 檢查是否有 process.nextTick 任務,如果有,全部執行。
- 檢查是否有microtask,如果有,全部執行。
- 退出該階段。
-
進入 idle,prepare 階段:
- 這兩個階段與我們編程關系不大,暫且按下不表。
-
進入 poll 階段
- 首先檢查是否存在尚未完成的回調,如果存在,那么分兩種情況。
- 第一種情況:
- 如果有可用回調(可用回調包含到期的定時器還有一些IO事件等),執行所有可用回調。
- 檢查是否有 process.nextTick 回調,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出該階段。
- 第二種情況:
- 如果沒有可用回調。
- 檢查是否有 immediate 回調,如果有,退出 poll 階段。如果沒有,阻塞在此階段,等待新的事件通知。
- 第一種情況:
- 如果不存在尚未完成的回調,退出poll階段。
- 首先檢查是否存在尚未完成的回調,如果存在,那么分兩種情況。
-
進入 check 階段。
- 如果有immediate回調,則執行所有immediate回調。
- 檢查是否有 process.nextTick 回調,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出 check 階段
-
進入 closing 階段。
- 如果有immediate回調,則執行所有immediate回調。
- 檢查是否有 process.nextTick 回調,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出 closing 階段
-
檢查是否有活躍的 handles(定時器、IO等事件句柄)。
- 如果有,繼續下一輪循環。
- 如果沒有,結束事件循環,退出程序。
-
通過上面的事件循環的介紹我們已經知道setTimeout setImmediate的執行機制,但是並沒有介紹process.nextTick()和promise.then()。這里我們還需要知道宏任務與微任務的概念
宏任務 Macrotask
宏任務是指Event Loop在每個階段執行的任務
宏任務包括 script (整體代碼),setTimeout, setInterval, setImmediate, I/O, UI renderin
微任務 Microtask
微任務是指Event Loop在每個階段之間執行的任務
微任務包括 process.nextTick, Promise.then,Object.observe,MutationObserver
宏任務與微任務執行順序圖
圖中綠色小塊表示Event Loop的各個階段,執行的是宏任務,粉色箭頭表示執行的是微任務
了解到這里我們再來分析上面的幾道題
題目一的執行結果是:
setTimeout
setImmediate
//或者
setImmediate
setTimeout
為什么結果不確定呢?我們知道setTimeout的回調函數在timer階段執行,setImmediate的回調函數在check階段執行。但是從事件循環開始到timer階段會消耗一定的時間,所以會出現兩種情況:
- 若timer前的准備時間超過1ms,則執行timer階段(setTimeout)的回調函數
- 若timer前的准備時間少於1ms,則執行check階段(setImmediate)的回調函數,下次event loop循環在執行timer階段的函數
題目二的執行結果是
nextTick
promise
這里雖然和process.nextTick一樣,promise.then也將回調函數注冊到microtask,但process.nextTick的microtask queue總是優先於promise的microtask queue執行的
題目三的執行結果是
2
3
5
4
1
Promise構造函數是同步執行的,所以先打印2,3,在打印5,接下來事件循環執行微任務執行promise.then的回調,打印4,然后進入下一個事件循環執行timer階段的回調打印1
題目四的執行結果是
永遠不會打印setInterval
process.nextTick會無限循環,將event loop阻塞在microtask階段,導致event loop上其他macrotask階段的回調函數沒有機會執行
解決方法通常是用setImmediate代替process.nextTick.
在setImmediate內執行setImmedaite時會將immediate注冊到下一次event loop的check階段,這樣其他macrotask就有機會執行
至此終於將node.js事件循環宏任務與微任務分析清楚了