Tasks, microtasks, queues and schedules(譯)

一 前言

一直想對異步處理作一個研究,在查閱資料時發現了這篇文章,很是深刻的解釋了事件循環中重的任務隊列。原文中有代碼執行工具,強烈建議本身執行一下查看結果,深刻體會task執行順序。
建議看這篇譯文以前先看這篇全面講解事件循環的文章:https://mp.weixin.qq.com/s/vI...
翻譯參考了這篇文章的部份內容:https://juejin.im/entry/55dbd...html

二 正文

原文地址:Tasks, microtasks, queues and schedulesgit

當我告訴個人同事 Matt Gaunt 我想寫一篇關於mircrotask、queueing和瀏覽器的Event Loop的文章。他說:「我實話跟你說吧,我是不會看的。」 好吧,不管如何我已經寫完了,那麼咱們坐下來一塊兒看看,好吧?es6

若是你更喜歡視頻,Philip Roberts 在 JSConf 上就事件循環有一個很棒的演講——沒有講 microtasks,不過很好的介紹了其它概念。好,繼續!github

思考下面 JavaScript 代碼:web

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');

控制檯上的輸出順序是怎樣的呢?chrome

Try it

正確的答案是:windows

script start

script end

promise1

promise2

setTimeout

可是因爲瀏覽器實現支持不一樣致使結果也不一致。api

Microsoft Edge, Firefox 40, iOS Safari 及桌面 Safari 8.0.8 在 promise1 和 promise2 以前打印 setTimeout -- 這彷佛是瀏覽器廠商相互競爭致使的實現不一樣。可是很奇怪的是,Firefox 39 和 Safari 8.0.7 居然結果都是對的(一致的)。promise

Why this happens

要想弄明白這些,你須要知道Event Loop是如何處理 tasks 和 microtasks的。若是你是第一次接觸它,須要花些功夫才能弄明白。深呼吸。。。瀏覽器

每一個線程都有本身的事件循環,因此每一個 web worker 都有本身的事件循環,所以web worker才能夠獨立執行。而來自同域的全部窗口共享一個事件循環,因此它們能夠同步地通訊。事件循環持續運行,直到清空 tasks 列隊的任務。事件循環包括多種任務源,事件循環執行時會訪問這些任務源,這樣就肯定了各個任務源的執行順序(IndexedDB 等規範定義了本身的任務源和執行順序),但瀏覽器能夠在每次循環中選擇從哪一個任務源去執行一個任務。這容許瀏覽器優先考慮性能敏感的任務,例如用戶輸入。Ok ok, 留下來陪我坐會兒……

Tasks 被放到任務源中,瀏覽器內部執行轉移到JavaScript/DOM領域,而且確保這些 tasks按序執行。在tasks執行期間,瀏覽器可能更新渲染。來自鼠標點擊的事件回調須要安排一個task,解析HTML和setTimeout一樣須要。

setTimeout延遲給定的時間,而後爲它的回調安排一個新的task。這就是爲何 setTimeout在 script end 以後打印:script end 在第一個task 內,setTimeout 在另外一個 task 內。好了,咱們快講完了,剩下一點我須要大家堅持下……

Mircotasks隊列一般用於存放一些任務,這些任務應該在正在執行的腳本以後當即執行,好比對一批動做做出反應,或者操做異步執行避免建立整個新任務形成的性能浪費。 只要沒有其餘JavaScript代碼在執行中,而且在每一個task隊列的任務結束時,microtask隊列就會被處理。在處理 microtasks 隊列期間,新添加到 microtasks 隊列的任務也會被執行。 microtasks 包括 MutationObserver callbacks。例如上面的例子中的 promise的callback。

一個settled狀態的promise(直接調用resolve或者reject)或者已經變成settled狀態(異步請求被settled)的promise,會馬上將它的callback(then)放到microtask隊列裏面。這就能保證promise的回調是異步的,即使promise已經變爲settled狀態。所以一個已settled的promise調用.then(yey,nay)時將當即把一個microtask任務加入microtasks任務隊列。這就是爲何 promise1 和 promise2 在 script end 以後打印,由於正在運行的代碼必須在處理 microtasks 以前完成。promise1 和 promise2 在 setTimeout 以前打印,由於 microtasks 老是在下一個 task 以前執行。

好,一步一步的運行:

console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

圖片描述

圖片描述

圖片描述

圖片描述

圖片描述

圖片描述

