[Frontend筆記]EventLoop中的宏任務與微任務是什麼

利用宏任務與微任務,充分了解Javascript中的異步

Posted by 李定宇 on Monday, October 31, 2022

宏任務與微任務是什麼?Promise和setTimeout的差別

前言

在Javascript中提到異步(非同步),腦海裡一定立刻浮現setTimeoutPromiseasync/await等等的名詞;然而近期在學習V8、瀏覽器的相關知識時,才發現上述三者的雖然宏觀上是異步、但微觀上其實不太一樣。

任務隊列和事件循環(MaroTask Queue / Event Loop)

在一段Javascript程式碼的調用,V8會順序把任務push到MaroTask Queue中,Event Loop會不斷循環監聽是否為空,如果不為空,會遵循MaroTask Queue 的FIFO(先進先出)原則,把任務取出放入 main thread 去執行。

image

(image from: https://javascript.info/event-loop)

而在MaroTask Queue中的任務,稱為宏任務(Maro Task)

setTimeout的時機

setTimeout的用法是,在一段時間後把callback push 到 MaroTask Queue 裡。假設有一函數:

function foo(){
	setTimeout(()=>{
		console.log("setTimeout task")
	},1000)
}

在V8引擎下的執行順序為:

  1. foo 函數在MaroTask Queue中等待被執行
  2. MaroTask Queue前面的宏任務被執行完,foo進入Event Loop,被push 至 call stack
  3. V8在執行 foo 函數,創建執行上下文。執行到setTimeout時,把裡面的callback重新封裝成宏任務、再push 到MaroTask Queue中(其中其實省略了DelayedIncomingQueue的部分,簡單來說就是有另一個queue是處理定時器任務、時間到了再把任務push 到 MaroTask Queue)
  4. 等到前面的宏任務執行完後,才執行該callback

利用setTimeout解決stack overflow

在很多面試考題的解法可以看到,利用setTimeout來解決 stack overflow的問題,其原理就是把原本要入棧的script直接改為宏任務來重新執行。

如:

  • 這個寫法會造成stack overflow

    function runStack (n) {
    if (n === 0) return 100;
    return runStack( n- 2);
    }
    runStack(50000)
    
  • 但利用setTimeout讓任務不入棧,便解決了stack overflow

    function runStack (n) {
    if (n === 0) return 100;
    return setTimeout(()=>{runStack( n- 2)},0);
    }
    runStack(50000)
    

但其實這是偏治標不治本的寫法,把原本的同步任務寫成異步,很容易會有效能的問題,因為干擾到了之後的宏任務的執行

setTimeout所設定的時間,跟實際執行的時間不太一樣

這其實也是因為,V8在setTimeout所設定的延遲時間過後、才把callback包成宏任務push到 MaroTask Queue裡;而假如MaroTask Queue前面的宏任務用時也很長,那麼就會影響到 setTimeout callback的執行時機。

Promise的執行時機

上面也談到,因為宏任務的顆粒度太大、會不太好掌握執行時機;而如果是要即時響應的任務,使用宏任務就不太適合。

因此,在 MaroTask Queue 和宏任務的基礎上,引入微任務(micro task)。V8在執行一段Javascript時,會為其創建全局執行上下文(Global Execution Context),而在這同時,V8引擎也會在內部創建一個微任務隊列(Micro Task Queue);換句話說,每個宏任務都會有一個微任務隊列。

在現代瀏覽器當中,產生微任務有兩個方式:

  • 使用MutationObserver 監控某個DOM節點,用Javascript修改這個DOM的任何行為
  • Promise中的resolve、reject回調

而為什麼Promise會跟微任務有關係呢?這就要說到為甚麼要有Promise。在原生的XMLHttpRequest中,如果要在請求一個api之後、再請求另一個api、然後業務邏輯處理、然後各個請求都還要異常處理,這樣很容易導致callback hell,整個程式碼會很難以閱讀;而Promise透過延遲綁定callback function、callback function 的返回值穿透和異常冒泡技術來解決以上的問題。(之後還要再寫一篇《手寫Promise》)

其中,Promise透過延遲綁定callback function就是透過微任務來解決的。

那麼,一個XMLHttpRequest利用Promise封裝以後,會怎麼執行呢?

  • Promise的executor函数中,調用 XMLHttpRequest 時觸發宏任務。
  • XMLHttpRequest 發起的請求,由Network Process執行,然後再將執行結果利用IPC的方式回傳給 Render Process、Render Process再把其push 到 Matro Queue中
  • 如果 XMLHttpRequest 的請求成功了,在該宏任務中觸發 resolve 微任務;如果請求失敗了,觸發 reject 微任務

Async/Await

至於async/await是如何將異步Promise寫成同步的寫法?這就必須講到協程(coroutine)。在一個線程(thread)上可以有多個協程(coroutine),但一次只能有一個協程拿到main thread的控制權。(像Javascript中的生成器就是依賴協程來實現的。)

那麼,async/await是如何使用協程的呢?以下列程式碼為例:


async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)
  • 首先執行console.log(0)
  • 處理foo函數。Javascript在處理async函數時,會先紀錄下該async函數的call stack等,執行 console.log(1)
  • 遇到await關鍵字後,會默認創建一個Promise對象

    let promise_ = new Promise((resolve,reject){
    resolve(100)
    })
    
  • 之後,Javascript會暫停當前協程等執行,把主線程控制權交給父協程執行,同時將該Promise對象返回給父協程

  • 父協程先處理console.log(3)

  • 然後檢查微任務隊列,有resolve(100)。執行完畢後再激活foo協程

  • 激活foo協程後,把剛剛的value返回給a,執行console.log(a)和console.log(2)

結論

可以看出,如果用宏任務與微任務的視角來解析異步任務,其實就可以很清晰地把任務順序給排序,在實際應用場景也可以心中有數。

ChangeLog

  • 20221031 - 初稿

Ref