[基礎] 淺談 JS Event Loop

不寫bug的米公子發表於2019-03-09

面試季臨近,Event Loop 這個概念也開始熱了,部落格上到處都在寫,面試到處都在問,於是我也藉此機會查閱了一些相關資料彌補自己的知識盲區,把自己學習完之後對於瀏覽器的 Event Loop 寫一篇個人總結,有理解不對之處歡迎大佬指正~

這篇暫不做 Node 環境下的 Event Loop 的討論

JavaScript 裡的棧和佇列

在說 Event Loop 之前,我們要先理解棧(stack)和佇列(queue)的概念。

棧和佇列,兩者都是線性結構,但是棧遵循的是後進先出(last in first off,LIFO),開口封底。而佇列遵循的是先進先出 (fisrt in first out,FIFO),兩頭通透。

[基礎] 淺談 JS Event Loop

Event Loop得以順利執行,它所依賴的容器環境,就和這兩個概念有關。

我們知道,在 js 程式碼執行過程中,會生成一個當前環境的“執行上下文執行環境 / 作用域)”,用於存放當前環境中的變數,這個上下文環境被生成以後,就會被推入js的執行棧。一旦執行完成,那麼這個執行上下文就會被執行棧彈出,裡面相關的變數會被銷燬,在下一輪垃圾收集到來的時候,環境裡的變數佔據的記憶體就能得以釋放。

這個執行棧,也可以理解為JavaScript的單一執行緒,所有程式碼都跑在這個裡面,以同步的方式依次執行,或者阻塞,這就是同步場景。

那麼非同步場景呢?顯然就需要一個獨立於“執行棧”之外的容器,專門管理這些非同步的狀態,於是在“主執行緒”、“執行棧”之外,有了一個 Task 的佇列結構,專門用於管理非同步邏輯。所有非同步操作的回撥,都會暫時被塞入這個佇列。Event Loop 處在兩者之間,扮演一個大管家的角色,它會以一個固定的時間間隔不斷輪詢,當它發現主執行緒空閒,就會去到 Task 佇列裡拿一個非同步回撥,把它塞入執行棧中執行,一段時間後,主執行緒執行完成,彈出上下文環境,再次空閒,Event Loop 又會執行同樣的操作。。。依次迴圈,於是構成了一套完整的事件迴圈執行機制。

[基礎] 淺談 JS Event Loop

上圖是筆者在 Google 上找的,比較簡潔地描繪了整個過程,只不過其中多了 heap (堆)的概念,堆和棧,簡單來說,堆是留給開發者分配的記憶體空間,而棧是原生編譯器要使用的記憶體空間,二者獨立。

microtask 和 macrotask

如果只想應付普通點的面試,上面一節的內容就足夠了,但是想要答出下面的這條面試題,就必須再次深入 Event Loop ,瞭解任務佇列的深層原理:microtask(微任務)和 macrotask(巨集任務)。

面試題

// 請給出下面這段程式碼執行後,log 的列印順序
console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end')
}
async1()

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

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })

console.log('script end')

// log 列印順序:script start -> async2 end -> Promise -> script end -> promise1 -> promise2 -> async1 end -> setTimeout
複製程式碼

如果只有一個單一的 Task 佇列,就不存在上面的順序問題了。但事實情況是,瀏覽器會根據任務性質的不同,將不同的任務源塞入不同的佇列中,任務源可以分為微任務microtask) 和巨集任務macrotask),介於瀏覽器對兩種不同任務源佇列中回撥函式的讀取機制,造成了上述程式碼中的執行順序問題。

[基礎] 淺談 JS Event Loop

上圖摘自《掘金小冊:前端面試之道》

過程解析

