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被执行的时机。

相关文章
相关标签/搜索