深入理解 JavaScript 事件循環

今天來看看 JavaScript 事件循環的原理~

1. 異步執行原理

(1)單線程的 JavaScript

我們知道,JavaScript 是一種單線程語言,它主要用來與用戶互動,以及操作 DOM。

JavaScript 有同步和異步的概念,這就解決了代碼阻塞的問題:

那單線程有什麼好處呢?

(2)多線程的瀏覽器

JS 是單線程的,在同一個時間只能做一件事情,那爲什麼瀏覽器可以同時執行異步任務呢?

這是因爲瀏覽器是多線程的,當 JS 需要執行異步任務時,瀏覽器會另外啓動一個線程去執行該任務。也就是說,JavaScript 是單線程的指的是執行 JavaScript 代碼的線程只有一個,是瀏覽器提供的 JavaScript 引擎線程(主線程)。除此之外,瀏覽器中還有定時器線程、 HTTP 請求線程等線程,這些線程主要不是來執行 JS 代碼的。

比如主線程中需要發送數據請求,就會把這個任務交給異步 HTTP 請求線程去執行,等請求數據返回之後,再將 callback 裏需要執行的 JS 回調交給 JS 引擎線程去執行。也就是說,瀏覽器纔是真正執行發送請求這個任務的角色,而 JS 只是負責執行最後的回調處理。所以這裏的異步不是 JS 自身實現的,而是瀏覽器爲其提供的能力。

下圖是 Chrome 瀏覽器的架構圖:

可以看到,Chrome 不僅擁有多個進程,還有多個線程。以渲染進程爲例,就包含 GUI 渲染線程、JS 引擎線程、事件觸發線程、定時器觸發線程、異步 HTTP 請求線程。這些線程爲 JS 在瀏覽器中完成異步任務提供了基礎。

2. 瀏覽器的事件循環

JavaScript 的任務分爲兩種同步異步

上面提到了任務隊列和執行棧,下面就先來看看這兩個概念。

(1)執行棧與任務隊列

1)執行棧:從名字可以看出,執行棧使用到的是數據結構中的棧結構, 它是一個存儲函數調用的棧結構,遵循先進後出的原則。它主要負責跟蹤所有要執行的代碼。 每當一個函數執行完成時,就會從堆棧中彈出(pop)該執行完成函數;如果有代碼需要進去執行的話,就進行 push 操作。以下圖爲例:

當執行這段代碼時,首先會執行一個 main 函數,然後執行我們的代碼。根據先進後出的原則,後執行的函數會先彈出棧,在圖中也可以發現,foo 函數後執行,當執行完畢後就從棧中彈出了。

JavaScript 在按順序執行執行棧中的方法時,每次執行一個方法,都會爲它生成獨有的執行環境(上下文),當這個方法執行完成後,就會銷燬當前的執行環境,並從棧中彈出此方法,然後繼續執行下一個方法。

2)任務隊列: 從名字中可以看出,任務隊列使用到的是數據結構中的隊列結構,它用來保存異步任務,遵循先進先出的原則。它主要負責將新的任務發送到隊列中進行處理。

JavaScript 在執行代碼時,會將同步的代碼按照順序排在執行棧中,然後依次執行裏面的函數。當遇到異步任務時,就將其放入任務隊列中,等待當前執行棧所有同步代碼執行完成之後,就會從異步任務隊列中取出已完成的異步任務的回調並將其放入執行棧中繼續執行,如此循環往復,直到執行完所有任務。

JavaScript 任務的執行順序如下:

在事件驅動的模式下,至少包含了一個執行循環來檢測任務隊列中是否有新任務。通過不斷循環,去取出異步任務的回調來執行,這個過程就是事件循環,每一次循環就是一個事件週期。

(2)宏任務和微任務

任務隊列其實不止一種,根據任務種類的不同,可以分爲微任務(micro task)隊列宏任務(macro task)隊列。常見的任務如下:

任務隊列執行順序如下:

