成為自信的node.js開發(fā)者(二)
作者:霧豹
原文地址:https://juejin.im/post/5c6a785951882562986cf126
相關(guān)推薦閱讀:成為自信的node.js開發(fā)者(一)
這一章,我們來學(xué)習(xí)一下event_loop, 本文內(nèi)容旨在厘清瀏覽器(browsing context)和Node環(huán)境中不同的 Event Loop。
首先清楚一點:瀏覽器環(huán)境和 node環(huán)境的event-loop 完全不一樣。
瀏覽器環(huán)境
為了協(xié)調(diào)事件、用戶交互、腳本、UI渲染、網(wǎng)絡(luò)請求等行為,用戶引擎必須使用Event Loop。event loop包含兩類:基于browsing contexts,基于worker。
本文討論的瀏覽器中的EL基于browsing contexts

上面圖中,關(guān)鍵性的兩點:
同步任務(wù)直接進入主執(zhí)行棧(call stack)中執(zhí)行
等待主執(zhí)行棧中任務(wù)執(zhí)行完畢,由EL將異步任務(wù)推入主執(zhí)行棧中執(zhí)行
task——宏任務(wù)
task在網(wǎng)上也被成為macrotask (宏任務(wù))
宏任務(wù)分類:
script代碼
setTimeout/setInterval
setImmediate (未實現(xiàn))
I/O
UI交互
宏任務(wù)特征
一個event loop 中,有一個或多個 task隊列。
不同的task會放入不同的task隊列中:比如,瀏覽器會為鼠標(biāo)鍵盤事件分配一個task隊列,為其他的事件分配另外的隊列。
先進隊列的先被執(zhí)行
microtask——微任務(wù)
微任務(wù)
微任務(wù)的分類
通常下面幾種任務(wù)被認為是microtask
promise(promise的then和catch才是microtask,本身其內(nèi)部的代碼并不是)
MutationObserver
process.nextTick(nodejs環(huán)境中)
微任務(wù)特性
一個EL中只有一個microtask隊列。
event-loop的循環(huán)過程
一個EL只要存在,就會不斷執(zhí)行下邊的步驟:
先執(zhí)行同步代碼,所有微任務(wù),一個宏任務(wù),所有微任務(wù)(,更新渲染),一個宏任務(wù),所有微任務(wù)(,更新渲染)……
執(zhí)行完microtask隊列里的任務(wù),有可能會渲染更新。在一幀以內(nèi)的多次dom變動瀏覽器不會立即響應(yīng),而是會積攢變動以最高60HZ的頻率更新視圖
例子
setTimeout(() => console.log('setTimeout1'), 0);
setTimeout(() => {
console.log('setTimeout2');
Promise.resolve().then(() => {
console.log('promise3');
Promise.resolve().then(() => {
console.log('promise4');
})
console.log(5)
})
setTimeout(() => console.log('setTimeout4'), 0);
}, 0);
setTimeout(() => console.log('setTimeout3'), 0);
Promise.resolve().then(() => {
console.log('promise1');
})
打印出來的結(jié)果是 :
promise1
setTimeout1
setTimeout2
'promise3'
5
promise4
setTimeout3
setTimeout4
另外一個例子:
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function () {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function () {
console.log('promise1')
setTimeout(() => {
console.log('sssss')
}, 0)
})
.then(function () {
console.log('promise2')
})
console.log('script end')
在瀏覽器內(nèi)輸出結(jié)果如下, node內(nèi)輸出結(jié)果不同
'script start'
'async2 end'
'Promise'
'script end'
'async1 end'
'promise1'
'promise2'
'setTimeout'
'sssss'
await 只是
fn().then()這些寫法的語法糖,相當(dāng)于await那一行代碼下面的代碼都被當(dāng)成一個微任務(wù),推入到了microtask queue中
順序:執(zhí)行完同步任務(wù),執(zhí)行微任務(wù)隊列中的全部的微任務(wù),執(zhí)行一個宏任務(wù),執(zhí)行全部的微任務(wù)
node 環(huán)境中
Node中的event-loop由 libuv庫 實現(xiàn),js是單線程的,會把回調(diào)和任務(wù)交給libuv
event loop 首先會在內(nèi)部維持多個事件隊列,比如 時間隊列、網(wǎng)絡(luò)隊列等等,而libuv會執(zhí)行一個相當(dāng)于 while true的無限循環(huán),不斷的檢查各個事件隊列上面是否有需要處理的pending狀態(tài)事件,如果有則按順序去觸發(fā)隊列里面保存的事件,同時由于libuv的事件循環(huán)每次只會執(zhí)行一個回調(diào),從而避免了 競爭的發(fā)生
個人理解,它與瀏覽器中的輪詢機制(一個task,所有microtasks;一個task,所有microtasks…)最大的不同是,node輪詢有phase(階段)的概念,不同的任務(wù)在不同階段執(zhí)行,進入下一階段之前執(zhí)行所有的process.nextTick() 和 所有的microtasks。
階段

