跟著 Event loop 規範理解瀏覽器中的非同步機制

fi3ework發表於2018-07-25

原文發自我的 GitHub blog,歡迎關注

前言

我們都知道 JavaScript 是一門單執行緒語言,這意味著同一事件只能執行一個任務,結束了才能去執行下一個。如果前面的任務沒有執行完,後面的任務就會一直等待。試想,有一個耗時很長的網路請求,如果所有任務都需要等待這個請求完成才能繼續,顯然是不合理的並且我們在瀏覽器中也沒有體驗過這種情況(除非你要同步請求 Ajax),究其原因,是 JavaScript 藉助非同步機制來實現了任務的排程。

程式中現在執行的部分和將來執行的部分之間的關係就是非同步程式設計的核心。

我們先看一個面試題:

try {
  setTimeout(() => {
    throw new Error("Error - from try statement");
  }, 0);
} catch (e) {
  console.error(e);
}
複製程式碼

上面這個例子會輸出什麼?答案是:

image

說明並沒有 catch 到丟出來的 error,這個例子可能理解起來費勁一點。

如果我換一個例子

console.log("A");

setTimeout(() => {
  console.log("B");
}, 100);

console.log("C");
複製程式碼

稍微瞭解一點瀏覽器中非同步機制的同學都能答出會輸出 “A C B”,本文會通過分析 event loop 來對瀏覽器中的非同步進行梳理,並搞清上面的問題。

呼叫棧

函式呼叫棧其實就是執行上下文棧(Execution Context Stack),每當呼叫一個函式時就會產生一個新的執行上下文,同時新產生的這個執行上下文就會被壓入執行上下文棧中。

跟著 Event loop 規範理解瀏覽器中的非同步機制

全域性上下文最先入棧,並且在離開頁面時開會出棧,JavaScript 引擎不斷的執行上下文棧中棧頂的那個執行上下文,在它執行完畢後將它出棧,直到整個執行棧為空。關於執行棧有五點比較關鍵:

  1. 單執行緒(這是由 JavaScript 引擎決定的)。
  2. 同步執行(它會一直同步執行棧頂的函式)。
  3. 只有一個全域性上下文。
  4. 可有無數個函式上下文(理論是函式上下文沒有限制,但是太多了會爆棧)。
  5. 每個函式呼叫都會建立一個新的 執行上下文,哪怕是遞迴呼叫。

這裡首先要明確一個問題,函式上下文執行棧是與 JavaScript 引擎(Engine)相關的概念,而非同步/回撥是與執行環境(Runtime)相關的概念。

如果執行棧與非同步機制完全無關,我們寫了無數遍的點選觸發回撥是如何做到的呢?是執行環境(瀏覽器/Node)來完成的, 在瀏覽器中,非同步機制是藉助 event loop 來實現的,event loop 是非同步的一種實現機制。JavaScript 引擎只是“傻傻”的一直執行棧頂的函式,而執行環境負責管理在什麼時候壓入執行上下文棧什麼函式來讓引擎執行。

JavaScript 引擎本身並沒有時間的概念,只是一個按需執行 JavaScript 任意程式碼片段的環境。“事件”( JavaScript 程式碼執行)排程總是由包含它的環境進行。

另外,從一個側面可以反應出執行上下文棧與非同步無關的 —— 執行上下文棧是寫在 ECMA-262 的規範中,需要遵守它的是瀏覽器的 JavaScript 引擎,比如 V8、Quantum 等。event loop 的是寫在 HTML 的規範中,需要遵守它的是各個瀏覽器,比如 Chrome、Firefox 等。

event loop

定義

我們通過 HTML5規範 的定義來看 event loop 的定義來看模型,本章節所有引用的部分都是翻譯自規範。

為了協調時間,使用者互動,指令碼,介面渲染,網路等等,使用者代理必須使用下一節描述的 event loops。event loops 分為兩種:瀏覽器環境及為 Web Worker 服務的。

