xingbofeng.github.io icon indicating copy to clipboard operation
xingbofeng.github.io copied to clipboard

JavaScript中的tasks与microtasks

Open xingbofeng opened this issue 6 years ago • 0 comments

原文:Tasks, microtasks, queues and schedules

当我跟我的同事Matt Gaunt交流的时候,我就在酝酿写一篇关于在浏览器环境下事件轮训的微任务(microtasks)队列执行顺序的文章。他说,我相信你会写的,但是,我肯定不会去阅读你的文章。那好,现在我无论如何都要把它写出来,以便于我们都能够静下来心平气和地讨论这个问题。

事实上,如果你看过足够多的技术相关的视频,你一定看过这样一段great talk at JSConf on the event loop视频,虽然这段视频没有包含微任务(microtasks)的相关讲解,但它确实把异步相关的问题解释得较为清楚明白。

看以下代码:

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

他们会以怎样的顺序输出呢?

事实上,正确的输出顺序是这样的:

script start
script end
promise1
promise2
setTimeout

但记住,这必须是对于支持Promise()语法的浏览器而言的。

Microsoft Edge, Firefox 40, iOS Safari 以及 PC端的 Safari 8.0.8setTimeout 会在 promise1promise2之前输出。这是十分奇怪的现象。但在Firefox 39 and Safari 8.0.7 似乎又能以正确的顺序输出。

为什么会出现这样的情况?

为了理解为何会产生上述的输出结果,你需要知道JavaScript引擎是如何进行任务和微任务的事件轮询的。以下许多原理也许是你第一次接触到,保持冷静地看下去……

每个线程都有他自己的事件轮询机制,每个页面都是在其所在线程独立运行的。然而,在一个页面中的所有task却共享同一个同步执行的线程。事件轮询连续不断地进行,所有待执行的任务被放入任务队列中。若任务队列中有多个任务,事件轮询则会保证其任务执行的顺序。但对于浏览器而言,它并不知道多个事件轮询队首的任务的优先级,因而它会更偏向于选择性能消耗更小的任务来执行(例如用户input框的输入行为)。那好,继续听我说……

异步任务被安排了一个既定的顺序,因而浏览器能够能依据其内部机制为每个任务分配相关资源。保证每个任务依次有序执行。在异步任务执行期间,浏览器也可能有自身的页面的渲染更新。如一次点击事件触发时,也需要将其回调函数放入事件轮询任务队列中。其可能改变HTML结构等。又如上面所述的setTimeout

setTimeout的作用是等待一个预先设定好的延时时间,然后把其回调函数推入任务队列中。为何setTimeout会比script end后输出,其原因便是script end等同步语句是最优先任务的一种。setTimeout则被放入一种单独的任务队列中。恩,这是现今我们都知道的。但是我需要你知道下面这一点……

微任务通常会在当前同步代码执行之后立即执行,例如对处理一些action以及在不需要新开线程的情况下处理一些异步任务。只要没有其它的同步JavaScript代码还在运行,在每个任务执行完毕后,微任务在回调函数之后便会排队形成微任务队列,在微任务排队期间任何其它微任务都会被添加到微任务队列队尾并且也将被处理。微任务包括Mutation Observer(变动观察器)以及上述所提到的Promise回调。

一旦一个Promise对象resolve了,或者说它之前就已经resolve了,为了避免它的回调函数出现错误调用,它会进入微任务队列。这样做保证了Promise的回调函数是异步执行的(即使Promise已经resolve了)。因此我们通常把这个回调函数传入Promise.then()而不是在在这个Promise对象resolve之后立刻执行下一个微任务。这便是promise1promise2会在script end之后打印的原因(由于立即执行的脚本会在微任务必须在微任务被处理之前执行完成)。而promise1promise2会在setTimeout之前被打印,这是由于微任务总会在下一段宏任务之前执行。

因此,看下面这段代码的执行过程

console.log('script start');

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

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

是的,我创建了一个动态图来展现这段代码的运行过程。你会怎样度过的你的礼拜六呢?是和你的朋友们出去晒太阳么?噢,我不会的。在这种情况下是不是该对我的UI设计功底刮目相看了呢,点击这个箭头看看。

一些浏览器的不同结果