timers階段
在這個階段檢查是否有超時的timer(setTimeout/setInterval),有的話就執(zhí)行他們的回調(diào)
但timer設(shè)定的閾值不是執(zhí)行回調(diào)的確切時間(只是最短的間隔時間),node內(nèi)核調(diào)度機制和其他的回調(diào)函數(shù)會推遲它的執(zhí)行
由poll階段來控制什么時候執(zhí)行timers callbacks
I/O callback 階段
處理異步事件的回調(diào),比如網(wǎng)絡(luò)I/O,比如文件讀取I/O,當(dāng)這些事件報錯的時候,會在 `I/O` callback階段執(zhí)行
poll 階段
這里是最重要的階段,poll階段主要的兩個功能:
處理poll queue的callbacks
回到timers phase執(zhí)行timers callbacks(當(dāng)?shù)竭_timers指定的時間時)
進入poll階段,timer的設(shè)定有下面兩種情況:
1. event loop進入了poll階段, **未設(shè)定timer**
poll queue不為空:event loop將同步的執(zhí)行queue里的callback,直到清空或執(zhí)行的callback到達系統(tǒng)上限
poll queue為空
如果有設(shè)定` callback`, event loop將結(jié)束poll階段進入check階段,并執(zhí)行check queue (check queue是 setImmediate設(shè)定的)
如果代碼沒有設(shè)定setImmediate() callback,event loop將阻塞在該階段等待callbacks加入poll queue
2. event loop進入了 poll階段, **設(shè)定了timer**
如果poll進入空閑狀態(tài),event loop將檢查timers,如果有1個或多個timers時間時間已經(jīng)到達,event loop將回到 timers 階段執(zhí)行timers queue
這里的邏輯比較復(fù)雜,流程可以借助下面的圖進行理解:

check 階段
一旦poll隊列閑置下來或者是代碼被`setImmediate`調(diào)度,EL會馬上進入check phase
close callbacks
關(guān)閉I/O的動作,比如文件描述符的關(guān)閉,連接斷開等
如果socket突然中斷,close事件會在這個階段被觸發(fā)

同步的任務(wù)執(zhí)行完,先執(zhí)行完全部的process.nextTick() 和 全部的微任務(wù)隊列,然后執(zhí)行每一個階段,每個階段執(zhí)行完畢后,
注意點
setTimeout 和 setImmediate
調(diào)用階段不一樣
不同的io中,執(zhí)行順序不保證
二者非常相似,區(qū)別主要在于調(diào)用時機不同。
setImmediate 設(shè)計在poll階段完成時執(zhí)行,即check段;
setTimeout 設(shè)計在poll階段為空閑時,且設(shè)定時間到達后執(zhí)行,但它在timer階段執(zhí)行
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
對于以上代碼來說,setTimeout 可能執(zhí)行在前,也可能執(zhí)行在后。
首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的。
如果在準備時候花費了大于 1ms 的時間,那么在 timer 階段就會直接執(zhí)行 setTimeout 回調(diào)。
如果準備時間花費小于 1ms,那么就是 setImmediate 回調(diào)先執(zhí)行了。
也就是說,進入事件循環(huán)也是需要成本的。有可能進入event loop 時,setTimeout(fn, 1) 還在等待timer中,并沒有被推入到 time 事件隊列,而setImmediate 方法已經(jīng)被推入到了 check事件隊列 中了。那么event_loop 按照time、i/o、poll、check、close 順序執(zhí)行,先執(zhí)行immediate 任務(wù)。

也有可能,進入event loop 時,setTimeout(fn, 1) 已經(jīng)結(jié)束了等待,被推到了time 階段的隊列中,如下圖所示,則先執(zhí)行了timeout 方法。

