事件迴圈Event loop到底是什麼

0nTheRoad發表於2021-01-27
摘要:本文通過結合官方文件MDN和其他部落格深入解析瀏覽器的事件迴圈機制,而NodeJS有另一套事件迴圈機制,不在本文討論範圍中。process.nextTick和setImmediate是NodeJS的API,所以本文也不予討論。

首先,先了解幾個概念。

Javascript到底是單執行緒還是多執行緒語言?


Javascript是一門單執行緒語言。相信應該有不少朋友對於Javascript是單執行緒語言還有些疑問(題外話:之前在某次面試中遇到一個面試官,一來就是“我們知道JS是一門多執行緒語言。。。”巴拉巴拉,當時就把我給愣住了。),不是有Web Worker可以建立多個執行緒嗎?答案就是,Javascript是單執行緒的,但是他的執行環境不是單執行緒。要如何理解這句話,首先得從Javascript執行環境比如瀏覽器的多執行緒說起。

瀏覽器通常包含以下執行緒:

  1. GUI渲染執行緒

    • 主要負責頁面的渲染,解析HTML、CSS,構建DOM樹,佈局和繪製等。
    • 當介面需要重繪或者由於某種操作引發迴流時,將執行該執行緒。
    • 該執行緒與JS引擎執行緒互斥,當執行JS引擎執行緒時,GUI渲染會被掛起。
  2. JS引擎執行緒

    • 該執行緒負責處理Javascript指令碼,執行程式碼。
    • 負責執行待執行的事件,比如定時器計數結束,或者非同步請求成功並正確返回時,將依次進入任務佇列,等待JS引擎執行緒執行。
    • 該執行緒與GUI執行緒互斥,當JS執行緒執行Javascript指令碼事件過長,將導致頁面渲染的阻塞。
  3. 定時器觸發執行緒

    • 負責執行非同步定時器一類函式的執行緒,如:setTimeout,setInterval。
    • 主執行緒依次執行程式碼時,遇到定時器會將定時器交給該執行緒處理,當計數完畢後,事件觸發執行緒會將計數完畢的事件回撥加入到任務佇列,等待JS引擎執行緒執行。
  4. 事件觸發執行緒

    • 主要負責將等待執行的事件回撥交給JS引擎執行緒執行。
  5. 非同步http請求執行緒

    • 負責執行非同步請求一類函式的執行緒,如:Promise,axios,ajax等。
    • 主執行緒依次執行程式碼時,遇到非同步請求,會將函式交給該執行緒處理,當監聽到狀態碼變更,如果有回撥函式,事件觸發執行緒會將回撥函式加入到任務佇列,等待JS引擎執行緒執行。

Web Worker是瀏覽器為Javascript提供的一個可以在瀏覽器後臺開啟一個新的執行緒的API(類似上面說到瀏覽器的多個執行緒),使Javascript可以在瀏覽器環境中多執行緒執行,但這個多執行緒是指瀏覽器本身,是它在負責排程管理Javascript程式碼,讓他們在恰當時機執行。所以Javascript本身是不支援多執行緒的。

非同步


Javascript的非同步過程通常是這樣的:

  1. 主執行緒發起一個非同步請求,非同步任務接受請求並告知主執行緒已收到(非同步函式返回);
  2. 主執行緒繼續執行後續程式碼,同時非同步操作開始執行;
  3. 非同步操作執行完成後通知主執行緒;
  4. 主執行緒收到通知後,執行非同步回撥函式。

這個過程有個問題,非同步任務各任務的執行時間過程長短不同,執行完成的時間點也不同,主執行緒如何調控非同步任務呢?這就引入了訊息佇列。

棧、堆、訊息佇列


:函式呼叫形成的一個由若干幀組成的棧。

:物件被分配在堆中,堆是一個用來表示一大塊(通常是非結構化的)記憶體區域。

訊息佇列:一個Javascript執行時包含了一個待處理訊息的訊息佇列。每一個訊息都關聯著一個用來處理這個訊息的回撥函式。在事件迴圈期間,執行時會從最先進入佇列的訊息開始處理,被處理的訊息會被移出佇列,並作為輸入引數來呼叫與之關聯的函式。然後事件迴圈在處理佇列中的下一個訊息。

事件迴圈Event loop到底是什麼

事件迴圈Event loop


瞭解了上述要點,現在回到主題事件迴圈。那麼Event loop到底是什麼呢?

