瀏覽器中的事件迴圈機制

莫凡_Tcg發表於2018-02-07

原文在我的部落格, 未經允許請不要轉載

網上一搜事件迴圈, 很多文章標題的前面會加上 JavaScript, 但是我覺得事件迴圈機制跟 JavaScript 沒什麼關係, JavaScript 只是一門解釋型語言, 方便開發和理解的, 由V8 JIT將 JavaScript 編譯成機器語言來呼叫底層, 至於瀏覽器怎麼執行 JavaScript 程式碼, JavaScript 管不著也不關心. 因此, “JavaScript事件迴圈機制”這種說法是不合理的. 事件迴圈機制是由執行時環境實現的, 具體來說有瀏覽器、Node等. 這篇文章就先來說說瀏覽器中實現的事件迴圈機制.

正文

首先,javascript 在瀏覽器端執行是單執行緒的,這是由瀏覽器決定的,這是為了避免多執行緒執行不同任務會發生衝突的情況。也就是說我們寫的javascript 程式碼只在一個執行緒上執行,稱之為主執行緒(HTML5提供了web worker API可以讓瀏覽器開一個執行緒執行比較複雜耗時的 javascript任務,但是這個執行緒仍受主執行緒的控制)。單執行緒的話,如果我們做一些“sleep”的操作比如說:

var now = + new Date()
while (+new Date() <= now + 1000){
//這是一個耗時的操所
}
複製程式碼

那麼在這將近一秒內,執行緒就會被阻塞,無法繼續執行下面的任務。

還有些操作比如說獲取遠端資料、I/O操作等,他們都很耗時,如果採用同步的方式,那麼程式在執行這些操作時就會因為耗時而等待,就像上面那樣,下面的任務也只能等待,這樣效率並不高。

那瀏覽器是怎麼做的呢?

我們找到WHATWG規範對Event loop的介紹:

WHATWG Event loop定義

為了協調事件,使用者互動,指令碼,渲染,網路等,使用者代理必須使用事件迴圈。

事件迴圈的主要機制就是任務佇列機制:

  • 一個事件迴圈有一個或者多個任務佇列(task queues)。任務佇列是task的有序列表,task是排程Events,Parsing,Callbacks,Using a resource,Reacting to DOM manipulation這些任務的演算法;
  • 每個任務都來自一個特定的任務源(task source)(比如滑鼠鍵盤事件)。來自同一個特定任務源且屬於特定事件迴圈的任務必須被加入到同一個任務佇列中,來自不同任務源的任務可以放在不同的任務佇列中;
  • 瀏覽器呼叫這些佇列中的任務時採取這樣的做法: 相同佇列中的任務按照先進先出的順序, 不同的佇列按照提前設定的佇列優先順序來呼叫. 例如,使用者代理可以有一個用於滑鼠和鍵盤事件的任務佇列(使用者互動任務源),另一個用於其他任務。然後,使用者代理75%概率呼叫鍵盤和滑鼠事件任務佇列,25%呼叫其他佇列, 這樣的話就保持介面響應而且不會餓死其他任務佇列. 但是相同佇列中的任務要按照先進先出的順序。也就是說單獨的任務佇列中的任務總是按先進先出的順序執行,但是不保證多個任務佇列中的任務優先順序,具體實現可能會交叉執行

在呼叫任務的過程中, 會產生新的任務, 瀏覽器就會不斷執行任務, 因此稱為事件迴圈.

microtask queue 微任務佇列

還有一些特殊任務, 它們不會被放在task queues中, 會放在一個叫做microtask(微任務) queue中, 繼續看標準:

Each event loop has a microtask queue. A microtask is a task that is originally to be queued on the microtask queue rather than a task queue.

任務佇列可以有多個, 但是微任務佇列只有一個.

那麼哪些任務是放在task queue, 哪些放在microtask queue呢? 通常對瀏覽器和Node.js來說:

  • macrotask(巨集任務): script(整體程式碼), setTimeout, setInterval, setImmediate, I/O, UI rendering等
  • microtask(微任務): process.nextTick, Promises(這裡指瀏覽器實現的原生 Promise), Object.observe, MutationObserver

請尤其注意macrotask中執行整體程式碼也是一個巨集任務

事件迴圈處理過程

總體來說, 瀏覽器端事件迴圈的一個回合(go-around或者叫cycle)就是:

  • 從macrotask佇列中(task queue)取一個巨集任務執行, 執行完後, 取出所有的microtask執行.
  • 重複回合

