"Tasks, microtasks, queues and schedules" 筆記
在這個講解 JavaScript Event Loop 的 talk 中,很清楚地說明了 Event Loop 是什麼,也讓我對非同步和 Event Loop 是如何運作有很深刻的理解(推薦觀看!)。但當看到 promise
, setTimeout
誰會先執行時突然不知道怎麼回答,直到我看到了原來 event queue 還能再分成 task queue
和 microtask queue
。
這篇文章是看了 Tasks, microtasks, queues and schedules 後的筆記,加上一點自己的註解
先看看這段程式碼,會依序 log 出什麼呢?
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(function () {
console.log('promise1');
})
.then(function () {
console.log('promise2');
});
console.log('script end');
setTimeout
和 Promise
同樣都會被丟到 Web API 處理,但怎麼知道哪個會先被執行呢?
p.s. log 結果是:
script start
script end
promise1
promise2
setTimeout
要知道為什麼 Promise
會比 setTimeout
先被執行,就要先了解 event loop 是如何處理 tasks 和 microtasks 的。
Event Loop
- 每個 thread 都有自己的 event loop
- 每個 web worker 都有自己的 event loop
- 在同一個 origin 下的所有視窗共用同一個 event loop,所以才能同步地溝通
Tasks
- 被瀏覽器排序好處理順序
- Tasks 之間,瀏覽器可能會 render updates。不會再執行 task 時 render。所以當 task 執行時間過長時,就會 block page rendering
滑鼠點擊、event callback、pase HTML 和 setTimeout
同樣都是 tasks,需要被排程執行
setTimeout
等待 delay 的時間,然後為 setTimeout callback
安排一個新的 task。這也是為什麼 setTimeout
會在 script end
之後才被執行,因為 setTimeout
task 被排在執行這段程式碼的 task 之後。此時的 tasks 排序會是這樣:
- run script:執行上面那整段程式碼
setTimeout
這時先忽略 Promise
部分不看,會依序發生:
- 將
run script
任務排入 task - 依序執行
run script
內容 - 遇到
setTimeout
後 thread 會先設一個 timer,並在時間到後安排新的 tasksetTimeout callback
在run script
後面 script end
log 出來後script
完成,移出 task- delay 時間到後執行
setTimeout callback
- 完成
Microtasks
Microtasks 會在當前 task 內所有任務都完後執行 microtasks(當前 task 仍然在 task queue,microtasks 完成後才會移出)
當 microtasks 中產生的新的 microtasks 會依序被排在 microtask queue 當中(像是
Promise
的 then callback)包含 mutation observer callbacks 和 promise callbacks
當 promise settled (fulfilled or rejected) 後,如果還有 then callback,則會為 then callback 安排新的 microtask(即便 promise 已經 settled 了),這就說明了為什麼 promise1
和 promise2
會在 script end
後、setTimeout
前被執行,因為 microtasks 會在下個 task 之前被執行
上面的範例,加上 Promise
後,會依序發生:
- 將
run script
任務排入 task,stack 上有script
- 依序執行
run script
內容 - 遇到
setTimeout
後 thread 會先設一個 timer,等時間到 Promise
settled 後還有 then,把 then (有promise1
的那個 )排入 microtask queuescript end
log 出來後script
完成並從 stack 抽離(run script
仍然在 tasks)- 接著執行 microtasks。首現 log 出
promise1
,後面又有接 then,將後面的 then(有promise2
的那個 )排入 microtask queue - 執行 microtask then (2),log 出
promise2
。此時 microtask queue 完成 run script
完成!此時瀏覽器可能會 update rendering- delay 時間到後把
setTimeout callback
排進 tasks - 執行
setTimeout callback
並印出setTimeout
- 完成
為什麼在一些瀏覽器反而是 setTimeout
先被執行?
因為實作的不同,它們把 Promise
當成一般 tasks 執行了
小練習
function a() {
function b() {
Promise.resolve()
.then(() => {
console.log("p1 b");
})
.then(() => {
console.log("p2 b");
});
setTimeout(() => {
console.log("setTimeout b");
}, 0);
console.log("b");
}
b();
Promise.resolve()
.then(() => {
console.log("p1 a");
})
.then(() => {
console.log("p2 a");
});
setTimeout(() => {
console.log("setTimeout a");
}, 0);
console.log("a");
}
a();
會是什麼結果呢?
如何知道是 tasks 還是 microtasks?
- 自行測試
- 看文件
接著看另一個例子
<div class="outer">
<div class="inner"></div>
</div>
// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
// Let's listen for attribute changes on the
// outer element
new MutationObserver(function () {
console.log('mutate');
}).observe(outer, {
attributes: true,
});
// Here's a click listener…
function onClick() {
console.log('click');
setTimeout(function () {
console.log('timeout');
}, 0);
Promise.resolve().then(function () {
console.log('promise');
});
outer.setAttribute('data-random', Math.random());
}
// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
當按下 inner 時,會 log......
答案是
click
promise
mutate
click
promise
mutate
timeout
timeout
???(我原本想的 log 順序是 click click promise mutate promise mutate timeout timeout)
會這樣的原因是因為 HTML spec 有寫道
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
白話一點說就是當 callback 執行完後會先執行 microtasks,且可以在當前 task 還沒完成前就被執行!
Wut??? 還是有看沒有懂
ECMAScript 也有說明 job (microtask) 是如何被開始執行的
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
當目前的 stack 沒有任務執行時就會開始執行 microtasks
這就說明了為什麼能在當前 task 還沒完成前就執行 microtasks
- Dispatch click (task),stack 上有 inner 觸發的 callback
- Log
click
- 遇到
setTimeout
,設 timer,等時間到後把setTimeout callback
排到 stack - 遇到
Promise
,Promise
settle 後,後面有then
,把then
排到 microtask queue - 觸發 mutation observer,排入 microtask queue
- Inner 的 callback 執行結束,從 stack 移出
- 開始執行 microtasks
- Log
promise
- Log
mutate
- Log
- microtasks 執行結束,接著因為 event bubbling 而被觸發的 outer callback 來到 stack 上
- 1 - 7 的步驟再重複一次
- outer callback 執行結束,dispatch click 也完成,從 tasks 移出
- 下個 task 是從 inner callback 來的
setTimeout callback
,logtimeout
- 下個 task 是從 outer callback 來的
setTimeout callback
,logtimeout
- 所有 tasks 完成!
如果不要自己點,而是用 inner.click()
呢?
結果出戶預料的是
click
click
promise
mutate
promise
timeout
timeout
Wut???
這是因為沒有使用者點擊,這其實跟第一個範例一樣只是單純的 script
執行順序
- Tasks: run script,microtasks:
,stack: script - 執行 inner callback(stack: script, inner callback)
- setTimeout 放到 task queue、Promise, mutation observer 放到 microtask queue
- Inner callback 完成,接著同步地觸發 outer callback(stack: script, outer callback)
- setTimeout 放到 task queue、Promise 放到 microtask queue
- Outer callback 完成
- 執行 microtasks
- 執行 setTimeout * 2
當 inner callback 執行結束後,因為 stack 不是空的(有 script),所以 microtasks 不會被執行,而是接著執行 outer callback
這確保了 microtasks 不會打斷執行到一半的 JavaScript
**這裡 mutate 只有一個,根據作者說法因為已經有一個 mutation microtask pending,所以不會排新的進去
Bonus
我好奇 run script 如果可以放在 task,那我放兩個 script 會怎麼樣呢?
<script>
console.log("script 1 start")
setTimeout(function() {
console.log("script1 setTimeout")
}, 0)
Promise.resolve().then(function() {
console.log("script1 promise")
})
console.log("script 1 end")
</script>
<script>
console.log("script 2 start")
setTimeout(function() {
console.log("script2 setTimeout")
}, 0)
Promise.resolve().then(function() {
console.log("script2 promise")
})
console.log("script 2 end")
</script>
結果出乎預料!
script 1 start
script 1 end
script1 promise
script 2 start
script 2 end
script2 promise
script1 setTimeout
script2 setTimeout
setTimeout 居然會在最後才執行!不是應該先排 setTimeout 到 task 然後才是 script 2 嗎?
- Tasks:
run script1
,run script2
- Log
script 1 start
- Set a timer for
setTimeout
. It ends immediately, send it's callback to task queue - Schedule then callback to microtask queue
- Log
script1 end
- Stack is now empty, execute microtasks queue
- Log
script1 promise
- Task
run script1
done, ontorun script 2
- Repeat step 2 to step 7 (change number 1 to 2)
- Task
run script2
done, ontosetTimeout callback
*2 - Log
script1 setTimeout
andscript2 setTimeout
respectively - All tasks done!
看來 Event Loop 會先將所有已知的 tasks (script1 & script2)放到 task queue,而不會等到執行時才放入
總結
- Tasks 會依序執行,瀏覽器在 task 與 task 之間可能會 update rendering
- Microtasks 會依序執行,方式如下:
- 在每個 callback 之後,但要是在沒有其他 JS 在執行中的狀態下(call stack 還有東西)
- 在每個 task 的最後
另外推薦閱讀 ⭐️🎀 JavaScript Visualized: Promises & Async/Await,裡面用了大量動畫來說明 event loop 和上面提到的 tasks & microtasks,非常淺顯易懂!