Event loop是一個執行模型,在不同的地方有不同的實現。瀏覽器和NodeJS基於不同的技術實現了各自的Event loop。
現在明白為什麼要把NodeJS排除在外了吧?同樣網上很多Event loop的相關博文一來就是Javascript的Event loop,實際上說的都是瀏覽器的Event loop。
瀏覽器的Event loop是在Html5規範中定義的,大致總結如下:

一個事件迴圈裡有很多個任務佇列(task queues)來自不同任務源,每一個任務佇列裡的任務(task)都是嚴格按照先進先出的順序執行的,但是不同任務佇列的任務執行順序是不確定的,瀏覽器會自己排程不同任務佇列。也有地方把task稱之為macrotask(巨集任務)。

規範中還提到了microtask(微任務)的概念,以下是規範闡述的程式模型:

  1. 選擇當前要執行的任務佇列,選擇一個最先進入任務佇列的任務,如果沒有任務可以選擇,則會跳轉至microtask的執行步驟;
  2. 將事件迴圈的當前執行任務設定為已選擇的任務;
  3. 執行任務;
  4. 將事件迴圈的當前任務設定為null,將執行完的任務從任務佇列中移除;
  5. microtask步驟:進入microtask檢查點;
  6. 更新介面渲染;
  7. 返回第一步。

執行進入microtask檢查點時,使用者代理會執行以下步驟:

  1. 設定進入microtask檢查點的標誌為true;
  2. 當事件迴圈的微任務佇列不為空時:選擇一個最先進入microtask佇列的microtask,設定事件迴圈當前執行任務為此microtask;
  3. 執行microtask;
  4. 設定事件迴圈當前執行任務為null,將執行結束的microtask從microtask佇列中移除;
  5. 對於相應事件迴圈的每個環境設定物件,通知它們哪些promise為rejected;
  6. 清理indexedDB的事務;
  7. 設定進入microtask檢查點的標誌為false。

由上可總結為:在事件迴圈中,使用者代理會不斷從task佇列中按順序取task執行,每執行完一個task都會檢查microtask佇列是否為空(執行完一個task的具體標誌時函式執行棧為空),如果不為空則會一次性執行完所有microtask。然後再進入下一個迴圈去task佇列中取下一個task執行。

task/macrotask(巨集任務)

  • script(整體程式碼)
  • setTimeout
  • setInterval
  • I/O
  • UI rendering

microtask(微任務)

  • Promise.then catch finally
  • MutationObserver

來看一個例子:

console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

執行結果是:

script start
script end
promise1
promise2
setTimeout

那麼問題來了,不是說每個事件迴圈開始會從task佇列取最先進入的task執行,然後再執行所有microtask嗎?為什麼setTimeout是task卻在Promise.then這個task的前面呢?反正我一開始是有這個疑惑的,很多文章都沒有說清楚這個具體執行的順序,大部分都是在描述規範的時候說的是“每個事件迴圈開始會從task佇列中取一個task執行,然後再執行所有microtask”,但是也有部分文章說的是“每個事件迴圈開始都是先執行所有microtask”。經過本人多方查證,規範裡的描述如上確實就是每個事件迴圈都是先執行task,那為什麼上面例子裡面體現出來的是先執行所有microtask呢?

script(整體程式碼)屬於task。

來看一下上面例子的詳細執行過程:

  1. 事件迴圈開始,task佇列中只有一個script,選擇script作為事件迴圈的已選擇任務;
  2. script按順序執行,同步程式碼直接輸出(script start、script end);
  3. 遇到setTimeout,0ms後將回撥函式放入task佇列;
  4. 遇到Promise,將第一個then的回撥函式放入microtask佇列;
  5. 當所有script程式碼執行完成後,此時函式執行棧為空,開始檢查microtask佇列,佇列只有第一個.then的回撥函式,執行輸出“promise1”,由於第一個.then返回的依然是promise,所以第二個.then的回撥會放入microtask佇列繼續執行,輸出“promise2”;
  6. 此時microtask佇列空了,進入下一個事件迴圈,檢查task佇列取出setTimeout回撥函式,執行輸出“setTimeout”,程式碼執行完成。

這樣是不是清楚了?所以實際上一開始執行script程式碼的時候就已經開始事件迴圈了,這就解釋了為什麼好像每次都是先執行所有的microtask。同時,這個例子中還引申出一個要點:在執行microtask任務的時候,如果又產生了新的microtask,那麼會繼續新增到佇列的末尾,且也會在這個事件迴圈週期執行,直到microtask佇列為空為止。

相關文章