注意
在 Node 11 版本中,Node 的 Event Loop 已經與 瀏覽器趨於相同。
背景
Event Loop也是js老生常談的一個話題了。2月底看了阮一峰老師的《Node定時器詳解》一文後,發現無法完全對標之前看過的js事件迴圈執行機制,又查閱了一些其他資料,記為筆記,感覺不妥,總結成文。
瀏覽器中與node中事件迴圈與執行機制不同,不可混為一談。 瀏覽器的Event loop是在HTML5中定義的規範,而node中則由libuv庫實現。同時閱讀《深入淺出nodeJs》一書時發現比較當時node機制已有不同,所以本文node部分針對為此文釋出時版本。強烈推薦讀下參考連結中的前三篇。
瀏覽器環境
js執行為單執行緒(不考慮web worker),所有程式碼皆在主執行緒呼叫棧完成執行。當主執行緒任務清空後才會去輪詢取任務佇列中任務。
任務佇列
非同步任務分為task(巨集任務,也可稱為macroTask)和microtask(微任務)兩類。 當滿足執行條件時,task和microtask會被放入各自的佇列中等待放入主執行緒執行,我們把這兩個佇列稱為Task Queue(也叫Macrotask Queue)和Microtask Queue。
- task:script中程式碼、setTimeout、setInterval、I/O、UI render。
- microtask: promise、Object.observe、MutationObserver。
具體過程
- 執行完主執行執行緒中的任務。
- 取出Microtask Queue中任務執行直到清空。
- 取出Macrotask Queue中一個任務執行。
- 取出Microtask Queue中任務執行直到清空。
- 重複3和4。
即為同步完成,一個巨集任務,所有微任務,一個巨集任務,所有微任務......
注意
- 在瀏覽器頁面中可以認為初始執行執行緒中沒有程式碼,每一個script標籤中的程式碼是一個獨立的task,即會執行完前面的script中建立的microtask再執行後面的script中的同步程式碼。
- 如果microtask一直被新增,則會繼續執行microtask,“卡死”macrotask。
- 部分版本瀏覽器有執行順序與上述不符的情況,可能是不符合標準或js與html部分標準衝突。可閱讀參考文章中第一篇。
new Promise((resolve, reject) =>{console.log(‘同步’);resolve()}).then(() => {console.log('非同步')})
,即promise
的then
和catch
才是microtask,本身的內部程式碼不是。- 個別瀏覽器獨有API未列出。
虛擬碼
while (true) {
巨集任務佇列.shift()
微任務佇列全部任務()
}
複製程式碼
node環境
js執行為單執行緒,所有程式碼皆在主執行緒呼叫棧完成執行。當主執行緒任務清空後才會去輪詢取任務佇列中任務。
迴圈階段
在node中事件每一輪迴圈按照順序分為6個階段,來自libuv的實現:
- timers:執行滿足條件的setTimeout、setInterval回撥。
- I/O callbacks:是否有已完成的I/O操作的回撥函式,來自上一輪的poll殘留。
- idle,prepare:可忽略
- poll:等待還沒完成的I/O事件,會因timers和超時時間等結束等待。
- check:執行setImmediate的回撥。
- close callbacks:關閉所有的closing handles,一些onclose事件。
執行機制
幾個佇列
除上述迴圈階段中的任務型別,我們還剩下瀏覽器和node共有的microtask和node獨有的process.nextTick
,我們稱之為Microtask Queue和NextTick Queue。
我們把迴圈中的幾個階段的執行佇列也分別稱為Timers Queue、I/O Queue、Check Queue、Close Queue。
迴圈之前
在進入第一次迴圈之前,會先進行如下操作:
- 同步任務
- 發出非同步請求
- 規劃定時器生效的時間
- 執行
process.nextTick()
開始迴圈
按照我們的迴圈的6個階段依次執行,每次拿出當前階段中的全部任務執行,清空NextTick Queue,清空Microtask Queue。再執行下一階段,全部6個階段執行完畢後,進入下輪迴圈。即:
- 清空當前迴圈內的Timers Queue,清空NextTick Queue,清空Microtask Queue。
- 清空當前迴圈內的I/O Queue,清空NextTick Queue,清空Microtask Queue。
- 清空當前迴圈內的Check Queu,清空NextTick Queue,清空Microtask Queue。
- 清空當前迴圈內的Close Queu,清空NextTick Queue,清空Microtask Queue。
- 進入下輪迴圈。
可以看出,nextTick
優先順序比promise
等microtask高。setTimeout
和setInterval
優先順序比setImmediate
高。
注意
- 如果在timers階段執行時建立了
setImmediate
則會在此輪迴圈的check階段執行,如果在timers階段建立了setTimeout
,由於timers已取出完畢,則會進入下輪迴圈,check階段建立timers任務同理。 setTimeout
優先順序比setImmediate
高,但是由於setTimeout(fn,0)
的真正延遲不可能完全為0秒,可能出現先建立的setTimeout(fn,0)
而比setImmediate
的回撥後執行的情況。
虛擬碼
while (true) {
loop.forEach((階段) => {
階段全部任務()
nextTick全部任務()
microTask全部任務()
})
loop = loop.next
}
複製程式碼
測試程式碼
function sleep(time) {
let startTime = new Date()
while (new Date() - startTime < time) {}
console.log('1s over')
}
setTimeout(() => {
console.log('setTimeout - 1')
setTimeout(() => {
console.log('setTimeout - 1 - 1')
sleep(1000)
})
new Promise(resolve => resolve()).then(() => {
console.log('setTimeout - 1 - then')
new Promise(resolve => resolve()).then(() => {
console.log('setTimeout - 1 - then - then')
})
})
sleep(1000)
})
setTimeout(() => {
console.log('setTimeout - 2')
setTimeout(() => {
console.log('setTimeout - 2 - 1')
sleep(1000)
})
new Promise(resolve => resolve()).then(() => {
console.log('setTimeout - 2 - then')
new Promise(resolve => resolve()).then(() => {
console.log('setTimeout - 2 - then - then')
})
})
sleep(1000)
})
複製程式碼
- 瀏覽器輸出:
setTimeout - 1 //1為單個task 1s over setTimeout - 1 - then setTimeout - 1 - then - then setTimeout - 2 //2為單個task 1s over setTimeout - 2 - then setTimeout - 2 - then - then setTimeout - 1 - 1 1s over setTimeout - 2 - 1 1s over 複製程式碼
- node輸出:
setTimeout - 1 1s over setTimeout - 2 //1、2為單階段task 1s over setTimeout - 1 - then setTimeout - 2 - then setTimeout - 1 - then - then setTimeout - 2 - then - then setTimeout - 1 - 1 1s over setTimeout - 2 - 1 1s over 複製程式碼
由此也可看出事件迴圈在瀏覽器和node中的不同。
由於新版 node 執行情況與瀏覽器相同,所以瀏覽器環境為例,以 console 輸出值代指值所在函式,執行過程如下
<!--執行完主執行執行緒中的任務。-->
<!--取出Microtask Queue中任務執行直到清空。-->
<!--取出Macrotask Queue中一個任務執行。-->
<!--取出Microtask Queue中任務執行直到清空。-->
<!--重複3和4。-->
以 IQ 代指微任務佇列,AQ 代指巨集任務佇列
1. 執行完主執行緒中任務:主執行執行緒執行完畢,setTimeout-1、setTimeout-2 進入等待
2. 清空 IQ:此時 IQ 中無任務
2. 執行 AQ 中一個任務: setTimeout-1 到時間後進入 AQ 中,被執行,執行過程中 setTimeout-1-1 進入等待狀態,setTimeout-1-then 直接進入 IQ 佇列,由於 setTimeout-1 中有 1s 等待,此時 setTimeout-2 肯定已經進入 AQ,setTimeout-1-1 也隨後進入 AQ,此時結束狀態為 IQ: [setTimeout-1-then],AQ: [setTimeout-2, setTimeout-1-1]
3. 清空 IQ: 此時 IQ 中有 setTimeout-1-then,執行 setTimeout-1-then,執行過程中,setTimout-1-then-then 直接被加入 IQ,所以 IQ 沒清空,所以繼續執行 setTimout-1-then-then,IQ 被清空,此時結束狀態為 IQ: [], AQ: [setTimeout-2, setTimeout-1-1]
4. 執行 AQ 中一個任務:即執行 setTimeout-2
5. 清空 IQ: 這一步與 3 相似,所以輸出 setTimeout-2-then、setTimeout-2-then-then,IQ 清空,此時結束狀態為 IQ: [], AQ: [setTimeout-1-1, setTimeout-2-1]
6. 執行 AQ 中一個任務:即 setTimeout-1-1
7. 清空 IQ: 本身就為空
8. 執行 AQ 中一個任務:即 setTimeout-2-1
複製程式碼