资讯专栏INFORMATION COLUMN

Tasks, microtasks, queues and schedules(译)

tianyu / 2128人阅读

摘要:事件循环持续运行,直到清空列队的任务。在执行期间,浏览器可能更新渲染。线索可能会发生多次。由于冒泡,函数再一次执行。这意味着队列不会在事件回调之间处理,而是在它们之后处理。当触发成功事件时,相关的对象在事件之后转为非激活状态第四步。

一 前言

一直想对异步处理做一个研究,在查阅资料时发现了这篇文章,非常深入的解释了事件循环中重的任务队列。原文中有代码执行工具,强烈建议自己执行一下查看结果,深入体会task执行顺序。
建议看这篇译文之前先看这篇全面讲解事件循环的文章:https://mp.weixin.qq.com/s/vI...
翻译参考了这篇文章的部分内容:https://juejin.im/entry/55dbd...

二 正文

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

当我告诉我的同事 Matt Gaunt 我想写一篇关于mircrotask、queueing和浏览器的Event Loop的文章。他说:“我实话跟你说吧,我是不会看的。” 好吧,无论如何我已经写完了,那么我们坐下来一起看看,好吧?

如果你更喜欢视频,Philip Roberts 在 JSConf 上就事件循环有一个很棒的演讲——没有讲 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");

控制台上的输出顺序是怎样的呢?

Try it

正确的答案是:

script start

script end

promise1

promise2

setTimeout

但是由于浏览器实现支持不同导致结果也不一致。

Microsoft Edge, Firefox 40, iOS Safari 及桌面 Safari 8.0.8 在 promise1 和 promise2 之前打印 setTimeout -- 这似乎是浏览器厂商相互竞争导致的实现不同。但是很奇怪的是,Firefox 39 和 Safari 8.0.7 竟然结果都是对的(一致的)。

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代码:

给出下面的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被执行的时机。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/94877.html

相关文章

  • Tasks(任务), microtasks(微任务), queues(队列) and schedul

    摘要:事件循环持续运行,执行列队。因此一个已解决的调用将立即把一个加入队列。如上所述,将称为。这意味着队列在事件回调之间不处理,而是在它们之后处理。当触发成功事件时,相关的对象在事件之后转为非激活状态第四步。 原文:Tasks, microtasks, queues and schedules git地址:Tasks(任务), microtasks(微任务), queues(队列) an...

    xiaodao 评论0 收藏0
  • 简要总结microtask和macrotask

    摘要:众所周知和都属于上述异步任务的一种那到底为什么和会有顺序之分这就是我想分析总结的问题所在了和的作用是为了让浏览器能够从内部获取的内容并确保执行栈能够顺序进行。只要执行栈没有其他在执行,在每个结束时,队列就会在回调后处理。 前言 我是在做前端面试题中看到了setTimeout和Promise的比较,然后第一次看到了microtask和macrotask的概念,在阅读了一些文章之后发现没有...

    yexiaobai 评论0 收藏0
  • 什么是浏览器的事件循环(Event Loop)?

    摘要:本文围绕浏览器的事件循环,而有自己的另一套事件循环机制,不在本文讨论范围。现在我们知道了浏览器运行时有一个叫事件循环的机制。将事件循环的当前运行任务设置为。对于相应事件循环的每个环境设置对象通知它们哪些为。 本文围绕浏览器的事件循环,而node.js有自己的另一套事件循环机制,不在本文讨论范围。网上的许多相关技术文章提到了process.nextTick和setImmediate两个n...

    JerryC 评论0 收藏0
  • 浏览器的微任务MicroTask和宏任务MacroTask

    摘要:简介我把在浏览器中运行主要分为以下几种类型的任务同步任务同步任务是指按照正常顺序执行的代码,比如函数调用,数值运算等等,只要是执行后立即能够得到结果的就是同步任务。取出微任务队列中的任务执行,直到队列被完全清空重复和,直到宏任务队列被清空。 简介 ​ 我把JavaScript在浏览器中运行主要分为以下几种类型的任务: 同步任务(MainTask) :同步任务是指JavaScr...

    v1 评论0 收藏0
  • event loop 与 vue

    摘要:但是导致了很明显的性能问题。上述两个例子其实是在这个中找到的,第一个使用的版本是,这个版本的实现是采用了,而后因为的里的有,于是尤雨溪更改了实现,换成了,也就是后一个所使用的。后来尤雨溪了解到是将回调放入的队列。 结论 对于event loop 可以抽象成一段简单的代码表示 for (macroTask of macroTaskQueue) { // 1. Handle cur...

    springDevBird 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<