可以看到,Eventloop 在處理宏任務和微任務的邏輯時的執行情況如下:

  1. JavaScript 引擎首先從宏任務隊列中取出第一個任務;

  2. 執行完畢後,再將微任務中的所有任務取出,按照順序分別全部執行(這裏包括不僅指開始執行時隊列裏的微任務),如果在這一步過程中產生新的微任務,也需要執行,也就是說在執行微任務過程中產生的新的微任務並不會推遲到下一個循環中執行,而是在當前的循環中繼續執行。

  3. 然後再從宏任務隊列中取下一個,執行完畢後,再次將 microtask queue 中的全部取出,循環往復,直到兩個 queue 中的任務都取完。

也是就是說,一次 Eventloop 循環會處理一個宏任務和所有這次循環中產生的微任務。

下面通過一個例子來體會事件循環:

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. 遇到第一個 console,它是同步代碼,加入執行棧,執行並出棧,打印出 "同步代碼 1";

  2. 遇到 setTimeout,它是一個宏任務,加入宏任務隊列;

  3. 遇到 new Promise 中的 console,它是同步代碼,加入執行棧,執行並出棧,打印出 "同步代碼 2";

  4. 遇到 Promise then,它是一個微任務,加入微任務隊列;

  5. 遇到第三個 console,它是同步代碼,加入執行棧,執行並出棧,打印出 "同步代碼 3";

  6. 此時執行棧爲空,去執行微任務隊列中所有任務,打印出 "promise.then";

  7. 執行完微任務隊列中的任務,就去執行宏任務隊列中的一個任務,打印出 "setTimeout"

注:更多異步代碼執行示例,詳見文章:  《「2021」高頻前端面試題彙總之代碼輸出結果篇》

從上面的宏任務和微任務的工作流程中,可以得出以下結論:

那麼問題來了,爲什麼要將任務隊列分爲微任務和宏任務呢,他們之間的本質區別是什麼呢?

JavaScript 在遇到異步任務時,會將此任務交給其他線程來執行(比如遇到 setTimeout 任務,會交給定時器觸發線程去執行,待計時結束,就會將定時器回調任務放入任務隊列等待主線程來取出執行),主線程會繼續執行後面的同步任務。

對於微任務,比如 promise.then,當執行 promise.then 時,瀏覽器引擎不會將異步任務交給其他瀏覽器的線程去執行,而是將任務回調存在一個隊列中,當執行棧中的任務執行完之後,就去執行 promise.then 所在的微任務隊列。

所以,宏任務和微任務的本質區別如下:

3. Node.js 的事件循環

(1)事件循環的概念

對於 Node.js 的事件循環,官網的描述如下:

When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

翻譯一下就是:當 Node.js 啓動時,它會初始化一個事件循環,來處理輸入的腳本,這個腳本可能進行異步 API 的調用、調度計時器或調用 process.nextTick(),然後開始處理事件循環。

JavaScript 和 Node.js 是基於 V8 引擎的,瀏覽器中包含的異步方式在 NodeJS 中也是一樣的。除此之外,Node.js 中還有一些其他的異步形式:

這些異步任務的執行就需要依靠 Node.js 的事件循環機制了。

Node.js 中的 Event Loop 和瀏覽器中的是完全不相同的東西。Node.js 使用 V8 作爲 js 的解析引擎,而 I/O 處理方面使用了自己設計的 libuv,libuv 是一個基於事件驅動的跨平臺抽象層,封裝了不同操作系統一些底層特性,對外提供統一的 API,事件循環機制也是它裏面的實現的,如下圖所示:根據上圖,可以看到 Node.js 的運行機制如下:

  1. V8 引擎負責解析 JavaScript 腳本;

  2. 解析後的代碼,調用 Node API;

  3. libuv 庫負責 Node API 的執行。它將不同的任務分配給不同的線程,形成一個 Event Loop(事件循環),以異步的方式將任務的執行結果返回給 V8 引擎;

  4. V8 引擎將結果返回給用戶;

(2)事件循環的流程

其中 libuv 引擎中的事件循環分爲 6 個階段,它們會按照順序反覆運行。每當進入某一個階段的時候,都會從對應的回調隊列中取出函數去執行。當隊列爲空或者執行的回調函數數量到達系統設定的閾值,就會進入下一階段。下面 是 Eventloop 事件循環的流程:

