Tasks, microtasks, queues and schedules(译)

Andy,javascript

原文 (opens in a new tab),由 FTAndy (opens in a new tab) 翻译,Ivan Yan (opens in a new tab) 校正,"署名-非商用-相同方式共享" (opens in a new tab)。译文反馈 (opens in a new tab)


Tasks, microtasks, queues and schedules

当我告诉我的同事 Matt Gaunt (opens in a new tab) 我想写一篇文章,介绍 mircrotask 队列和浏览器事件循环。他说:“我老实跟你说,我不会看的。” 嗯,我无论如何都要写,那么我们坐下来开始享受它,ok?

如果你更喜欢视频,Philip Roberts (opens in a new tab) 在 JSConf 上就事件循环有一个很棒的演讲 (opens in a new tab)——没有讲 microtasks,不过很好的介绍了其它概念。好,继续!

有下面的Javascript代码:

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

log会怎么输出呢?

试一下

正确的答案是:script start, script end, promise1, promise2, setTimeout,但是各浏览器不一致。

Microsoft Edge, Firefox 40, iOS Safari 及桌面 Safari 8.0.8 在 promise1promise2 之前打印 setTimeout ——尽管这似乎是竞争条件导致的。很奇怪的是,Firefox 39 和 Safari 8.0.7 是对的。

为什么会这样

要想弄明白为什么,你需要知道事件循环如何处理 tasks 和 microtasks。第一次接触需要花些功夫才能弄明白。深呼吸……

每个线程都有自己的事件循环,所以每个 web worker 有自己的事件循环(event loop),所以它能独立地运行。而所有同源的 window 共享一个事件循环,因为它们能同步的通讯。事件循环持续运行,执行 tasks 列队。一个事件循环有多个 task 来源,并且保证在 task 来源内的执行顺序(IndexedDB (opens in a new tab) 等规范定义了自己的 task 来源),在每次循环中浏览器要选择从哪个来源中选取 task,这使得浏览器能优先执行敏感 task,例如用户输入。Ok ok, 留下来陪我坐会儿……

Tasks 被列入队列,于是浏览器能从它的内部转移到 Javascript/DOM 领地,并且确使这些 tasks 按序执行。在 tasks 之间,浏览器可以更新渲染。来自鼠标点击的事件回调需要安排一个 task,解析 HTML 和 setTimeout 同样需要。

setTimeout 延迟给定的时间,然后为它的回调安排一个新的 task。这就是为什么 setTimeoutscript end 之后打印,script end 在第一个 task 内,setTimeout 在另一个 task 内。好了,我们快讲完了,剩下一点我需要你们坚持下……

Mircotasks 通常用于安排一些事,它们应该在正在执行的代码之后立即发生,例如响应操作,或者让操作异步执行,以免付出一个全新 task 的代价。mircotask 队列在回调之后处理,只要没有其它执行当中的(mid-execution)代码;或者在每个 task 的末尾处理。在处理 microtasks 队列期间,新添加的 microtasks 添加到队列的末尾并且也被执行。 microtasks 包括 mutation observer 回调。上面的例子中的 promise 的回调也是。

promise 一旦解决(settled),或者已解决,它便为它的回调安排一个 microtask。这确使 promise 回调是异步的,即便 promise 已经解决。因此一个已解决的 promise 调用 .then(yey, nay) 将立即把一个 microtask 加入队列。这就是为什么 promise1promise2script end 之后打印,因为正在运行的代码必须在处理 microtasks 之前完成。promise1promise2setTimeout 之前打印,因为 microtasks 总是在下一个 task 之前执行。

好,一步一步的运行:

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

Tasks
Run script
setTimeout callback
Microtasks
Promise then
Promise then
JS stack
Log
script start
script end
promise1
promise2
setTimeout

是的,我做了一个 step-by-step 动画图解。你周六是怎么过的?和朋友们出去晒太阳?我没有。嗯,如果不明白我的 UI 设计,点击上面的箭头。

