宏任務與微任務是什麼?Promise和setTimeout的差別
前言
在Javascript中提到異步(非同步),腦海裡一定立刻浮現setTimeout
、Promise
、async/await
等等的名詞;然而近期在學習V8、瀏覽器的相關知識時,才發現上述三者的雖然宏觀上是異步、但微觀上其實不太一樣。
任務隊列和事件循環(MaroTask Queue / Event Loop)
在一段Javascript程式碼的調用,V8會順序把任務push到MaroTask Queue中,Event Loop會不斷循環監聽是否為空,如果不為空,會遵循MaroTask Queue 的FIFO(先進先出)原則,把任務取出放入 main thread 去執行。
(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引擎下的執行順序為:
- foo 函數在MaroTask Queue中等待被執行
- MaroTask Queue前面的宏任務被執行完,foo進入Event Loop,被push 至 call stack
- V8在執行 foo 函數,創建執行上下文。執行到
setTimeout
時,把裡面的callback重新封裝成宏任務、再push 到MaroTask Queue中(其中其實省略了DelayedIncomingQueue
的部分,簡單來說就是有另一個queue是處理定時器任務、時間到了再把任務push 到 MaroTask Queue) - 等到前面的宏任務執行完後,才執行該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
- 極客時間:《瀏覽器原理與實踐》、《圖解Google V8》