事件循環 - 第 2 頁. png

整個流程分爲六個階段,當這六個階段執行完一次之後,纔可以算得上執行了一次 Eventloop 的循環過程。下面來看下這六個階段都做了哪些事:

  1. timers 階段:執行 timer(setTimeout、setInterval)的回調,由 poll 階段控制;

  2. I/O callbacks 階段:主要執行系統級別的回調函數,比如 TCP 連接失敗的回調;

  3. idle, prepare 階段:僅 Node.js 內部使用,可以忽略;

  4. poll 階段:輪詢等待新的鏈接和請求等事件,執行 I/O 回調等;

  5. check 階段:執行 setImmediate() 的回調;

  6. close callbacks 階段:執行關閉請求的回調函數,比如 socket.on('close', ...)

注意:上面每個階段都會去執行完當前階段的任務隊列,然後繼續執行當前階段的微任務隊列,只有當前階段所有微任務都執行完了,纔會進入下個階段,這裏也是與瀏覽器中邏輯差異較大的地方。

其中,這裏面比較重要的就是第四階段:poll,這一階段中,系統主要做兩件事:

在進入該階段時如果沒有設定了 timer 的話,會出現以下情況:

(1)如果 poll 隊列不爲空,會遍歷回調隊列並同步執行,直到隊列爲空或者達到系統限制;

(2)如果 poll 隊列爲空時,會出現以下情況:

當設定了 timer 且 poll 隊列爲空,則會判斷是否有 timer 超時,如果有的就會回到 timer 階段執行回調。

這一過程的具體執行流程如下圖所示:

(3)宏任務和微任務

Node.js 事件循環的異步隊列也分爲兩種:宏任務隊列和微任務隊列。

(4)process.nextTick()

上面提到了 process.nextTick(),它是 node 中新引入的一個任務隊列,它會在上述各個階段結束時,在進入下一個階段之前立即執行。

Node.js 官方文檔的解釋如下:

process.nextTick()is not technically part of the event loop. Instead, thenextTickQueuewill be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.

例如下面的代碼:

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

Promise.resolve().then(() ={
    console.error('promise')
})

process.nextTick(() ={
    console.error('nextTick')
})

輸出結果如下:

nextTick
promise
timeout

可以看到,process.nextTick() 是優先於 promise 的回調執行。

(5)setImmediate 和 setTimeout

上面還提到了 setImmediate 和 setTimeout,這兩者很相似,主要區別在於調用時機的不同:

例如下面的代碼:

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

setImmediate(() ={
  console.log('setImmediate');
});

輸出結果如下:

timeout
setImmediate

在上面代碼的執行過程中,第一輪循環後,分別將 setTimeout  和 setImmediate 加入了各自階段的任務隊列。第二輪循環首先進入 timers 階段,執行定時器隊列回調,然後 pending callbackspoll 階段沒有任務,因此進入 check 階段執行 setImmediate 回調。所以最後輸出爲 timeout、setImmediate。

4. Node 與瀏覽器 Event Loop 差異

Node.js 與瀏覽器的 Event Loop 差異如下:

Nodejs 和瀏覽器的事件循環流程對比如下:

  1. 執行全局的 Script 代碼(與瀏覽器無差);

  2. 把微任務隊列清空:注意,Node 清空微任務隊列的手法比較特別。在瀏覽器中,我們只有一個微任務隊列需要接受處理;但在 Node 中,有兩類微任務隊列:next-tick 隊列和其它隊列。其中這個 next-tick 隊列,專門用來收斂 process.nextTick 派發的異步任務。在清空隊列時,優先清空 next-tick 隊列中的任務,隨後纔會清空其它微任務

  3. 開始執行 macro-task(宏任務)。注意,Node 執行宏任務的方式與瀏覽器不同:在瀏覽器中,我們每次出隊並執行一個宏任務;而在 Node 中,我們每次會嘗試清空當前階段對應宏任務隊列裏的所有任務(除非達到系統限制);

  4. 步驟 3 開始,會進入 3 -> 2 -> 3 -> 2… 的循環。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/PTP1UEpqouAfwwqQdPZ_Dg