JavaScript深入之事件迴圈機制(event loop)

Escape Plan發表於2019-04-03

單執行緒模型

眾所周知,JavaScript 是單執行緒的,所謂單執行緒,就是指一次只能完成一個任務,如果有多個任務就必須要排隊,前面的一個任務完成了,再執行後面的任務,以此類推。

需要注意的是 JavaScript 只在一個執行緒上執行,不代表瀏覽器核心只有一個執行緒,事實上瀏覽器內部有多個執行緒,主執行緒用於 JavaScript 程式碼的編譯和執行,其它執行緒都是在後臺配合主執行緒。

JavaScript 之所以選擇單執行緒,跟歷史有關係。JavaScript 從誕生起就是單執行緒,原因是不想讓瀏覽器變得太複雜,多執行緒需要面臨鎖、狀態同步等問題,這對於一種網頁尾本語言來說開銷太大。如果 JavaScript 同時有兩個執行緒,一個執行緒在網頁 DOM 節點上新增內容,另一個執行緒刪除了這個節點,這時瀏覽器應該以哪個執行緒為準?所以,為了避免複雜性,JavaScript 一開始就是單執行緒,這已經成了這門語言的核心特徵。

同步和非同步

上面說了 JavaScript 是單執行緒的,這種模式下,如果有一個非常耗時的任務進行的話,後面的任務都得排隊等著,這時候應用程式就無法去做其他的事情,為此 JavaScript 語言的任務執行模式分為兩個部分:同步(Synchronous)和非同步(Asynchronous)

  • 同步:就是上面說的排隊等待的形式。
  • 非同步:非同步操作發生在未知或不可預測的時間,是指在執行一個任務的時候不能立即返回結果,而是在將來通過一定手段得到,後一個任務不用等前一個任務結束就執行。

那麼 JavaScript 是如何來執行非同步任務的呢,就是後面要講的事件迴圈機制。

呼叫棧(call stack)

講事件迴圈之前,我們先來看一下 JavaScript 中的 call stack。下圖是 JavaScript 引擎的一個簡化圖:

JavaScript深入之事件迴圈機制(event loop)

上圖中看出 JavaScript 引擎主要包含兩個部分:

  1. Memory Heap (記憶體堆):這是記憶體分配發生的地方。
  2. Call Stack(呼叫棧):這是程式碼執行時儲存函式呼叫的結構。

前面說了,JavaScript 是一種單執行緒程式語言,這意味著它只有一個 Call Stack 。因此,它一次僅能做一件事。Call Stack 是一個資料結構,它基本記錄了我們在程式執行中的所處的位置,如果我們進入一個函式,我們把它放在堆疊的頂部。如果我們從一個函式中返回,我們彈出堆疊的頂部。

JavaScript深入之事件迴圈機制(event loop)

上面圖中可以看出,當開始執行 JS 程式碼時,首先向呼叫棧中壓入一個 main()函式(代表了全域性上下文),然後執行我們的程式碼,根據先進後出的原則,後執行的程式碼會先彈出棧。

如果在呼叫堆疊中執行的函式呼叫需要花費大量時間才能進行處理,會發生什麼? 例如,假設你想在瀏覽器中使用 JavaScript 進行一些複雜的影象轉換。這時候瀏覽器就被阻塞了,這意味著瀏覽器無法渲染,它不能執行任何其他程式碼,它就是被卡住了。這時候就想到了我們前面講過的非同步任務的處理方式,那麼如何執行非同步任務呢,就是下面要講的事件迴圈(event loop)機制

事件迴圈(event loop)

儘管允許執行非同步 JavaScript 程式碼(如 setTimeout 函式),但直到 ES6 出現,實際上 JavaScript 本身從來沒有任何明確的非同步概念。 JavaScript 引擎從來都只是執行單個程式模組而不做更多別的事情。 那麼,誰來告訴 JS 引擎去執行你編寫的一大段程式?實際上,JS 引擎並不是孤立執行,它執行在一個宿主環境中,對於大多數開發人員來說,宿主環境就是一個典型的 Web 瀏覽器或 Node.js。所有環境中的共同點是一個稱為事件迴圈的內建機制,它隨著時間的推移處理程式中多個模組的執行順序,並每次呼叫 JS 引擎。

所以,例如,當你的 JavaScript 程式發出一個 Ajax 請求來從伺服器獲取一些資料時,你在一個回撥函式中寫好了 “響應” 程式碼,JS 引擎將會告訴宿主環境:

“嘿,我現在暫停執行,但是每當你完成這個網路請求,並且你有一些資料,請呼叫這個函式並返回給我。

然後瀏覽器開始監聽來自網路的響應,當響應返回給你的時候,宿主環境會將回撥函式插入到事件迴圈中來安排回撥函式的執行順序。

我們來看下面的圖表:

JavaScript深入之事件迴圈機制(event loop)

我們都使用過 setTimeout、AJAX 這些 API, 但是,這些 API 不是由 JS 引擎提供的。那這些 Web APIs 到底是什麼? 從本質上講,它們是瀏覽器並行啟動的一部分,是你無法訪問的執行緒,你僅僅只可以呼叫它們。

前面說了瀏覽器核心是多執行緒,在核心控制下各執行緒相互配合以保持同步,一個瀏覽器通常由以下常駐執行緒組成:

  • GUI 渲染引擎執行緒:顧名思義,該執行緒負責頁面的渲染
  • JavaScript 引擎執行緒:負責 JS 的解析和執行
  • 定時觸發器執行緒:處理定時事件,比如setTimeout, setInterval
  • 事件觸發執行緒:處理DOM事件
  • 非同步 http 請求執行緒:處理http請求

