深入理解 Event Loop

前端路跡發表於2019-05-22

眾所周知,JavaScript(以下簡稱 JS) 是單執行緒語言,在 html5 中增加了 web workers,web workers 是新開了執行緒執行的,那麼 JS 還是單執行緒的嗎?當然是,為什麼要設計成單執行緒?

網上有很多說法,大部分都說是多個執行緒同時對一個dom操作(同時修改dom內容,一個執行緒增加屬性,一個執行緒刪除屬性),會非常混亂,當然如果支援多執行緒就會相應的就要加入多執行緒的鎖機制,那麼 JS 就變得非常複雜了,想想 JS 最開始設計的初衷就是用於使用者互動,而且當時的原始需求是:功能不需要太強,語法較為簡單,容易學習和部署,Brendan Eich 只用了10天,就設計完成了這種語言的第一版,因此也不可能加入多執行緒這麼複雜的技術。

即使現在支援 web workers,由於沒有多執行緒的機制,web workers 和執行執行緒只能通過 postMessage 來通訊,而且由於沒有鎖,web workers 無法訪問 window 和 document 物件。

JS 的單執行緒是指一個瀏覽器程式中只有一個 JS 的執行執行緒,即同一時刻內只會有一段程式碼在執行。

Micro-Task 與 Macro-Task

單執行緒如何實現非同步?JS 設計了一個事件迴圈的方式。所有的程式碼執行均按照事件迴圈的方式進行。

事件迴圈中分兩種任務:一個是巨集任務(Macro-Task),另一個是微任務(Micro-Task)。常見的巨集任務和微任務如下。

巨集任務:script(整體程式碼)、setTimeout、setInterval、requestAnimationFrame、I/O、事件、MessageChannel、setImmediate (Node.js) 微任務:Promise.then、 MutaionObserver、process.nextTick (Node.js)

事件迴圈按下圖的方式進行。

深入理解 Event Loop

注意: 巨集任務執行完後,需要清空當前微任務佇列後才回去執行下一個巨集任務,如果微任務裡面產生了新的微任務,仍然會在當前事件迴圈裡面被執行完,後面會舉例說明。

來個示例驗證下上面的流程。

<script>
    console.log(1);

    setTimeout(function timeout1() {
        console.log(2);
    }, 0);

    Promise.resolve().then(function promise1() {
        console.log(3);
        setTimeout(function timeout2() {
            console.log(4);
            Promise.resolve().then(function promise2() {
                console.log(5);
            });
        }, 0);
        return Promise.resolve()
            .then(function promise3() {
                console.log(6);
                return Promise.resolve().then(function promise4() {
                    console.log(7);
                });
            })
            .then(function promise5() {
                console.log(8);
            });
    })
    console.log(9);
</script>

<script>
    console.log(10);
    setTimeout(function timeout3() {
        console.log(11);
    }, 0);
    Promise.resolve().then(function promise6() {
        console.log(12);
    });
</script>
複製程式碼

