# 再談Event Loop

什麼是 event loop?JavaScript 跑在一個 thread 上,一次只能做一件事,不能像其他程式語言一樣,想開 thread 就開 thread,想怎樣就怎樣。(備註:nodejs 在 12.10.0 推出了 threads (opens new window) module)

這和 Javascript 的歷史以及設計目的有關,當初 JavaScript 是為了在瀏覽器上運作,和使用者互動的腳本語言。

由於 Javascript 需要操作 DOM,並且將 DOM 正確地繪製在瀏覽器上。如果有兩個 thread 同時修改 DOM 怎麼辦?dead lock 的問題怎麼辦?一旦有多執行緒,就必須考慮到不同 thread 同時存取同一變數的情形,也會讓情況變得更加複雜。

如果有寫過 iOS 或是 Android 的手機應用開發,可以發現編譯器會要求任何的繪製動作都要在 main thread 執行,否則會有不可預期的結果。

不過,為什麼會有不可預期的結果,可以試著從幾個角度思考:

  • 如果兩個 thread 同時存取 DOM 節點,其中一個將節點刪除,另外一個 thread 存取 DOM 的屬性(假設是 getAttribute 好了),只要一不小心就會噴出例外
  • 假設多個 thread 同時監聽同樣的 event,執行順序該怎麼處理

像是這樣的 case 處理起來可以說是相當棘手。JavaScript 可沒那麼多時間慢慢想了,為了避免不必要的複雜性,所以只有一個,這也讓前端開發省下許多功夫去處理

那麼問題就來了,如果一個 API call 需要 3 秒鐘,難道瀏覽器就要停在那等 3 秒嗎?顯然不是,為了解決這樣的問題,衍生出了 event loop 的機制。

所有同步性的工作,瀏覽器會一個個執行,遇到非同步的操作(IO, API call 等等),會先放到一個叫做 task queue 的地方,等到瀏覽器目前沒有其他工作,就會到 task queue 看看有沒有還沒執行的任務,再把它拿出來執行。

整個流程圖大概像這樣:

![Untitled Diagram](/Users/kalan/Desktop/Untitled Diagram.png)

由於這個過程是不間斷的,所以就叫做 event loop。

setTimeout 也會將任務放入 task queue 當中,不斷檢查 timeout 是否符合,符合的話就會將函數拿出來執行。

意識到這件事很重要,我們必須要等到整個 stack 的任務執行完畢後,stack 為空才會將 task queue 的函數拿出來。例如下面的程式碼:

function fib(n) {
    if (n < 1) {
        return 1;
    }
    return fib(n - 1) + fib(n - 2);
}
setTimeout(() => console.log('hello'), 1000);
fib(40);

在瀏覽器執行這一段程式碼,你會發現為什麼過了一秒,console.log 卻還沒出現。

這是因為 fib(40) 造成的遞迴塞滿了整個 stack,直到瀏覽器消化完了之後,才趕緊將 task queue 的 setTimeout 拿出來。

所以即使有 setTimeout 的機制存在,也無法擺脫 JavaScript 本質是一個 thread 的事實,也不是在 setTimeout 中執行效能昂貴的操作就沒事了。

另外我們再來看看,如果在 setTimeout 執行過長的任務會怎樣:

setTimeout(() => {
  fib(40);
}, 0);
setTimeout(() => {
  console.log('hello');
}, 500);

從這裡我們也可以清楚了解 setTimeout(fn, 0) 跟直接執行有什麼區別,setTimeout 會先把任務放入 task queue 當中再回到 stack 執行,若 stack 有其他 task 也會阻塞任務執行。因此,fib(40) 執行後因為讓 stack 持續有任務,導致 500ms 過後 console.log('hello') 還沒出現。

這個機制看起來沒什麼,卻告訴了我們幾件事:

  • 若 stack 裡頭有其他任務正在進行,setTimeout 的時間可能不會被正確觸發。
  • setTimeout 裡頭執行過長的任務也會導致 UI blocking。

# Micro Tasks

除了一般 setInterval, setTimeout, ajax 請求之外,還有其他 browser 的 API 會使用 task queue,但執行時機又稍有不同。

  • Promise
  • MutationObserver

這兩者的差異最大的差異在於執行時機,我們來看看下列的程式碼:

Promise.resolve('hello world').then(function(str) {
  console.log(str);
}).then(function() {
  console.log('promise2');
});

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

// hello world
// promise2
// setTimeout

從這裡可以發現,Promise callback 的執行會比 setTimeout 還早。在每次的 event loop 當中,如果 micro task queue 裡頭有函數,會在下一個頁面渲染之前執行 micro task queue (promise, MutationObserver)裏頭的函數,再執行 task queue (setTimeout, setInterval)的函數,再渲染頁面。

簡單來說就是在頁面渲染之後執行的任務,只是為什麼要有這樣的機制呢?

他們會優先於渲染頁面之前開始跑,原因在於這些操作有可能會有 DOM 的操作,在同一個 event loop 執行後可以確保頁面渲染只有一次。

# 後記

雖然整個 event loop 的過程比上述來得更複雜一些,但這裡掌握幾件事就可以了。

  1. 盡量不要在 callback 當中執行負擔過重的函數,避免佔據 call stack
  2. 理解 microtask 與一般 task queue 執行順序不同。

如果對詳細的運作過程有興趣,可以參考 tasks-microtaks-queues (opens new window),裡頭提供了很詳盡的視覺化,可以讓你了解背後是怎麼運作的。