JS 事件迴圈(Event Loop)

時傾發表於2023-01-30

轉自:面試必問之 JS 事件迴圈(Event Loop),看這一篇足夠!

理解 JavaScript 的事件迴圈往往伴隨著宏任務和微任務、JavaScript 單執行緒執行過程及瀏覽器非同步機制等相關問題,而瀏覽器和 NodeJS 中的事件迴圈實現也是有很大差別。熟悉事件迴圈,瞭解瀏覽器執行機制將對我們理解 JavaScript 的執行過程,以及在排查程式碼執行問題時有很大幫助。

本文將在瀏覽器非同步執行原理和事件驅動的理解基礎上,詳細介紹 JavaScript 的事件迴圈機制以及在瀏覽器和 NodeJS 中的不同表現。

瀏覽器 JS 非同步執行的原理

JS 是單執行緒的,也就是同一個時刻只能做一件事情,那麼思考:為什麼瀏覽器可以同時執行非同步任務呢?

因為瀏覽器是多執行緒的,當 JS 需要執行非同步任務時,瀏覽器會另外啟動一個執行緒去執行該任務。也就是說,“JS 是單執行緒的”指的是執行 JS 程式碼的執行緒只有一個,是瀏覽器提供的 JS 引擎執行緒(主執行緒)。瀏覽器中還有定時器執行緒和 HTTP 請求執行緒等,這些執行緒主要不是來跑 JS 程式碼的。

比如主執行緒中需要發一個 AJAX 請求,就把這個任務交給另一個瀏覽器執行緒(HTTP 請求執行緒)去真正傳送請求,待請求回來了,再將 callback 裡需要執行的 JS 回撥交給 JS 引擎執行緒去執行。即瀏覽器才是真正執行傳送請求這個任務的角色,而 JS 只是負責執行最後的回撥處理。所以這裡的非同步不是 JS 自身實現的,其實是瀏覽器為其提供的能力。

以 Chrome 為例,瀏覽器不僅有多個執行緒,還有多個程式,如渲染程式、GPU 程式和外掛程式等。而每個 tab 標籤頁都是一個獨立的渲染程式,所以一個 tab 異常崩潰後,其他 tab 基本不會被影響。作為前端開發者,主要重點關注其渲染程式,渲染程式下包含了 JS 引擎執行緒、HTTP 請求執行緒和定時器執行緒等,這些執行緒為 JS 在瀏覽器中完成非同步任務提供了基礎。

事件驅動淺析

瀏覽器非同步任務的執行原理背後其實是一套事件驅動的機制。事件觸發、任務選擇和任務執行都是由事件驅動機制來完成的。NodeJS 和瀏覽器的設計都是基於事件驅動的,簡而言之就是由特定的事件來觸發特定的任務,這裡的事件可以是使用者的操作觸發的,如 click 事件;也可以是程式自動觸發的,比如瀏覽器中定時器執行緒在計時結束後會觸發定時器事件。而本文的主題內容事件迴圈其實就是在事件驅動模式中來管理和執行事件的一套流程。

以一個簡單場景為例,假設遊戲介面上有一個移動按鈕和人物模型,每次點選右移後,人物模型的位置需要重新渲染,右移 1 畫素。根據渲染時機的不同我們可以用不同的方式來實現:
實現方式一:事件驅動。點選按鈕後,修改座標 positionX 時,立即觸發介面渲染的事件,觸發重新渲染。
實現方式二:狀態驅動或資料驅動。點選按鈕後,只修改座標 positionX,不觸發介面渲染。在此之前會啟動一個定時器 setInterval,或者利用 requestAnimationFrame 來不斷地檢測 positionX 是否有變化。如果有變化,則立即重新渲染。

瀏覽器中的點選事件處理也是典型的基於事件驅動。在事件驅動中,當有事件觸發後,被觸發的事件會按順序暫時存在一個佇列中,待 JS 的同步任務執行完成後,會從這個佇列中取出要處理的事件並進行處理。那麼具體什麼時候取任務、優先取哪些任務,這就由事件迴圈流程來控制了。

瀏覽器中的事件迴圈

執行棧與任務佇列

JS 在解析一段程式碼時,會將同步程式碼按順序排在某個地方,即執行棧,然後依次執行裡面的函式。當遇到非同步任務時就交給其他執行緒處理,待當前執行棧所有同步程式碼執行完成後,會從一個佇列中去取出已完成的非同步任務的回撥加入執行棧繼續執行,遇到非同步任務時又交給其他執行緒,.....,如此迴圈往復。而其他非同步任務完成後,將回撥放入任務佇列中待執行棧來取出執行。