讓我們首先來分析一下上述程式碼的執行流程:

  1. JavaScript 解析引擎在指令碼開頭碰到了 console.log 於是列印 script strt
  2. 解析引擎解析至 async1()async1 執行環境被推入執行棧,解析引擎進入 async1 內部
  3. 引擎發現 async1 內部呼叫了 async2,於是繼續進入 async 2,並將 async 2 執行環境推入執行棧
  4. 引擎碰到 console.log,於是列印 async2 end
  5. async2 函式執行完成,返回了一個 Promise.resolve(undefined)此時,該回撥被推入 microtask async1 函式中的執行權被讓出,等待主執行緒空閒
  6. 引擎解析至 setTimeout等待 0ms 後將其回撥推入 macrotask,執行權繼續讓出
  7. 引擎指標繼續下移,直到碰到了 Promise,解析進入注入函式的內部,碰到 console.log,於是列印 Promise,再往下,碰到了 resolve此時,該回撥被推入 microtask ,執行權被讓出
  8. 引擎繼續往下,碰到 console.log,列印完 script end
  9. 至此,主執行緒空閒,Event Loop 事件迴圈啟動,開始從 microtask 裡拿出 promise 回撥,放入主執行緒執行,首先拿出最早注入的 async2Promise.resolve(undefined)執行,此時 await 操作符解析該表示式,得到結果 undefined,並將 async1 [Promise] 函式 標誌為 resolve 狀態,將 await 後面的程式碼作為回撥,繼續推入 microtask,等待執行,執行權被讓出
  10. 此時主執行緒沒有可執行的程式碼,再次空閒,Event Loop 啟動,去 microtask 中拿到之前的 new Promise 回撥,放入主執行緒執行,列印結果 promise1promise2
  11. 主執行緒空閒,Event Loopmicrotask 裡拿 aysnc1 的回撥,列印出 async1 end
  12. 最後,主執行緒空閒,microtask 佇列空,Event Loopmacrotask 裡拿到 setTimeout 的回撥,放入主執行緒,列印最後的 setTimeout

常見的微任務和巨集任務

微任務包括 process.nextTickpromiseMutationObserver,其中 process.nextTick 為 Node 獨有。

巨集任務包括 scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering

關於 asyncawait

上述的面試題裡,大部分邏輯解釋下來都很好懂,除了一處,就是 await 後的 console.log(async1 end)new Promise resolve 後的回撥,到底哪個先執行?由於瀏覽器底層的解析引擎實現不同,對於不同的瀏覽器其結果可能不一樣(最新版的 chrome 瀏覽器對於 await 的處理變快了,async1 會先於 promise 1 列印)。

但是相比於這個執行順序,上述題目衍生出的一個更重要的問題,是對於 async/await 的理解。

對於 async/await 的更詳細解釋,大家可以參照這篇 理解 JavaScript 的 async/await,懶得看的童鞋可以看下面的結論:

  1. 一個函式,只要被 async 關鍵字包裝過,就會返回一個 promise,如果該函式有返回值,那麼這個返回值就會作為 then 處理的 response ,如果沒有返回值,那麼 then 就處理 undefined
  2. await 表示式,只能用在被 async 包裝過的函式裡,否則會報錯
  3. await 表示式後接的函式返回值,型別可以為 promise,或者其他任何的值,await 後的程式碼在當前執行環境下,會被阻塞至拿到該函式呼叫後的結果,等拿到結果後,會將 await 後面的程式碼繼續包裝成新的 promise,並將之前拿到的結果作為 response 傳入其中,同時讓出執行緒控權
  4. async/await 本質上是 Generator 的語法糖

巨集任務與微任務,哪個先執行?

關於這個問題,眾說紛紜,很多大佬都說是巨集任務先於微任務執行,但是程式碼的執行結果卻顯示是微任務先執行。 先看看大佬們的解釋:

這裡很多人會有個誤區,認為微任務快於巨集任務,其實是錯誤的。因為巨集任務中包括了 script ,瀏覽器會先執行一個巨集任務,接下來有非同步程式碼的話才會先執行微任務。 《掘金小冊:前端面試之道》

也就是說,Event Loop 抓取回撥的邏輯是先執行巨集任務,再執行微任務,再執行巨集任務。。。以此迴圈,本質上來說,當前執行棧裡的程式碼都屬於巨集任務,於是等待執行棧清空,巨集任務執行完成,瀏覽器回去 microtask 裡抓取微任務來執行,除非 microtask 裡沒有,才會去 macrotask 抓取任務執行。

相關文章