圖片描述

沒錯,就是上面這個,我作了一個 step-by-step 動畫圖解。你週六是怎麼過的?和朋友們一塊兒出去玩?我沒有出去。嗯,若是搞不明白個人使人驚歎的UI設計界面,點擊上面的箭頭試試。

瀏覽器實現差別

一些瀏覽器的打印結果:

script start
script end
setTimeout
promise1
promise2

在 setTimeout 以後運行 promise 的回調,就好像將 promise 的回調看成一個新的 task 而不是 microtask。

這多少情有可原,由於 promise 來自 ECMAScript 規範而不是 HTML 規範。ECAMScript 有一個概念 job,和 microtask 類似,可是二者的關係在郵件列表討論中沒有明確。不過,通常共識是 promise 應該是 microtask 隊列的一部分,而且有充足的理由。

將 promise看成task(macrotask)會帶來一些性能問題,由於回調沒有必要由於task相關的事(好比渲染)而延遲執行。與其它 task 來源交互時它也產生不肯定性,也會打斷與其它 API 的交互,不事後面再細說。

我提交了一條 Edge 反饋,它錯誤地將 promises 看成 task。WebKit nightly 作對了,因此我認爲 Safari 最終會修復,而 Firefox 43 彷佛已經修復。

有趣的是 Safari 和 Firefox 發生了退化,而以前的版本是對的。我在想這是否只是巧合。

How to tell if something uses tasks or microtasks

動手試一試是一種辦法,查看相對於promise和setTimeout如何打印,儘管這取決於實現是否正確。

一種方法是查看規範:
將一個 task 加入隊列: step 14 of setTimeout
將 microtask 加入隊列:step 5 of queuing a mutation record

如上所述,ECMAScript 將 microtask 稱爲 job:
調用 EnqueueJob 將一個 microtask 加入隊列:step 8.a of PerformPromiseThen

如今,讓咱們看一個更復雜的例子。一個有心的學徒 :「可是他們尚未準備好」。別管他,你已經準備好了,讓咱們開始……

Level 1 bossfight

在發出這篇文章以前,我犯過一個錯誤。下面是一段html代碼:

<div class="outer">
  <div class="inner"></div>
</div>

給出下面的JS代碼,若是click div.inner將會打印出什麼呢?

// 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);

繼續,在查看答案以前先試一試。 線索:logs可能會發生屢次。

Test it

點擊inner區域觸發click事件:

圖片描述

click div.inner :

click
promise
mutate
click
promise
mutate
timeout
timeout

click div.outer :

click
promise
mutate
timeout

和你猜測的有不一樣嗎?若是是,你獲得的結果可能也是正確的。不幸的是,瀏覽器實現並不統一,下面是各個瀏覽器下測試結果:

圖片描述

Who's right?

觸發 click 事件是一個 task,Mutation observer 和 promise 的回調 加入microtask列隊,setTimeout 回調加入task列隊。所以運行過程以下:

點擊內部區域觸發內部區域點擊事件 -> 冒泡到外部區域 -> 觸發外部區域點擊事件
這裏要注意一點: setTimeout 執行時機在冒泡以後,由於也是在microtask以後,準確的說是在最後的時機執行了。

圖片描述

堆棧爲空以後將會執行microtasks裏面的任務。

圖片描述

因爲冒泡, click函數再一次執行。

圖片描述

最後將執行setTimeout。

圖片描述

因此 Chrome 是對的。對我來講新發現是,microtasks 在回調以後運行(只要沒有其它的 Javascript 在運行),我原覺得它只能在一個task 的末尾執行。這個規則來自 HTML 規範,調用一個回調:

If the stack of script settings objects is now empty, perform a microtask checkpoint

HTML: Cleaning up after a callback step 3

一個 microtask checkpoint 逐個檢查 microtask隊列,除非咱們已經在處理一個 microtask 隊列。相似地,ECMAScript 規範這麼說 jobs:

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
儘管在 HTML 中"can be"變成了"must be"。

What did browsers get wrong?

對於 mutation callbacks,Firefox 和 Safari 都正確地在內部區域和外部區域單擊事件之間執行完畢,清空了microtask 隊列,可是 promises 列隊的處理看起來和chrome不同。這多少情有可原,由於 jobs 和 microtasks 的關係不清楚,可是我仍然指望在事件回調之間處理。Firefox ticket. Safari ticket.