其它浏览器有什么不同?

一些浏览器的打印结果:script start, script end, setTimeout, promise1, promise2。在 setTimeout 之后运行 promise 的回调,就好像将 promise 的回调当作一个新的 task 而不是 microtask。

这多少情有可原,因为 promise 来自 ECMAScript 规范而不是 HTML 规范。ECAMScript 有一个概念 job,和 microtask 相似,但是两者的关系在邮件列表讨论 (opens in a new tab)中没有明确。不过,一般共识是 promise 应该是 microtask 队列的一部分,并且有充足的理由。

将 promise 当作 task 会导致性能问题,因为回调可能不必要地被与 task 相关的事(比如渲染)延迟。与其它 task 来源交互时它也导致不确定性,也会打断与其它 API 的交互,不过后面再细说。

我提交了一条 Edge 反馈 (opens in a new tab),它错误地将 promises 当作 task。WebKit nightly 做对了,所以我认为 Safari 最终会修复,而 Firefox 43 似乎已经修复。

有趣的是 Safari 和 Firefox 发生了退化,而之前的版本是对的。我在想这是否只是巧合。

怎么知道是 task 还是 microtask?

测试是一种办法,查看相对于 promise 和 setTimeout 如何打印,尽管这取决于实现是否正确。

一种方法是查看规范。例如,setTimeout 的第十四步 (opens in a new tab)将一个 task 加入队列,mutation record 的第五步 (opens in a new tab)将 microtask 加入队列。

如上所述,ECMAScript 将 microtask 称为 job。PerformPromiseThen 的第八步 (opens in a new tab) 调用 EnqueueJob 将一个 microtask 加入队列。

现在,让我们看一个更复杂的例子。(一个有心的学徒 :“但是他们还没有准备好”。别管他,你已经准备好了,让我们开始……)

等级 1 BOSS战

在我写这篇博文之前,我就已经把这个搞错了。这是一段 html 代码:

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

有如下的 Javascript 代码,假如我点击 div.inner 会发生什么 log 呢?

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

继续,在看答案之前想清楚。

试一下吧

点击下面内部的矩形出发一个 click 事件:

你的估计和结果是否一样?假如是,你可能是正确的。但不幸的是浏览器不会同意这一点:

  • Chrome
    • click
    • promise
    • mutate
    • click
    • promise
    • mutate
    • timeout
    • timeout
  • Firefox
    • click
    • mutate
    • click
    • mutate
    • timeout
    • promise
    • promise
    • timeout
  • Safari
    • click
    • mutate
    • click
    • mutate
    • promise
    • promise
    • timeout
    • timeout
  • Edge
    • click
    • click
    • mutate
    • timeout
    • promise
    • timeout
    • promise

谁是对的?

触发 click 事件是一个 task,Mutation observer 和 promise 回调作为 microtask 加入列队,setTimeout 回调作为 task 加入列队。因此运行过程如下:

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

Tasks
Dispatch click
setTimeout callback
setTimeout callback
Microtasks
Promise then
Mutation observers
Promise then
Mutation observers
JS stack
Log
click
promise
mutate
click
promise
mutate
timeout
timeout