JS 按順序執行執行棧中的方法,每次執行一個方法時,會為這個方法生成獨有的執行環境(上下文 context),待這個方法執行完成後,銷燬當前的執行環境,並從棧中彈出此方法(即消費完成),然後繼續下一個方法。

可見,在事件驅動的模式下,至少包含了一個執行迴圈來檢測任務佇列是否有新的任務。透過不斷迴圈去取出非同步回撥來執行,這個過程就是事件迴圈,而每一次迴圈就是一個事件週期或稱為一次 tick。

宏任務和微任務

任務佇列不只一個,根據任務的種類不同,可以分為微任務(micro task)佇列和宏任務(macro task)佇列。

事件迴圈的過程中,執行棧在同步程式碼執行完成後,優先檢查微任務佇列是否有任務需要執行,如果沒有,再去宏任務佇列檢查是否有任務執行,如此往復。微任務一般在當前迴圈就會優先執行,而宏任務會等到下一次迴圈,因此,微任務一般比宏任務先執行,並且微任務佇列只有一個,宏任務佇列可能有多個。另外我們常見的點選和鍵盤等事件也屬於宏任務。

常見宏任務:

  • setTimeout()
  • setInterval()
  • setImmediate()

常見微任務:

  • promise.then()、promise.catch()
  • new MutaionObserver()
  • process.nextTick()
console.log('同步程式碼1');
setTimeout(() => {
    console.log('setTimeout')
}, 0)
new Promise((resolve) => {
  console.log('同步程式碼2')
  resolve()
}).then(() => {
    console.log('promise.then')
})
console.log('同步程式碼3');

上面的程式碼將按如下順序輸出為:"同步程式碼 1"、"同步程式碼 2"、"同步程式碼 3"、"promise.then"、"setTimeout",具體分析如下:

  1. setTimeout 回撥和 promise.then 都是非同步執行的,將在所有同步程式碼之後執行;

    順便提一下,在瀏覽器中 setTimeout 的延時設定為 0 的話,會預設為 4ms,NodeJS 為 1ms。具體值可能不固定,但不是為 0。
  2. 雖然 promise.then 寫在後面,但是執行順序卻比 setTimeout 優先,因為它是微任務;
  3. new Promise 是同步執行的,promise.then 裡面的回撥才是非同步的。

也有人這樣去理解:微任務是在當前事件迴圈的尾部去執行;宏任務是在下一次事件迴圈的開始去執行。我們來看看微任務和宏任務的本質區別是什麼?

我們已經知道,JS 遇到非同步任務時會將此任務交給其他執行緒去處理,自己的主執行緒繼續往後執行同步任務。比如 setTimeout 的計時會由瀏覽器的定時器執行緒來處理,待計時結束,就將定時器回撥任務放入任務佇列等待主執行緒來取出執行。前面我們提到,因為 JS 是單執行緒執行的,所以要執行非同步任務,就需要瀏覽器其他執行緒來輔助,即多執行緒是 JS 非同步任務的一個明顯特徵。

我們再來分析下 promise.then(微任務)的處理。當執行到 promise.then 時,V8 引擎不會將非同步任務交給瀏覽器其他執行緒,而是將回撥存在自己的一個佇列中,待當前執行棧執行完成後,立馬去執行 promise.then 存放的佇列,promise.then 微任務沒有多執行緒參與,甚至從某些角度說,微任務都不能完全算是非同步,它只是將書寫時的程式碼修改了執行順序而已。

setTimeout 有“定時等待”這個任務,需要定時器執行緒執行;ajax 請求有“傳送請求”這個任務,需要 HTTP 執行緒執行,而 promise.then 它沒有任何非同步任務需要其他執行緒執行,它只有回撥,即使有,也只是內部巢狀的另一個宏任務。

簡單小結一下微任務和宏任務的本質區別:
宏任務特徵:有明確的非同步任務需要執行和回撥;需要其他非同步執行緒支援。
微任務特徵:沒有明確的非同步任務需要執行,只有回撥;不需要其他非同步執行緒支援。

定時器誤差

事件迴圈中,總是先執行同步程式碼後,才會去任務佇列中取出非同步回撥來執行。
當執行 setTimeout 時,瀏覽器啟動新的執行緒去計時,計時結束後觸發定時器事件將回撥存入宏任務佇列,等待 JS 主執行緒來取出執行。如果這時主執行緒還在執行同步任務的過程中,那麼此時的宏任務就只有先掛起,這就造成了計時器不準確的問題。同步程式碼耗時越長,計時器的誤差就越大。
不僅同步程式碼,由於微任務會優先執行,所以微任務也會影響計時,假設同步程式碼中有一個死迴圈或者微任務中遞迴不斷在啟動其他微任務,那麼宏任務裡面的程式碼可能永遠得不到執行。所以主執行緒程式碼的執行效率提升是一件很重要的事情。