本文只關注瀏覽器部分,所以忽略 Web Worker。JavaScript 引擎並不是獨立執行的,它需要執行在宿主環境中, 所以其實使用者代理(user agent)在這個情境下更好的翻譯應該是執行環境或者宿主環境,也就是瀏覽器。

每個使用者代理必須至少有一個 browsing context event loop,但每個 unit of related similar-origin browsing contexts 最多隻能有一個。

關於 unit of related similar-origin browsing contexts,節選一部分規範的介紹:

Each unit of related browsing contexts is then further divided into the smallest number of groups such that every member of each group has an active document with an origin that, through appropriate manipulation of the document.domain attribute, could be made to be same origin-domain with other members of the group, but could not be made the same as members of any other group. Each such group is a unit of related similar-origin browsing contexts.

簡而言之就是一個瀏覽器環境(unit of related similar-origin browsing contexts.),只能有一個事件迴圈(event loop)。

event loop 又是幹什麼的呢?

每個 event loop 都有一個或多個 task queues. 一個 task queue 是 tasks 的有序的列表, 是用來響應如下如下工作的演算法:

  • 事件

    EventTarget 觸發的時候釋出一個事件 Event 物件,這通常由一個專屬的 task 完成。

    注意:並不是所有的事件都從是 task queue 中釋出,也有很多是來自其他的 tasks。

  • 解析

    HTML 解析器 令牌化然後產生 token 的過程,是一個典型的 task。

  • 回撥函式

    一般使用一個特定的 task 來呼叫一個回撥函式。

  • 使用資源(譯者注:其實就是網路)

    當演算法 獲取 到了資源,如果獲取資源的過程是非阻塞的,那麼一旦獲取了部分或者全部的內容將由 task 來執行這個過程。

  • 響應 DOM 的操作

    有一些元素會對 DOM 的操作產生 task,比如當元素被 插入到 document 時

可以看到,一個頁面只有一個 event loop,但是一個 event loop 可以有多個 task queues。

每個來自相同 task source 並由相同 event loop(比如,Document 的計時器產生的回撥函式,Document 的滑鼠移動產生的事件,Document 的解析器產生的 tasks) 管理的 task 都必須加入到同一個 task queue 中,可是來自不同 task sourcestasks 可能會被排入到不同的 task queues 中。

來自相同的 task source 的 task 將會被排入相同的 task queue,但是規範說來自不同 task sourcestasks 可能會被排入到不同的 task queues 中,也就是說一個 task queue 中可能排列著來自不同 task sources 的 tasks,但是具體什麼 task source 對應什麼 task queue,規範並沒有具體說明。

但是規範對 task source 進行了分類:

如下 task sources 被大量應用於本規範或其他規範無關的特性中:

  • DOM 操作的 task source

    這種 task source 用來對 DOM 的操作進行反應,比如像 inserted into the document 的非阻塞的行為。

  • 使用者操作的 task source

    這種 task source 用來響應使用者的反應,比如滑鼠和鍵盤的事件。這些用來反應使用者輸入的事件必須由 user interaction task source 來觸發並排入 tasks queued

  • 網路 task source

    這種 task source 用來反應網路活動的響應。

  • 時間旅行 task source

    這種 task source 用來將 history.back() 等 API 排入 task queue。

一般我們看個各個文章中對於 task queue 的描述都是隻有一個,不論是網路,使用者時間內還是計時器都會被 Web APIs 排入到用一個 task queue 中,但事實上規範中明確表示了是有多個 task queues,並舉例說明了這樣設計的意義:

舉例來說,一個使用者代理可以有一個處理鍵盤滑鼠事件的 task queue(來自 user interaction task source),還有一個 task queue 來處理所有其他的。使用者代理可以以 75% 的機率先處理滑鼠和鍵盤的事件,這樣既不會徹底不執行其他 task queues 的前提下保證使用者介面的響應, 而且不會讓來自同一個 task source 的事件順序錯亂。

接著看。

使用者代理將要排入任務時,必須將任務排入相關的 event looptask queues