所以,setTimeout setImmediate 哪個先執(zhí)行,這主要取決于,進入event loop 花了多長時間。
但當(dāng)二者在異步i/o callback內(nèi)部調(diào)用時,總是先執(zhí)行setImmediate,再執(zhí)行setTimeout
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
在上述代碼中,setImmediate 永遠先執(zhí)行。因為兩個代碼寫在 IO 回調(diào)中,IO 回調(diào)是在 poll 階段執(zhí)行,當(dāng)回調(diào)執(zhí)行完畢后隊列為空,發(fā)現(xiàn)存在 setImmediate 回調(diào),所以就直接跳轉(zhuǎn)到 check 階段去執(zhí)行回調(diào)了。
process.nextTick() 和 setImmediate()
官方推薦使用
setImmediate(),因為更容易推理,也兼容更多的環(huán)境,例如瀏覽器環(huán)境
process.nextTick() 在當(dāng)前循環(huán)階段結(jié)束之前觸發(fā)
setImmediate() 在下一個事件循環(huán)中的check階段觸發(fā)
通過process.nextTick()觸發(fā)的回調(diào)也會在進入下一階段前被執(zhí)行結(jié)束,這會允許用戶遞歸調(diào)用 process.nextTick() 造成I/O被榨干,使EL不能進入poll階段
因此node作者推薦我們盡量使用setImmediate,因為它只在check階段執(zhí)行,不至于導(dǎo)致其他異步回調(diào)無法被執(zhí)行到
例子
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
注意:主棧執(zhí)行完了之后,會先清空 process.nextick() 隊列和microtask隊列中的任務(wù),然后按照每一個階段來執(zhí)行先處理異步事件的回調(diào),比如網(wǎng)絡(luò)I/O,比如文件讀取I/O。當(dāng)這些I/O動作都結(jié)束的時候,在這個階段會觸發(fā)它們的
另外一個例子
const {readFile} = require('fs')
setTimeout(() => {
console.log('1')
}, 0)
setTimeout(() => {
console.log('2')
}, 100)
setTimeout(() => {
console.log('3')
}, 200)
readFile('./test.js', () => {
console.log('4')
})
readFile(__filename, () => {
console.log('5')
})
setImmediate(() => {
console.log('立即回調(diào)')
})
process.nextTick(() => {
console.log('process.nexttick的回調(diào)')
})
Promise.resolve().then(() => {
process.nextTick(() => {
console.log('nexttick 第二次回調(diào)')
})
console.log('6')
}).then(() => {
console.log('7')
})
上面代碼的結(jié)果是:
process.nexttick的回調(diào)
6
7
nexttick 第二次回調(diào)
1
立即回調(diào)
4
5
2
3
上面代碼需要注意點:
下面兩個回調(diào)任務(wù),要等
100ms和200ms才能被推入到timers階段的任務(wù)隊列
兩個讀取文件的回調(diào),需要等待讀取完成后,才能被推入到
poll階段的任務(wù)隊列。(不是被推入到io階段的任務(wù)隊列,只有讀取失敗等異常的回調(diào),才會被推入到io階段的任務(wù)隊列)在微任務(wù)里面,新添加的
process.nextTick()也會在新階段的開始之前被執(zhí)行。簡單理解為,在每一個階段的任務(wù)隊列開始之前,都需要全部清空process.nextTick和microtask任務(wù)隊列
一個誤區(qū)
自己在驗證上面的想法的時候,實驗過很多代碼,從未失手過,但是當(dāng)實驗到下面的代碼時:
Promise.resolve().then(() => {
console.log(1)
Promise.resolve().then(() => {
console.log(2)
})
}).then(() => {
console.log(3)
})
按照上面我們講的,這里應(yīng)該是輸出132, 但是反復(fù)驗證,在 node 實際輸出的是 123,連續(xù)好幾天都不得其解,后來看到一個問答,才恍然大悟:https://stackoverflow.com/questions/36870467/what-is-the-order-of-execution-in-javascript-promises
首先,上面的代碼,在.then() 的回調(diào)函數(shù)中去執(zhí)行promise.resolve(), 實際上是, 在目前的promise 鏈中新建了一個獨立的 promise鏈 。你沒有任何辦法保證這兩個哪個先執(zhí)行完,這實際上是node引擎 的一個bug,就像一口氣發(fā)出兩個請求,并不知道哪個請求先返回。
每次我們都能得到相同的結(jié)果是因為,我們Promise.resolve()里面恰好沒有異步的操作,這并不是event-loop 專門設(shè)計成這樣的。
所以,不必花太多的時間,在上面的代碼中,實際寫代碼中,也不會出現(xiàn)這種情況。