檢視更新渲染

微任務佇列執行完成後,也就是一次事件迴圈結束後,瀏覽器會執行檢視渲染,當然這裡會有瀏覽器的最佳化,可能會合並多次迴圈的結果做一次檢視重繪,因此檢視更新是在事件迴圈之後,所以並不是每一次操作 Dom 都一定會立馬重新整理檢視。
檢視重繪之前會先執行 requestAnimationFrame 回撥,那麼對於 requestAnimationFrame 是微任務還是宏任務是有爭議的,在這裡看來,它應該既不屬於微任務,也不屬於宏任務。

NodeJS 中的事件迴圈

JS 引擎本身不實現事件迴圈機制,這是由它的宿主實現的,瀏覽器中的事件迴圈主要是由瀏覽器來實現,而在 NodeJS 中也有自己的事件迴圈實現。
NodeJS 中也是迴圈 + 任務佇列的流程以及微任務優先於宏任務,大致表現和瀏覽器是一致的,不過它與瀏覽器中也有一些差異,並且新增了一些任務型別和任務階段。
接下來我們介紹下 NodeJS 中的事件迴圈流程。

NodeJS 中的非同步方法

因為都是基於 V8 引擎,瀏覽器中包含的非同步方式在 NodeJS 中也是一樣的。另外 NodeJS 中還有一些其他常見非同步形式。

  • 檔案 I/O:非同步載入本地檔案。
  • setImmediate():與 setTimeout 設定 0ms 類似,在某些同步任務完成後立馬執行。
  • process.nextTick():在某些同步任務完成後立馬執行。
  • server.close、socket.on('close',...)等:關閉回撥。

如果上面的形式和 setTimeoutpromise 等同時存在,如何分析出程式碼的執行順序呢?只要我們理解了 NodeJS 的事件迴圈機制,就清楚了。

事件迴圈模型

NodeJS 的跨平臺能力和事件迴圈機制都是基於 Libuv 庫實現的,你不用關心這個庫的具體內容。我們只需要知道 Libuv 庫是事件驅動的,並且封裝和統一了不同平臺的 API 實現。

NodeJS 中 V8 引擎將 JS 程式碼解析後呼叫 Node API,然後 Node API 將任務交給 Libuv 去分配,最後再將執行結果返回給 V8 引擎。在 Libux 中實現了一套事件迴圈流程來管理這些任務的執行,所以 NodeJS 的事件迴圈主要是在 Libuv 中完成的。

事件迴圈各階段

在 NodeJS 中 JS 的執行,我們主要需要關心的過程分為以下幾個階段,下面每個階段都有自己單獨的任務佇列,當執行到對應階段時,就判斷當前階段的任務佇列是否有需要處理的任務。

  • timers 階段:執行所有 setTimeout() 和 setInterval() 的回撥。
  • pending callbacks 階段:某些系統操作的回撥,如 TCP 連結錯誤。除了 timers、close、setImmediate 的其他大部分回撥在此階段執行。
  • poll 階段:輪詢等待新的連結和請求等事件,執行 I/O 回撥等。V8 引擎將 JS 程式碼解析並傳入 Libuv 引擎後首先進入此階段。如果此階段任務佇列已經執行完了,則進入 check 階段執行 setImmediate 回撥(如果有 setImmediate),或等待新的任務進來(如果沒有 setImmediate)。在等待新的任務時,如果有 timers 計時到期,則會直接進入 timers 階段。此階段可能會阻塞等待。
  • check 階段:setImmediate 回撥函式執行。
  • close callbacks 階段:關閉回撥執行,如 socket.on('close', ...)。

上面每個階段都會去執行完當前階段的任務佇列,然後繼續執行當前階段的微任務佇列,只有當前階段所有微任務都執行完了,才會進入下個階段。這裡也是與瀏覽器中邏輯差異較大的地方,不過瀏覽器不用區分這些階段,也少了很多非同步操作型別,所以不用刻意去區分兩者區別。程式碼如下所示:

const fs = require('fs');
fs.readFile(__filename, (data) => {
    // poll(I/O 回撥) 階段
    console.log('readFile')
    Promise.resolve().then(() => {
        console.error('promise1')
    })
    Promise.resolve().then(() => {
        console.error('promise2')
    })
});
setTimeout(() => {
    // timers 階段
    console.log('timeout');
    Promise.resolve().then(() => {
        console.error('promise3')
    })
    Promise.resolve().then(() => {
        console.error('promise4')
    })
}, 0);
// 下面程式碼只是為了同步阻塞1秒鐘,確保上面的非同步任務已經準備好了
var startTime = new Date().getTime();
var endTime = startTime;
while(endTime - startTime < 1000) {
    endTime = new Date().getTime();
}
// 最終輸出 timeout promise3 promise4 readFile promise1 promise2