這句話很關鍵,是使用者代理(宿主環境/執行環境/瀏覽器)來控制任務的排程,這裡就引出了下一章的 Web APIs。

接下來我麼來看看 event loop 是如何執行 task 的。

處理模型

我們可以形象的理解 event loop 為如下形式的存在:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}
複製程式碼

event loop 會在整個頁面存在時不停的將 task queues 中的函式拿出來執行,具體的規則如下:

一個 event loop 在它存在的必須不斷的重複一下的步驟:

  1. 從 task queues 中取出 event loop 的最先新增的 task,如果沒有可以選擇的 task,那麼跳到第 Microtasks 步。
  2. 設定 event loop 當前執行的 task 為上一步中選擇的 task。
  3. 執行:執行選中的 task。
  4. 將 event loop 的當前執行 task 設為 null。
  5. 從 task queue 中將剛剛執行的 task 移除。
  6. Microtasks執行 microtask 檢查點的任務
  7. 更新渲染,如果是瀏覽器環境中的 event loop(相對來說就是 Worker 中的 event loop)那麼執行以下步驟:
  8. 如果是 Worker 環境中的 event loop(例如,在 WorkerGlobalScope 中執行),可是在 event loop 的 task queues 中沒有 tasks 並且 WorkerGlobalScope 物件為關閉的標誌,那麼銷燬 event loop,終止這些步驟的執行,恢復到 run a worker 的步驟。
  9. 回到第 1 步。

microtask

規範引出了 microtask,

每個 event loop 都有一個 microtask queue。microtask 是一種要排入 microtask queue 的而不是 task queue 的任務。有兩種 microtasks:solitary callback microtasks 和 compound microtasks。

規範只介紹了 solitary callback microtasks,compound microtasks 可以先忽略掉。

當一個 microtask 要被排入的時候,它必須被排如相關 event loopmicrotask queuemicrotasktask source 是 microtask task source.

microtasks 檢查點

當使用者代理執行到了 microtasks 檢查點的時候,如果 performing a microtask checkpoint flag 為 false,則使用者代理必須執行下面的步驟:

  1. performing a microtask checkpoint flag 置為 true。

  2. 處理 microtask queue:如果 event loop 的 microtask queue 是空的,直接跳到 Done 步。

  3. 選擇 event loop 的 microtask queue 中最老的 microtask。

  4. 設定 event loop 當前執行的 task 為上一步中選擇的 task。

  5. 執行:執行選中的 task。

注意:這有可能包含執行含有 clean up after running script 步驟的指令碼,然後會導致再次 執行 microtask 檢查點的任務,這就是我們要使用 performing a microtask checkpoint flag 的原因。

  1. 將 event loop 的當前執行 task 設為 null。

  2. 將上一步中執行的 microtask 從 microtask queue 中移除,然後返回 處理 microtask queue 步驟。

  3. 完成: 對每一個 responsible event loop 就是當前的 event loop 的 environment settings object,給 environment settings object 發一個 rejected promises 的通知。

  4. 清理 IndexedDB 的事務

  5. performing a microtask checkpoint flag 設為 false。

整個流程如下圖:

跟著 Event loop 規範理解瀏覽器中的非同步機制

task & microTask

task

task 主要包含:

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

microtask

microtask 主要包含:

  • process.nextTick(Node.js 環境)
  • Promises(這裡指瀏覽器實現的原生 Promise)
  • Object.observe(已被 MutationObserver 替代)
  • MutationObserver
  • postMessage

Web APIs

在上一章講講到了使用者代理(宿主環境/執行環境/瀏覽器)來控制任務的排程,task queues 只是一個佇列,它並不知道什麼時候有新的任務推入,也不知道什麼時候任務出隊。event loop 會根據規則不斷將任務出隊,那誰來將任務入隊呢?答案是 Web APIs。

