瀏覽器和Node不同的事件迴圈(Event Loop)

toBeTheLight發表於2018-03-12

注意

在 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。

具體過程

  1. 執行完主執行執行緒中的任務。
  2. 取出Microtask Queue中任務執行直到清空。
  3. 取出Macrotask Queue中一個任務執行。
  4. 取出Microtask Queue中任務執行直到清空。
  5. 重複3和4。

即為同步完成,一個巨集任務,所有微任務,一個巨集任務,所有微任務......

注意

  • 在瀏覽器頁面中可以認為初始執行執行緒中沒有程式碼,每一個script標籤中的程式碼是一個獨立的task,即會執行完前面的script中建立的microtask再執行後面的script中的同步程式碼。
  • 如果microtask一直被新增,則會繼續執行microtask,“卡死”macrotask。
  • 部分版本瀏覽器有執行順序與上述不符的情況,可能是不符合標準或js與html部分標準衝突。可閱讀參考文章中第一篇。
  • new Promise((resolve, reject) =>{console.log(‘同步’);resolve()}).then(() => {console.log('非同步')}),即promisethencatch才是microtask,本身的內部程式碼不是。
  • 個別瀏覽器獨有API未列出。

虛擬碼

while (true) {
  巨集任務佇列.shift()
  微任務佇列全部任務()
}
複製程式碼

node環境

js執行為單執行緒,所有程式碼皆在主執行緒呼叫棧完成執行。當主執行緒任務清空後才會去輪詢取任務佇列中任務。

迴圈階段

在node中事件每一輪迴圈按照順序分為6個階段,來自libuv的實現:

  1. timers:執行滿足條件的setTimeout、setInterval回撥。
  2. I/O callbacks:是否有已完成的I/O操作的回撥函式,來自上一輪的poll殘留。
  3. idle,prepare:可忽略
  4. poll:等待還沒完成的I/O事件,會因timers和超時時間等結束等待。
  5. check:執行setImmediate的回撥。
  6. 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高。setTimeoutsetInterval優先順序比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
複製程式碼

參考文章

相關文章