這一章,我們來學習一下event_loop, 本文內容旨在釐清瀏覽器(browsing context)和Node環境中不同的 Event Loop。
首先清楚一點:瀏覽器環境和 node環境的event-loop
完全不一樣。
瀏覽器環境
為了協調事件、使用者互動、指令碼、UI渲染、網路請求等行為,使用者引擎必須使用Event Loop
。event loop
包含兩類:基於browsing contexts,基於worker。
本文討論的瀏覽器中的EL基於browsing contexts
上面圖中,關鍵性的兩點:
同步任務直接進入主執行棧(call stack)中執行
等待主執行棧中任務執行完畢,由EL將非同步任務推入主執行棧中執行
task——巨集任務
task在網上也被成為macrotask
(巨集任務)
巨集任務分類:
script程式碼
setTimeout/setInterval
setImmediate (未實現)
I/O
UI互動
巨集任務特徵
一個event loop
中,有一個或多個 task佇列。
不同的task會放入不同的task佇列中:比如,瀏覽器會為滑鼠鍵盤事件分配一個task佇列,為其他的事件分配另外的佇列。
先進佇列的先被執行
microtask——微任務
微任務
微任務的分類
通常下面幾種任務被認為是microtask
promise(promise
的then
和catch
才是microtask,本身其內部的程式碼並不是)
MutationObserver
process.nextTick(nodejs環境中)
微任務特性
一個EL中只有一個microtask佇列。
event-loop的迴圈過程
一個EL只要存在,就會不斷執行下邊的步驟:
先執行同步程式碼,所有微任務,一個巨集任務,所有微任務(,更新渲染),一個巨集任務,所有微任務(,更新渲染)...... 執行完microtask佇列裡的任務,有可能會渲染更新。在一幀以內的多次dom變動瀏覽器不會立即響應,而是會積攢變動以最高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');
})
複製程式碼
列印出來的結果是 :
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')
複製程式碼
在瀏覽器內輸出結果如下, node內輸出結果不同
'script start'
'async2 end'
'Promise'
'script end'
'async1 end'
'promise1'
'promise2'
'setTimeout'
'sssss'
複製程式碼
-
await 只是
fn().then()
這些寫法的語法糖,相當於await
那一行程式碼下面的程式碼都被當成一個微任務,推入到了microtask queue
中 -
順序:執行完同步任務,執行微任務佇列中的全部的微任務,執行一個巨集任務,執行全部的微任務
node 環境中
Node中的event-loop
由 libuv庫 實現,js是單執行緒的,會把回撥和任務交給libuv
event loop
首先會在內部維持多個事件佇列,比如 時間佇列、網路佇列等等,而libuv會執行一個相當於 while true的無限迴圈,不斷的檢查各個事件佇列上面是否有需要處理的pending狀態事件,如果有則按順序去觸發佇列裡面儲存的事件,同時由於libuv的事件迴圈每次只會執行一個回撥,從而避免了 競爭的發生
個人理解,它與瀏覽器中的輪詢機制(一個task,所有microtasks;一個task,所有microtasks…)最大的不同是,node輪詢有phase(階段)的概念,不同的任務在不同階段執行,進入下一階段之前執行所有的process.nextTick() 和 所有的microtasks。
階段
timers階段
在這個階段檢查是否有超時的timer(setTimeout/setInterval),有的話就執行他們的回撥
但timer設定的閾值不是執行回撥的確切時間(只是最短的間隔時間),node核心排程機制和其他的回撥函式會推遲它的執行
由poll階段來控制什麼時候執行timers callbacks
複製程式碼
I/O callback 階段
處理非同步事件的回撥,比如網路I/O,比如檔案讀取I/O,當這些事件報錯的時候,會在 `I/O` callback階段執行
複製程式碼
poll 階段
這裡是最重要的階段,poll階段主要的兩個功能:
處理poll queue的callbacks
回到timers phase執行timers callbacks(當到達timers指定的時間時)
進入poll階段,timer的設定有下面兩種情況:
1. event loop進入了poll階段, **未設定timer**
poll queue不為空:event loop將同步的執行queue裡的callback,直到清空或執行的callback到達系統上限
poll queue為空
如果有設定` callback`, event loop將結束poll階段進入check階段,並執行check queue (check queue是 setImmediate設定的)
如果程式碼沒有設定setImmediate() callback,event loop將阻塞在該階段等待callbacks加入poll queue
2. event loop進入了 poll階段, **設定了timer**
如果poll進入空閒狀態,event loop將檢查timers,如果有1個或多個timers時間時間已經到達,event loop將回到 timers 階段執行timers queue
這裡的邏輯比較複雜,流程可以藉助下面的圖進行理解:
![](https://ws1.sinaimg.cn/large/006tKfTcgy1g0anodoa11j311i0h0t8w.jpg)
複製程式碼
check 階段
一旦poll佇列閒置下來或者是程式碼被`setImmediate`排程,EL會馬上進入check phase
複製程式碼
close callbacks
關閉I/O的動作,比如檔案描述符的關閉,連線斷開等
如果socket突然中斷,close事件會在這個階段被觸發
複製程式碼
同步的任務執行完,先執行完全部的process.nextTick()
和 全部的微任務佇列,然後執行每一個階段,每個階段執行完畢後,
注意點
setTimeout 和 setImmediate
-
呼叫階段不一樣
-
不同的io中,執行順序不保證
二者非常相似,區別主要在於呼叫時機不同。
setImmediate
設計在poll階段完成時執行,即check段;
setTimeout
設計在poll階段為空閒時,且設定時間到達後執行,但它在timer階段執行
setTimeout(function timeout () {
console.log('timeout');
},0);
setImmediate(function immediate () {
console.log('immediate');
});
複製程式碼
對於以上程式碼來說,setTimeout 可能執行在前,也可能執行在後。
首先 setTimeout(fn, 0) === setTimeout(fn, 1)
,這是由原始碼決定的。
如果在準備時候花費了大於 1ms 的時間,那麼在 timer 階段就會直接執行 setTimeout 回撥。 如果準備時間花費小於 1ms,那麼就是 setImmediate 回撥先執行了。
也就是說,進入事件迴圈也是需要成本的。有可能進入event loop 時,setTimeout(fn, 1)
還在等待timer中,並沒有被推入到 time 事件佇列
,而setImmediate
方法已經被推入到了 check事件佇列
中了。那麼event_loop 按照time
、i/o
、poll
、check
、close
順序執行,先執行immediate
任務。
也有可能,進入event loop 時,setTimeout(fn, 1)
已經結束了等待,被推到了time
階段的佇列中,如下圖所示,則先執行了timeout
方法。
所以,setTimeout
setImmediate
哪個先執行,這主要取決於,進入event loop 花了多長時間。
但當二者在非同步i/o callback內部呼叫時,總是先執行setImmediate,再執行setTimeout
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
複製程式碼
在上述程式碼中,setImmediate 永遠先執行。因為兩個程式碼寫在 IO 回撥中,IO 回撥是在 poll 階段執行,當回撥執行完畢後佇列為空,發現存在 setImmediate 回撥,所以就直接跳轉到 check 階段去執行回撥了。
process.nextTick() 和 setImmediate()
官方推薦使用
setImmediate()
,因為更容易推理,也相容更多的環境,例如瀏覽器環境
process.nextTick()
在當前迴圈階段結束之前觸發
setImmediate()
在下一個事件迴圈中的check階段觸發
通過process.nextTick()
觸發的回撥也會在進入下一階段前被執行結束,這會允許使用者遞迴呼叫 process.nextTick()
造成I/O被榨乾,使EL不能進入poll階段
因此node作者推薦我們儘量使用setImmediate,因為它只在check階段執行,不至於導致其他非同步回撥無法被執行到
例子
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')
複製程式碼
注意:主棧執行完了之後,會先清空 process.nextick() 佇列和microtask佇列中的任務,然後按照每一個階段來執行先處理非同步事件的回撥,比如網路I/O,比如檔案讀取I/O。當這些I/O動作都結束的時候,在這個階段會觸發它們的
另外一個例子
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('立即回撥')
})
process.nextTick(() => {
console.log('process.nexttick的回撥')
})
Promise.resolve().then(() => {
process.nextTick(() => {
console.log('nexttick 第二次回撥')
})
console.log('6')
}).then(() => {
console.log('7')
})
複製程式碼
上面程式碼的結果是:
process.nexttick的回撥
6
7
nexttick 第二次回撥
1
立即回撥
4
5
2
3
複製程式碼
上面程式碼需要注意點:
-
下面兩個回撥任務,要等
100ms
和200ms
才能被推入到timers
階段的任務佇列 -
兩個讀取檔案的回撥,需要等待讀取完成後,才能被推入到
poll
階段的任務佇列。(不是被推入到io
階段的任務佇列,只有讀取失敗等異常的回撥,才會被推入到io
階段的任務佇列) -
在微任務裡面,新新增的
process.nextTick()
也會在新階段的開始之前被執行。簡單理解為,在每一個階段的任務佇列開始之前,都需要全部清空process.nextTick
和microtask
任務佇列
一個誤區
自己在驗證上面的想法的時候,實驗過很多程式碼,從未失手過,但是當實驗到下面的程式碼時:
Promise.resolve().then(() => {
console.log(1)
Promise.resolve().then(() => {
console.log(2)
})
}).then(() => {
console.log(3)
})
複製程式碼
按照上面我們講的,這裡應該是輸出132
, 但是反覆驗證,在 node
實際輸出的是 123
,連續好幾天都不得其解,後來看到一個問答,才恍然大悟: stackoverflow.com/questions/3…
首先,上面的程式碼,在.then()
的回撥函式中去執行promise.resolve()
, 實際上是, 在目前的promise 鏈
中新建了一個獨立的 promise鏈
。 你沒有任何辦法保證這兩個哪個先執行完,這實際上是node引擎 的一個bug,就像一口氣發出兩個請求,並不知道哪個請求先返回。
每次我們都能得到相同的結果是因為,我們Promise.resolve()
裡面恰好沒有非同步的操作,這並不是event-loop
專門設計成這樣的。
所以,不必花太多的時間,在上面的程式碼中,實際寫程式碼中,也不會出現這種情況。