無論在執行macrotask還是microtask, 都有可能產生新的macrotask或者microtask, 就這樣繼續執行.

用任務佇列機制解釋非同步操作順序

這裡有一些常見非同步操作:

const interval = setInterval(() => {
  console.log('setInterval')
}, 0)

setTimeout(() => {  
  console.log('setTimeout 1')
  Promise.resolve().then(() => {
    console.log('promise 3')
  }).then(() => {
    console.log('promise 4')
  }).then(() => {
    setTimeout(() => {
      console.log('setTimeout 2')
      Promise.resolve().then(() => {
        console.log('promise 5')
      }).then(() => {
        console.log('promise 6')
      }).then(() => {
      	clearInterval(interval)
      })
    }, 0)
  })
}, 0)

Promise.resolve().then(() => {
  console.log('promise 1')
}).then(() => {
  console.log('promise 2')
})
複製程式碼

結果(Chrome 63.0.3239.84; Mac OS):

promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval // 大部分情況下2次, 少數情況下一次
setTimeout 2
promise 5
promise 6
複製程式碼

這個順序是如何得來的?

我們先講promise 4後面只出現一次setInterval的情況, 畫個圖簡單表示一下這個過程:

任務佇列機制

注意

本圖為了方便把各時間段(Cycle)佇列的任務都畫在佇列中去了, 實際上執行一個task 和 microtask 後就會把這個任務從相應佇列中刪除

首先, 主任務就是執行指令碼, 也就是執行上述程式碼, 這也是一個task. 在執行程式碼過程中, 遇到setTimeout、setInterval 就會將回撥函式新增到task queue中, 遇到 promise 就會將then回撥新增到 microtask 中去.

Task執行完, 接著取所有 microtask 執行, 所有microtask 執行完了, microtask queue也就空了, 接著再取task執行, 如果microtask queue為空, 沒有任務, 則繼續取下一個task執行, 就這樣迴圈執行. 圖中箭頭就表示執行的順序.

那麼為什麼promise 4後面大部分情況下出現2次setInterval, 少數情況出現1次呢?

我猜測這是因為setInterval是有最短間隔時間的(chrome下4ms左右), 這個時間不同機子、不同瀏覽器都有可能不一樣. 程式碼中的引數是0, 意味著儘可能短的時間內就會產生一個task加入到 task queue中. 瀏覽器在執行setInterval後到執行下一個task前, 時間間隔就可能超過這個最短時間, 因此會產生一個setInterval task.

我是這樣論證的:

我把含有promise5、promise6回撥函式的setTimeout的時間設定大一點, 讓它推遲插入task queue中:

...  
setTimeout(() => {
      console.log('setTimeout 2')
      Promise.resolve().then(() => {
        console.log('promise 5')
      }).then(() => {
        console.log('promise 6')
      }).then(() => {
      	clearInterval(interval)
      })
}, 10)   //這裡加上10ms 
...
複製程式碼

結果是promise 4後面的setInterval出現了5次, 因此我覺得promise 4後面大部分情況下出現2次setInterval、少數情況出現一次的原因就是瀏覽器在執行setInterval回撥函式後、執行setTimeout回撥函式前, 時間間隔大部分情況超過了這個最短時間.

另外, 我試著再依次加上1ms, 直到14ms——也就是加上4ms時, promise 4後面的setInterval變成了6次, 可以認為setInterval最短間隔時間在Chrome下約為4ms(不考慮機子效能、設定).

Node中的奇怪結果

首先說明一下, 在Node中也體現了任務佇列的機制, 但是這不是Node實現的, 這是V8實現的, 由Node呼叫了V8任務佇列機制的API. 至於為什麼是V8實現的, 我們翻翻ECMA 262 標準對 Job 和 Job queue 的介紹就可以得知

但是讓人摸不著頭腦的是, 這段程式碼在node v8.5.0下有時會出現這樣的結果:

promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
setTimeout 2
setInterval   // 為什麼會出現setInterval???
promise 5
promise 6
複製程式碼

按理說應該是setTimeout 2 => promise 5 => promise 6, 因為輸出setTimeout 2的回撥函式是task, 執行完這個task後應該呼叫microtask 輸出promise 5 => promise 6啊? 很奇怪! Node對V8確實有些改動, 不知道是不是這方面原因...

還請大神解惑!

你竟然讀到這了

總結一下:

學習技術還是有捷徑的, 那就是讀標準 ;)

相關文章