一些浏览器会以这样的顺序打印:script start, script end, setTimeout, promise1, promise2。通常是在setTimeout之后运行Promise的回调函数。是把Promise的回调作为一个新的宏任务而非微任务。

这种情况是可解释的,由于Promise来源于ECMAScript而不是HTMLECMAScript拥有一个类似于microtasks(微任务)的jobs的概念。但是它与别的类型的任务的概念也不是太明确(参见vague mailing list discussions)。但是,在一般情况普遍认为Promise是微任务的一种。

若把Promise当做宏任务会导致性能问题,因为Promise的回调函数也许会有不必要的延迟,导致宏任务队列的任务被阻塞,例如页面渲染。此外,由于它可能会依赖于其它任务的资源,因而可能造成死锁的产生。能够打破这类死锁的方式只有调用一些别的API,这是很蠢的。

这是一篇Microsoft Edgebug修复日志WebKit早已使用正确的方式处理宏任务微任务问题。所以我也假想Safari以后也会修复这一问题。此外,在Firefox 43也是正确修复了这一问题的。

十分有趣的是SafariFirefox在修复bug后都都经历过版本回退的事情,我也十分好奇以后它们是否会再出现这种问题。

如何知道某类任务到底是宏任务还是微任务

测试是一种办法。我们通过和PromisesetTimeout对比,观察它会在什么时候输出。

正确的方法是,去查询ECMAScript标准和HTML标准。例如setTimeout在宏任务中排列第14位,而Mutation Observer(变动观察器)在微任务中排列第5位。

值得一提的是,在ECMAScript领域中,微任务(microtasks)被称作为jobsPerformPromiseThen排列jobs之中的第八位,EnqueueJob也被称作一种微任务。

现在,让我们看更多更复杂的例子。

更复杂的例子

在写这篇文章之前我把一个问题考虑错了,看这段HTML

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

执行下列这段JS代码,考虑在我点击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);

在不同浏览器执行的结果是这样的:

哪个结果才是正确的?

调用click事件的回调函数其实也是一个宏任务,Mutation Observer(变动观察器)Promise的回调函数都会被当做微任务进入微任务队列。setTimeout的回调会在宏任务队列中排队。可到原文链接观察他们是如何执行的。

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

因此Chrome中才能够得到正确的结果。有一种说法是微任务所在内存会在它的回调函数调用之后被释放,我认为它的内存其实会在宏任务队列执行完毕之后才被释放。这个规则可参照HTML规范:

If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3

此外微任务自身也会被核对是否正确进入了微任务队列,除非它已经被执行完毕。类似的,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

是什么让浏览器出现错误的结果

FirefoxSafari已经正确处理了多个点击事件之间的微任务队列,通过Mutation Observer(变动观察器)的回调所显示。但是Promise的打印时间却显得不同。这也是可解释的。在jobs微任务(microtasks)之间的执行顺序是模糊的,但在不久的将来这些问题应该都会得到正确的解决办法。可参考Firefox ticketSafari ticket

Edge中我们已经看到Promise会以正确的顺序进入微任务队列中,但是却没有正确处理微任务队列和click事件的顺序,相反微任务会在所有click事件之后再开始执行。在两次click打印之后便只有一次mutate的打印。Bug ticket

一个类似的例子

使用与上述相同的例子,在以下代码执行后会发生什么?

inner.click();

同样你可以到原文链接查看效果。

我发誓我是真的在Chrome中得到了不同的效果。我已经更新了这个图表很多次了。如果你在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
});

因此正确的结果是:click, click, promise, mutate, promise, timeout, timeout,看起来似乎在Chrome中得到了正确的结果。

在标准里的每个事件监听回调是这样的:

If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3

在这之前,微任务会在事件回调之间运行。但是click()却会导致事件同步调度,因此调用click()的脚本仍然在回调函数之间的堆栈中。上述规则确保微任务不会中断正在执行的JavaScript代码。这意味着我们不会在事件监听回调之间处理微任务队列,而是在其之后进行处理。

总结

  • 宏任务按顺序执行,浏览器可以在宏任务执行期间进行页面渲染。
  • 微任务按顺序执行。
    • 在微任务回调执行之后,确保没有别的同步JavaScript代码在其中执行。
    • 在宏任务执行完毕之后才释放内存。

xingbofeng avatar Jul 23 '17 12:07 xingbofeng