所以 Chrome 是对的。对我来说新发现是,microtasks 在回调之后运行(只要没有其它的 Javascript 在运行,我原以为它只能在 task 的末尾运行。这个规则来自 HTML 规范,调用一个回调:

{% blockquote %} If the stack of script settings objects (opens in a new tab) is now empty,perform a microtask checkpoint (opens in a new tab)

—— HTML: 回调之后的清理 (opens in a new tab)第三步 {% endblockquote %}

一个 microtask checkpoint 逐个检查 microtask 队列,除非我们已经在处理一个 microtask 队列。类似地,ECMAScript 规范这么说 jobs:

{% blockquote %} 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 (opens in a new tab) {% endblockquote %}

尽管在 HTML 中"can be"变成了"must be"。

其它浏览器哪里错了?

对于 mutation 回调,Firefox 和 Safari 正确地在单击回调之间清空 microtask 队列,但是 promises 列队似乎不一样。这多少情有可原,因为 jobs 和 microtasks 的关系不清楚,但是我仍然期望在事件回调之间处理。Firefox bug (opens in a new tab)Safari bug (opens in a new tab)

对于 Edge,我们已经看到它错误的将 promises 当作 task,它也没有在单击回调之间清空 microtask 队列,而是在所有单击回调执行完之后清空,于是总共只有一个 mutate 在两个 click 之后打印。Edge bug (opens in a new tab)

等级 1 BOSS愤怒的老大哥

使用上面相同的的例子,假如我们执行这个会发生什么呢:

inner.click();

这会在事件触发之前执行,在顶级作用域的代码,而不是交互事件中。

试一下

下面是各浏览器的结果:

  • Chrome
    • click
    • click
    • promise
    • mutate
    • promise
    • timeout
    • timeout
  • Firefox
    • click
    • click
    • mutate
    • timeout
    • promise
    • promise
    • timeout
  • Safari
    • click
    • click
    • mutate
    • promise
    • promise
    • timeout
    • timeout
  • Edge
    • click
    • click
    • mutate
    • timeout
    • promise
    • timeout
    • promise

我发誓我在 Chrome 中始终得到不同的结果,我更新了这个表许多次才意识到我测试的是 Canary。假如你在 Chrome 中得到了不同的结果,请在评论中告诉我是哪个版本。

为什么不一样?

下面是所发生的:

// 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.click();

Tasks
Run script
setTimeout callback
setTimeout callback
Microtasks
Promise then
Mutation observers
Promise then
JS stack
Log
click
click
promise
mutate
promise
timeout
timeout

正确的顺序是:click, click, promise, mutate, promise, timeout, timeout,似乎 Chrome 是对的。

在每个事件回调调用之后:

If the stack of script settings objects (opens in a new tab) is now empty,perform a microtask checkpoint (opens in a new tab).

HTML: 回调之后的清理 (opens in a new tab)第三步

之前,这意味着 microtasks 在事件回调之间运行,但是 .click() 让事件同步触发,所以调用 .click() 的代码仍然在事件回调之间的栈内。上面的规则确保了 microtasks 不会中断执行当中的代码。这意味着 microtasks 队列在事件回调之间不处理,而是在它们之后处理。

这重要吗?

重要,它会在偏角处咬你(疼)。我就遇到了这个问题,在我尝试用 promises 而不是用怪异的 IDBRequest 对象为 IndexedDB 创建一个简单的包装库 (opens in a new tab) 时。它让 IDB 用起来很有趣 (opens in a new tab)

当 IDB 触发成功事件时,相关的 transaction 对象在事件之后转为非激活状态 (opens in a new tab)(第四步)。如果我创建的 promise 在这个事件发生时被履行(resolved),回调应当在第四步之前执行,这时这个对象仍然是激活状态。但是在 Chrome 之外的浏览器中不是这样,导致这个库有些无用。

实际上你可以在 Firefox 中解决这个问题,因为 promise polyfills 如 es6-promise (opens in a new tab) 使用 mutation observers 执行回调,它正确地使用了 microtasks。而它在 Safari 下似乎存在竞态条件,不过这可能是因为他们糟糕的 IDB 实现 (opens in a new tab)。不幸的是 IE/Edge 不一致,因为 mutation 事件不在回调之后处理。

希望不久我们能看到一些互通性。

你做到了!

总结:

希望你现在明白了事件循环,或者至少得到一个借口出去走一走躺一躺。

呃,还有人在吗?Hello?Hello?

感谢 Anne van Kesteren, Domenic Denicola, Brian Kardell 和 Matt Gaunt 校对和修正。是的,Matt 最后还是看了此文,我不必把他整成发条橙了。

译注:setImmediate.js (opens in a new tab) 也提到了 tasks 与 microtasks 的区别,供参考。

© FTAndy.RSS