按照上面流程梳理下執行流程:

  1. 將兩個巨集任務(兩個script程式碼)初始化進巨集任務佇列,巨集任務佇列為:[script1, script2]
  2. script1 出隊壓入執行棧執行,巨集任務佇列為:[script2]
  3. 同步程式碼執行輸出:1,
  4. 0ms 後把 timeout1 放入巨集任務佇列,巨集任務佇列為:[script2, timeout1]
  5. promise1 入隊,微任務佇列為:[promise1]
  6. 同步程式碼執行輸出:9
  7. script1 執行完畢,進入微任務執行階段,promise1 出隊壓入執行棧執行,微任務佇列為空
  8. 同步程式碼執行輸出:3
  9. 0ms 後把 timeout2 放入巨集任務佇列,巨集任務佇列為:[script2, timeout1, timeout2]
  10. promise3 入隊,微任務佇列為:[promise3]
  11. promise1 執行完畢,繼續判斷微任務佇列是否為空,promise3 出隊壓入執行棧執行,微任務佇列為空
  12. 同步程式碼執行輸出:6
  13. promise4 入隊,微任務佇列為:[promise4]
  14. promise3 執行完畢,promise5 入隊,微任務佇列為:[promise4,promise5]
  15. 判斷微任務佇列是否為空,promise4 出隊壓入執行棧執行,微任務佇列為:[promise5]
  16. 同步程式碼執行輸出:7
  17. promise4 執行完畢,繼續判斷微任務佇列是否為空,promise5 出隊壓入執行棧執行,微任務佇列為空
  18. 同步程式碼執行輸出:8
  19. 微任務佇列清空,巨集任務 script2 出隊壓入執行棧執行,巨集任務佇列為空
  20. 同步程式碼執行輸出:10
  21. 0ms 後把 timeout3 放入巨集任務佇列,巨集任務佇列為:[timeout1, timeout2, timeout3]
  22. promise6 入隊,微任務佇列為:[promise6]
  23. script2 執行完畢,進入微任務執行階段,promise6 出隊壓入執行棧執行,微任務佇列為空
  24. 同步程式碼執行輸出:12
  25. 微任務佇列為空,巨集任務 timeout1 壓入執行棧執行,巨集任務佇列為[timeout2, timeout3]
  26. 同步程式碼執行輸出:2
  27. timeout1執行完畢,微任務佇列為空,巨集任務 timeout2 壓入執行棧執行,巨集任務佇列為[timeout3]
  28. 同步程式碼執行輸出:4,promise2 入隊,微任務佇列為:[promise2]
  29. timeout2 執行完畢,判斷微任務佇列是否為空,promise2 出隊壓入執行棧執行,微任務佇列為空
  30. 同步程式碼執行輸出:5
  31. promise2執行完,微任務佇列為空,巨集任務 timeout2 壓入執行棧執行,巨集任務佇列為空
  32. 同步程式碼執行輸出:11
  33. timeout3執行完畢,微任務佇列為空,巨集任務佇列為空

setTimeout

setTimeout 的 delay 最小值在不同瀏覽器的有差異,在 Chrome 74 上測試的結果是 2ms,Firefox 67 上測試的記過是 1ms。

最小值是什麼意思?就是小於這個值後,瀏覽器按照0處理。比如在 Chrome 上,測試下面的程式碼:

setTimeout(function(){console.log(1)},1.99);
setTimeout(function(){console.log(2)},0);
複製程式碼

輸出的結果為 1、2,而

setTimeout(function(){console.log(1)},2);
setTimeout(function(){console.log(2)},0);
複製程式碼

輸出的結果為 2、1,說明 2ms 是有效的。

另外 setTimeout 是從呼叫開始計時,到了時間就放入巨集任務佇列,我們來看下面的例子。

var s = Date.now()
setTimeout(function timeout1() {
    console.log(1)
}, 200)

while (Date.now() - s <= 200) {
}

setTimeout(function timeout2() {
    console.log(2)
}, 0)
複製程式碼
  1. timeout1 200ms 後會放入到巨集任務佇列中
  2. while 執行了 200ms,此時 timeout1 已經先新增到巨集任務佇列中,因此最終列印結果為:1、2
  3. 如果將 while 的時間設定小於 200ms,考慮到程式碼執行需要花費時間,將 while 的條件改為Date.now() - s <= 198
  4. 測試 while 執行只花費了 198ms,timeout2 會被先新增到巨集任務佇列中,因此最終列印結果會是:2、1

setInterval

和 setTimeout 相同,呼叫開始計時,按 delay 時間將回撥新增到巨集任務佇列中。那麼 setInterval 是按 delay 不斷的向巨集任務佇列新增任務,還是需要等待已新增的任務執行完後再新增,還是其他機制?

思考下面程式碼:

var start = Date.now()

var id = setInterval(function interval() {

    var whileStart = Date.now()   
    console.log(whileStart - start)                 // 輸出 interval1 呼叫的時間和最開始呼叫計時的時間差,即過了多久才呼叫
    while (Date.now() - whileStart < 250) {   // 相當於 sleep 250ms
    }
}, 100)

setTimeout(function timeout() {
    clearInterval(id)
    console.log(Date.now() - start)       
}, 400)
複製程式碼

列印的時間間隔是?

100
351
605
855
複製程式碼

為了更好的理解,用圖示來解釋上面的流程。

深入理解 Event Loop

參考

JavaScript語言的歷史

相關文章