上圖中看出,JavaScript 執行時,除了正在執行的主執行緒,還存在一個 callback queue(也叫task queue),即任務佇列,裡面是各種需要當前程式處理的非同步任務(實際上,根據非同步任務的型別,存在多個任務佇列)。

非同步執行的執行機制如下:

  1. 首先主執行緒(即 JavaScript 引擎)會在 call stack 中執行所有的同步任務。
  2. 當遇到非同步任務(如比如setTimeout、Ajax)時,則交由 Web APIs 相應的執行緒來處理,Web APIs這邊處理完畢後,會將相應的 callback 函式放入到任務佇列中。
  3. event loop 會不斷的監測 呼叫棧 和 任務佇列,當呼叫棧為空的時候,event loop 就會把任務佇列中的第一個事件取出推入到呼叫棧中。
  4. 執行渲染操作,更新介面
  5. 如此迴圈往復。

下面我們通過一個例子來看一下具體的執行過程。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');

複製程式碼

setTimeout 有個要注意的地方,如上述例子延遲 5s 執行,不是嚴格意義上的 5s,正確來說是至少 5s 以後會執行。因為 Web API 會設定一個 5s 的定時器,時間到期後將回撥函式加到佇列中,此時該回撥函式還不一定會馬上執行,因為佇列中可能還有之前加入的其他回撥函式,而且還必須等到 Call Stack 空了之後才會從佇列中取一個回撥執行。這也是很多人說 JavaScript 中的定時器其實不是完全精確的原因。

關於事件迴圈的詳細講解,推薦一個視訊《what the hack is event loop》

任務佇列

每個執行緒都有自己的事件迴圈,所以每個 web worker 有自己的事件迴圈(event loop),所以它能獨立地執行。一個事件迴圈有多個 task 來源,並且保證在 task 來源內的執行順序,在每次迴圈中瀏覽器要選擇從哪個來源中選取 task,任務源可以分為 微任務(microtask)巨集任務(macrotask),在ES6規範中,microtask 稱為 jobs, macrotask 稱為 task。

macrotask 主要包括下面幾個:

  • script 主程式
  • setTimeout
  • setInterval
  • setImmediate(Node)
  • I/O
  • UI互動事件

microtask 主要包含:

  • Promise
  • MutationObserver
  • process.nextTick (Node)

JavaScript深入之事件迴圈機制(event loop)

參考 whatwg規範中關於任務佇列的定義我們可以瞭解到:

  1. 每個事件迴圈都有一個微任務佇列(microtask queue)。
  2. 瀏覽器每次都是先執行最舊的 macrotask,也就是先加進巨集任務隊裡的那個 macrotask。
  3. 每次執行完一個 macrotask,就會檢查 microtask queue 裡面是否存在 microtask,如果有則不斷執⾏ microtask,在 microtasks 執行時還可以加入更多的 microtask,然後一個一個的執行,直到 microtask 佇列清空。
  4. 下一個迴圈,執行下一個 macrotask 中的任務,如此迴圈往復。

有點繞,我們下面先看一個例子來解釋一下:

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
複製程式碼

我們來分析一下上面程式碼的具體執行步驟。表格中紅色的表示當前正在執行的任務。

  1. 首先主執行緒執行同步程式碼,script 程式碼進入 call stack,當前正在執行的 macrotask 為 主script。
macrotasks microtasks call stack Log
script script script start
  1. 遇到 setTimeout 函式,將其回撥函式加入到 macrotasks 中
macrotasks microtasks call stack Log
script script script start
setTimeout callback
  1. 繼續往下執行,遇到 Promise,將已經resolved 的 Promise 回撥加入到 microtasks。
macrotasks microtasks call stack Log
script Promise then 1 script script start
setTimeout callback
  1. 繼續往下執行,輸出 log ‘script end’,此時當前的 macrotask 執行完畢,前面說到當每一次 macrotask 執行完畢後都會去檢查 microtask queue,此時開始處理 microtasks 中的任務。
macrotasks microtasks call stack Log
script Promise then 1 Promise callback 1 script start
setTimeout callback script end
  1. 檢查 microtasks 發現有一個 microtask,開始執行 Promise then 1 的回撥,輸出log。
macrotasks microtasks call stack Log
script Promise then 1 Promise callback 1 script start
setTimeout callback script end
promise1
  1. 發現該回撥還有一個 then 函式的回撥,再把它(暫且稱之為 Promise then 2)也放入到 microtasks 中,此時 Promise then 1 這個 microtask 執行完畢,被移除。此時 macrotasks 還未清空,因此要繼續執行 microtasks, 輸出log。
macrotasks microtasks call stack Log
script Promise then 2 Promise callback 2 script start
setTimeout callback script end
promise1
promise2
  1. 此時,主 script 這個 macrotask 執行完畢,開始執行下一個 macrotask,也就是 setTimeout callback,輸出log,而 microtask queue 被清空。
macrotasks microtasks call stack Log
setTimeout callback setTimeout callback script start
script end
promise1
promise2
setTimeout
  1. setTimeout callback 這個 macrotask 執行完畢,此時檢查 microtask queue 中沒有任務,並且 macrotask queue 中也沒有任務了,本次事件迴圈結束,call stack 清空。

相關文章