Henry's Blog

"Tasks, microtasks, queues and schedules" 筆記

在這個講解 JavaScript Event Loop 的 talk 中,很清楚地說明了 Event Loop 是什麼,也讓我對非同步和 Event Loop 是如何運作有很深刻的理解(推薦觀看!)。但當看到 promise, setTimeout 誰會先執行時突然不知道怎麼回答,直到我看到了原來 event queue 還能再分成 task queuemicrotask 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');

setTimeoutPromise 同樣都會被丟到 Web API 處理,但怎麼知道哪個會先被執行呢?

p.s. log 結果是:

script start
script end
promise1
promise2
setTimeout

要知道為什麼 Promise 會比 setTimeout 先被執行,就要先了解 event loop 是如何處理 tasks 和 microtasks 的。

Event Loop

Tasks

滑鼠點擊、event callback、pase HTML 和 setTimeout 同樣都是 tasks,需要被排程執行

setTimeout 等待 delay 的時間,然後為 setTimeout callback 安排一個新的 task。這也是為什麼 setTimeout 會在 script end 之後才被執行,因為 setTimeout task 被排在執行這段程式碼的 task 之後。此時的 tasks 排序會是這樣:

  1. run script:執行上面那整段程式碼
  2. setTimeout

這時先忽略 Promise 部分不看,會依序發生:

  1. run script 任務排入 task
  2. 依序執行 run script 內容
  3. 遇到 setTimeout 後 thread 會先設一個 timer,並在時間到後安排新的 task setTimeout callbackrun script 後面
  4. script end log 出來後 script 完成,移出 task
  5. delay 時間到後執行 setTimeout callback
  6. 完成

Microtasks

當 promise settled (fulfilled or rejected) 後,如果還有 then callback,則會為 then callback 安排新的 microtask(即便 promise 已經 settled 了),這就說明了為什麼 promise1promise2 會在 script end 後、setTimeout 前被執行,因為 microtasks 會在下個 task 之前被執行

上面的範例,加上 Promise 後,會依序發生:

  1. run script 任務排入 task,stack 上有 script
  2. 依序執行 run script 內容
  3. 遇到 setTimeout 後 thread 會先設一個 timer,等時間到
  4. Promise settled 後還有 then,把 then (有 promise1 的那個 )排入 microtask queue
  5. script end log 出來後 script 完成並從 stack 抽離(run script 仍然在 tasks)
  6. 接著執行 microtasks。首現 log 出 promise1,後面又有接 then,將後面的 then(有 promise2 的那個 )排入 microtask queue
  7. 執行 microtask then (2),log 出 promise2。此時 microtask queue 完成
  8. run script 完成!此時瀏覽器可能會 update rendering
  9. delay 時間到後把 setTimeout callback 排進 tasks
  10. 執行 setTimeout callback 並印出 setTimeout
  11. 完成

為什麼在一些瀏覽器反而是 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…

ECMAScript: Jobs and Job Queues

當目前的 stack 沒有任務執行時就會開始執行 microtasks

這就說明了為什麼能在當前 task 還沒完成前就執行 microtasks

  1. Dispatch click (task),stack 上有 inner 觸發的 callback
  2. Log click
  3. 遇到 setTimeout,設 timer,等時間到後把 setTimeout callback 排到 stack
  4. 遇到 PromisePromise settle 後,後面有 then,把 then 排到 microtask queue
  5. 觸發 mutation observer,排入 microtask queue
  6. Inner 的 callback 執行結束,從 stack 移出
  7. 開始執行 microtasks
    1. Log promise
    2. Log mutate
  8. microtasks 執行結束,接著因為 event bubbling 而被觸發的 outer callback 來到 stack 上
  9. 1 - 7 的步驟再重複一次
  10. outer callback 執行結束,dispatch click 也完成,從 tasks 移出
  11. 下個 task 是從 inner callback 來的 setTimeout callback,log timeout
  12. 下個 task 是從 outer callback 來的 setTimeout callback,log timeout
  13. 所有 tasks 完成!

如果不要自己點,而是用 inner.click() 呢?

結果出戶預料的是

click
click
promise
mutate
promise
timeout
timeout

Wut???

這是因為沒有使用者點擊,這其實跟第一個範例一樣只是單純的 script

執行順序

  1. Tasks: run script,microtasks: ,stack: script
  2. 執行 inner callback(stack: script, inner callback)
  3. setTimeout 放到 task queue、Promise, mutation observer 放到 microtask queue
  4. Inner callback 完成,接著同步地觸發 outer callback(stack: script, outer callback)
  5. setTimeout 放到 task queue、Promise 放到 microtask queue
  6. Outer callback 完成
  7. 執行 microtasks
  8. 執行 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>

Codepen

結果出乎預料!

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 嗎?

  1. Tasks: run script1, run script2
  2. Log script 1 start
  3. Set a timer for setTimeout. It ends immediately, send it's callback to task queue
  4. Schedule then callback to microtask queue
  5. Log script1 end
  6. Stack is now empty, execute microtasks queue
  7. Log script1 promise
  8. Task run script1 done, onto run script 2
  9. Repeat step 2 to step 7 (change number 1 to 2)
  10. Task run script2 done, onto setTimeout callback *2
  11. Log script1 setTimeout and script2 setTimeout respectively
  12. All tasks done!

看來 Event Loop 會先將所有已知的 tasks (script1 & script2)放到 task queue,而不會等到執行時才放入

總結

另外推薦閱讀 ⭐️🎀 JavaScript Visualized: Promises & Async/Await,裡面用了大量動畫來說明 event loop 和上面提到的 tasks & microtasks,非常淺顯易懂!

Reference

  1. Tasks, microtasks, queues and schedules
  2. Execution order of Timeout and Promise functions(Main Tasks and Micro Tasks)
  3. ⭐️🎀 JavaScript Visualized: Promises & Async/Await

#javascript