對於 Edge,咱們已經看到它錯誤的將 promises 看成 task,它也沒有在單擊回調之間清空 microtask 隊列,而是在全部單擊回調執行完以後清空,因而總共只有一個 mutate 在兩個 click 以後打印。 Bug ticket.

Level 1 boss's angry older brother

仍然使用上面的例子,假如咱們運行下面代碼會怎麼樣:

inner.click();

跟以前同樣,它會觸發 click 事件,不過是經過代碼而不是實際的交互動做。

Try it

下面是各個瀏覽器的運行狀況:

圖片描述

我發誓我一直在Chrome 中獲得不一樣的結果,我已經更新了這個表許許屢次了。我以爲我是錯誤地測試了Canary。假如你在 Chrome 中獲得了不一樣的結果,請在評論中告訴我是哪一個版本。

Why is it different?

這裏介紹了它是怎樣發生的:

將Run srcipt加入Tasks隊列,將inner.click加入執行堆棧:

圖片描述

執行click函數:

圖片描述

按順序執行,分別將setTimeout加入Tasks隊列,將Promise MultationObserver加入microtasks隊列:

圖片描述

click函數執行完畢以後,咱們沒有去處理microtasks隊列的任務,由於此時堆棧不爲空:

圖片描述

咱們不能將 MultationObserver加入microtasks隊列,由於有一個等待處理的 MultationObserver:

圖片描述

如今堆棧爲空了,咱們能夠處理microtasks隊列的任務了:

圖片描述

最終結果:

圖片描述

經過對比事件觸發,咱們要注意兩個地方:JS stack是不是空的決定了microtasks隊列裏任務的執行;microtasks隊列裏不能同時有多個MultationObserver。

正確的順序是:click, click, promise, mutate, promise, timeout, timeout,彷佛 Chrome 是對的。

在每一個listerner callback被調用以後:

If the stack of script settings objects is now empty,perform a microtask checkpoint.

— HTML: 回調以後的清理第三步

以前,這意味着 microtasks 在事件回調之間運行,可是如今.click()讓事件同步觸發,所以調用.click()的腳本仍處於回調之間的堆棧中。上面的規則確保了 microtasks 不會中斷正在執行的JS代碼。這意味着 microtasks 隊列不會在事件回調之間處理,而是在它們以後處理。

Does any of this matter?

重要,它會在偏角處咬你(疼)。我就遇到了這個問題,我在嘗試爲IndexedDB建立一個使用promises而不是奇怪的IDBRequest對象的簡單包裝庫時遇到了此問題。它讓 IDB 用起來頗有趣

當 IDB 觸發成功事件時,相關的 transaction 對象在事件以後轉爲非激活狀態(第四步)。若是我建立的 promise 在這個事件發生時被resolved,回調應當在第四步以前執行,這時這個對象仍然是激活狀態。可是在 Chrome 以外的瀏覽器中不是這樣,致使這個庫有些無用。

實際上你能夠在 Firefox 中解決這個問題,由於 promise polyfills 如 es6-promise 使用 mutation observers 執行回調,它正確地使用了 microtasks。而它在 Safari 下彷佛存在競態條件,不過這多是由於他們糟糕的 IDB 實現。不幸的是 IE/Edge 不一致,由於 mutation 事件不在回調以後處理。

但願不久咱們能看到一些互通性。

You made it!

總結:

  • tasks 按序執行,瀏覽器會在 tasks 之間執行渲染。
  • microtasks 按序執行,在下面狀況時執行:

    • 在每一個回調以後,只要沒有其它代碼正在運行。
    • 在每一個 task 的末尾。

但願你如今明白了事件循環,或者至少獲得一個藉口出去走一走,躺一躺。

呃,還有人在嗎?Hello?Hello?

感謝 Anne van Kesteren, Domenic Denicola, Brian Kardell 和 Matt Gaunt 校對和修正。是的,Matt 最後仍是看了此文,我沒必要把他整成發條橙了。

三 後記

總結

1.microtask隊列就會被處理的時機

(1)只要沒有其餘JavaScript代碼在執行中,
(2)而且在每一個task隊列的任務結束時,

  microtask隊列就會被處理。

也就是說能夠在執行一個task以後連續執行多個microtask。

2. promise相關

(1)promise一旦建立就會立刻執行
(2)當狀態變爲settled的時候,callback纔會被加入microtask 隊列

因此要注意promise建立和callback被執行的時機。

相關文章
相關標籤/搜索