另一個與瀏覽器的差異還體現在同一個階段裡的不同任務執行,在 timers 階段裡面的宏任務、微任務測試程式碼如下所示:

setTimeout(() => {
  console.log('timeout1')
    Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0);
setTimeout(() => {
  console.log('timeout2')
    Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0);
  • 瀏覽器中執行
    每次宏任務完成後都會優先處理微任務,輸出“timeout1”、“promise1”、“timeout2”、“promise2”。
  • NodeJS 中執行
    因為輸出 timeout1 時,當前正處於 timers 階段,所以會先將所有 timer 回撥執行完之後再執行微任務佇列,即輸出“timeout1”、“timeout2”、“promise1”、“promise2”。

上面的差異可以用瀏覽器和 NodeJS 10 對比驗證。是不是感覺有點反程式設計師?因此 NodeJS 在版本 11 之後,就修改了此處邏輯使其與瀏覽器儘量一致,也就是每個 timer 執行後都先去檢查一下微任務佇列,所以 NodeJS 11 之後的輸出已經和瀏覽器一致了。

nextTick、setImmediate 和 setTimeout

實際專案中我們常用 Promise 或者 setTimeout 來做一些需要延時的任務,比如一些耗時計算或者日誌上傳等,目的是不希望它的執行佔用主執行緒的時間或者需要依賴整個同步程式碼執行完成後的結果。

NodeJS 中的 process.nextTick()setImmediate() 也有類似效果。其中 setImmediate() 我們前面已經講了是在 check 階段執行的,而 process.nextTick() 的執行時機不太一樣,它比 promise.then() 的執行還早,在同步任務之後,其他所有非同步任務之前,會優先執行 nextTick。可以想象是把 nextTick 的任務放到了當前迴圈的後面,與 promise.then() 類似,但比 promise.then() 更前面。意思就是在當前同步程式碼執行完成後,不管其他非同步任務,先儘快執行nextTick

etTimeout(() => {
    console.log('timeout');
}, 0);
Promise.resolve().then(() => {
    console.error('promise')
})
process.nextTick(() => {
    console.error('nextTick')
})
// 輸出:nextTick、promise、timeout

接下來我們再來看看 setImmediatesetTimeout,它們是屬於不同的執行階段了,分別是 timers 階段和 check 階段。

setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('setImmediate');
});
// 輸出:timeout、 setImmediate

分析上面程式碼,第一輪迴圈後,分別將 setTimeoutsetImmediate 加入了各自階段的任務佇列。第二輪迴圈首先進入 timers 階段,執行定時器佇列回撥,然後 pending callbackspoll 階段沒有任務,因此進入 check 階段執行 setImmediate 回撥。所以最後輸出為“timeout”、“setImmediate”。當然這裡還有種理論上的極端情況,就是第一輪迴圈結束後耗時很短,導致 setTimeout 的計時還沒結束,此時第二輪迴圈則會先執行 setImmediate 回撥。

再看這下面一段程式碼,它只是把上一段程式碼放在了一個 I/O 任務回撥中,它的輸出將與上一段程式碼相反。

const fs = require('fs');
fs.readFile(__filename, (data) => {
    console.log('readFile');
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('setImmediate');
    });
});
// 輸出:readFile、setImmediate、timeout

如上面程式碼所示:

  • 第一輪迴圈沒有需要執行的非同步任務佇列;
  • 第二輪迴圈 timers 等階段都沒有任務,只有 poll 階段有 I/O 回撥任務,即輸出“readFile”;
  • 參考前面事件階段的說明,接下來,poll 階段會檢測如果有 setImmediate 的任務佇列則進入 check 階段,否則再進行判斷,如果有定時器任務回撥,則回到 timers 階段,所以應該進入 check 階段執行 setImmediate,輸出“setImmediate”;
  • 然後進入最後的 close callbacks 階段,本次迴圈結束;
  • 最後進行第三輪迴圈,進入 timers 階段,輸出“timeout”。

所以最終輸出“setImmediate”在“timeout”之前。可見這兩者的執行順序與當前執行的階段有關係。

總結

本文詳細講解了瀏覽器和 NodeJS 中事件迴圈的流程,雖然底層機制不一樣,但在最終表現上是基本一致的。理解事件迴圈的原理,可以幫助我們準確分析和運用各種非同步形式,減少程式碼的不確定性,在一些執行效率最佳化上也能有明確的思路。

相關文章