我們都知道 JavaScript 的執行是單執行緒的,但是瀏覽器並不是單執行緒的,Web APIs 就是一些額外的執行緒,它們通常由 C++ 來實現,用來處理非同步事件比如 DOM 事件,http 請求,setTimeout 等。他們是瀏覽器實現併發的入口,對於 Node.JavaScript 來說,就是一些 C++ 的 APIs。

WebAPIs 本身並不能直接將回撥函式放在函式呼叫棧中來執行,否則它會隨機在整個程式的執行過程中出現。每個 WebAPIs 會在其執行完畢的時候將回撥函式推入到對應的任務佇列中,然後由 event loop 按照規則在函式呼叫棧為空的時候將回撥函式推入執行棧中執行。event loop 的基本作用就是檢查函式呼叫棧和任務佇列,並在函式呼叫棧為空時將任務佇列中的的第一個任務推入執行棧中,每一個任務都在下一個任務執行前執行完畢。

WebAPIs 提供了多執行緒來執行非同步函式,在回撥發生的時候,它們會將回撥函式和推入任務佇列中並傳遞返回值。

流程

至此,我們已經瞭解了執行上下文棧,event loop 及 WebAPIs,它們的關係可以用下圖來表示(圖片來自網路,原始出處已無法考證),一輪 event loop 的文字版流程如下:

首先執行一個 task,如果整個第一輪 event loop,那麼整體的 script 就是一個 task,同步執行的程式碼會直接放進 call stack(呼叫棧)中,諸如 setTimeout、fetch、ajax 或者事件的回撥函式會由 Web APIs 進行管理,然後 call stack 繼續執行棧頂的函式。當網路請求獲取到了響應或者 timer 的時間到了,Web APIs 就會將對應的回撥函式推入對應的 task queues 中。event loop 不斷執行,一旦 event loop 中的 current task 為 null,它就回去掃 task queues 有沒有 task,然後按照一定規則拿出 task queues 中一個最早入隊的回撥函式(比如上面提到的以 75% 的機率優先執行滑鼠鍵盤的回撥函式所在的佇列,但是具體規則我還沒找到),取出的回撥函式放入上下文執行棧就開始同步執行了,執行完之後檢查 event loop 中的 microtask queue 中的 microtask,按照規則將它們全部同步執行掉,最後完成 UI 的重渲染,然後再執行下一輪的 event loop...

68747470733a2f2f63646e2d696d616765732d312e6d656469756d2e636f6d2f6d61782f313630302f312a2d4d4d42484b795f5a7843726f7565635271767342672e706e67

應用

setTimeout 的不準確性

JavaScript 引擎並不是獨立執行的,它執行在宿主環境中

瞭解了上面 Web APIs,我們知道瀏覽器中有一個 Timers 的 Web API 用來管理 setTimeout 和 setInterval 等計時器,在同步執行了 setTimeout 後,瀏覽器並沒有把你的回撥函式掛在事件迴圈佇列中。 它所做的是設定一個定時器。 當定時器到時後, 瀏覽器會把你的回撥函式放在事件迴圈中, 這樣, 在未來某個時刻的 tick 會摘下並執行這個回撥。

但是如果定時器的任務佇列中已經被新增了其他的任務,後面的回撥就要等待。

let t1, t2

t1 = new Date().getTime();

// 1
setTimeout(()=>{
    let i = 0;
    while (i < 50000000) {i++}
    console.log('block finished')
}
, 300)

// 2
setTimeout(()=>{
    t2 = new Date().getTime();
    console.log(t2 - t1)
}
, 300)

複製程式碼

這個例子中,列印出來的時間戳就不會等於 300,雖然兩個 setTimeout 的函式都會在時間到了時被 Web API 排入任務佇列,然後 event loop 取出第一個 setTimeout 的回撥開始執行,但是這個回撥函式會同步阻塞一段時間,導致只有它執行完畢 event loop 才能執行第二個 setTimeout 的回撥函式。

進入呼叫棧的時機

例1

try {
  setTimeout(() => {
    throw new Error("Error - from try statement");
  }, 0);
} catch (e) {
  console.error(e);
}
複製程式碼

回到最開始的那個問題,整個過程是這樣的:執行到 setTimeout 時先同步地將回撥函式註冊給 Web APIs 的 timer,要清楚此時 setTimeout 的回撥函式此時根本沒有入呼叫棧甚至連 task queue 都沒有進入,所以 try 的這個程式碼塊就執行結束了,沒有丟擲任何 error,catch 也被直接跳過,同步執行完畢。

等到 timer 的計時到了(要注意並不一定是下一個 event loop,因為 setTimeout 在每個瀏覽器中的最短時間是不確定的,在 Chrome 中執行幾次也會發現每次時間都不同,0 ms ~ 2 ms 都有),會將 setTimeout 中的回撥放入 task queue 中,此時 event loop 中的 current task 為 null,就將這個回撥函式設為 current task 並開始同步執行,此時呼叫棧中只有一個全域性上下文,try catch 已經結束了,就會直接將這個 error 丟出。

例2

for (var i = 0; i < 5; i++) {
  setTimeout((function(i) {
    console.log(i);
  })(i), i * 1000);
}
複製程式碼

正確答案是立即輸出 “0 1 2 3 4”,setTime 的第一個引數接受的是一個函式或者字串,這裡第一個引數是一個立即執行函式,返回值為 undefined,並且在立即執行的過程中就輸出了 "0 1 2 3 4",timer 沒有接收任何回撥函式,就與 event loop 跟無關了。

例3

new Promise(resolve => {
    resolve(1);
    Promise.resolve().then(() => console.log(2));
    console.log(4)
}).then(t => console.log(t)); // a
console.log(3);
複製程式碼

是阮老師推特上的一道題,首先 Promise 建構函式中的物件同步執行(不瞭解 Promise 的同學可以先看下 這篇文章),碰到 resolve(1),將當前 Promise 標記為 resolve,但是注意它 then 的回撥函式還沒有被註冊,因為還沒有執行到 a 處。繼續執行又碰到一個 Promise,然後也立刻被 resolved 了,並且執行它的 then 註冊,將第二個 then 的回撥函式推入空的 microtaskQueue 中。繼續執行輸出一個 4,然後 a 處的 then 現在才開始註冊,將第一個 Promise 的 then 回撥函式推入 microtaskQueue 中。繼續執行輸出一個 3。現在 task queue 中的任務已經執行完畢,到了 microtask checkpoint flag,發現有兩個 microtask,按照新增的順序執行,第一個輸出一個 2,第二個輸出一個 1,最後再更新一下 UI 然後這一輪 event loop 就結束了,最終的輸出是"4 3 2 1"

Vue

筆者本人並沒有使用過 Vue,但是稍微知道一點 Vue 的 DOM 更新中有批量更新,緩衝在同一事件迴圈中的資料變化,即 DOM 只會被修改一次。

關於這點 顧軼靈 大佬在知乎上有過 回答

為啥要用 microtask?根據HTML Standard,在每個 task 執行完以後,UI 都會重渲染,那麼在 microtask 中就完成資料更新,當前 task 結束就可以得到最新的 UI 了。反之如果新建一個 task 來做資料更新,那麼渲染就會進行兩次。

在 event loop 那章的規範中明確的寫到,在 event loop 的一輪中會按照 task -> microTask -> UI render 的順序。使用者的程式碼可能會多次修改資料,而這些修改中後面的修改可能會覆蓋掉前面的修改,再加上 DOM 的操作是很昂貴的,一定要儘量減少,所以要將使用者的修改 thunk 起來然後只修改一次 DOM,所以需要使用 microTask 在 UI 更新渲染前執行,就算有多次修改,也會只修改一次 DOM,然後進行渲染。

更新一下,現在 Vue 的 nextTick 實現移除了 MutationObserver 的方式(相容性原因),取而代之的是使用 MessageChannel。

其實用什麼具體的 API 不是最關鍵的,重要的是使用 microTask 在 在 UI render 前進行 thunk。

參考

相關文章