Macrotasks and microtasks in JavaScript

异步运行机制

关于JavaScript的异步运行机制,参照Philip Roberts在JSConf上的演讲视频,在视频中他对JavaScript的异步运行机制做了可视化讲解,最初在看到这个视频时简直被惊艳到(那时我还是一个小萌新),我们也可以在他写的可视化工具中运行自己的代码以分析JavaScript的运行机制。遗憾的是他不支持Promises,也未涉及到Macrotasks and microtasks,所以不足以让我们从中窥见Macrotasks and microtasks的奥秘。

从Philip Roberts的演讲中我们还是可以了解到:JavaScript主线程拥有一个执行栈以及一个回调队列,主线程会依次执行代码,当遇到函数时,会先将函数 入栈,函数运行完毕后再将该函数 出栈,直到所有代码执行完毕。当遇到 WebAPI(例如:setTimeout, AJAX)这些函数时,这些函数会立即返回一个值,从而让主线程不会在此处阻塞。而真正的异步操作会由浏览器执行,浏览器会在这些任务完成后,将事先定义的回调函数推入主线程的回调队列中。而主线程则会在 清空当前执行栈后,按照先入先出的顺序读取回调队列里面的任务。

所以当浏览器执行如下JavaScript代码时:

1
2
3
4
5
console.log('script start')
setTimeout(function fn() {
console.log('setTimeout')
}, 0);
console.log('script end')

执行顺序为:

首先console.log('script start')会被压入执行栈,执行后打印script start,然后出栈。接下来setTimeout会被压入执行栈,它会返回fn函数。遇到(setTimeout, AJAX)这类web api时,JavaScript引擎会将他们交给浏览器的异步执行队列来处理,计时器设定计时为零,但是fn函数会加入task队列。但执行栈中的JavaScript程序会继续执行,console.log('script end')函数会执行,并输出script start。当执行栈为空时task队列中的函数会被取出依次执行。

macrotask 和 microtask

先提出一个问题:当我们执行下面的代码时,预计输出顺序会是什么样的?

1
2
3
4
5
6
7
8
9
10
setTimeout(function() {
console.log(1)
},0);
new Promise(function(resolve) {
resolve()
console.log(2)
}).then(function() {
console.log(3)
});
console.log(4)

如果我们不知道macrotask和microtask的相关概念和区别,我们的分析逻辑应该是这样的:

  1. setTimeout进入执行栈,因为它是web API,JavaScript引擎会将它交给浏览器异步执行队列执行,异步执行队列在setTimeout完成计时后会将匿名回调函数推到回调队列。

  2. new Promise进入执行栈,将Promise的状态从fulfilled切换到resolve。然后把then里面的匿名回调函数推到回调队列中。然后执行console.log(2)

  3. console.log(4)进入执行栈,打印出4。

  4. 接下来从回调队列中依次取出setTimeout和then的回调函数并执行,输出1、3。

按照这样的分析,我们输出的顺序应该是:2、4、1、3。

然而当我们在浏览器中执行这段代码是,得到的结果却是:2、4、3、1。到底是哪一个环节出了问题呢?

要搞清楚这个问题,我们需要了解macrotasks和microtasks这两个概念:Macrotask 和 microtask 都是属于上述的异步任务中的一种,它们对应的API分别为:
macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
microtasks: process.nextTick, Promises, Object.observe(废弃), MutationObserver

其实本文开头提到的回调队列包含macrotasks队列和microtasks队列。浏览器在处理这两种异步任务队列时的表现为:在每一次事件循环中,macrotask只会提取一个执行,而 microtask 会一直提取,直到 microtasks 队列清空。一般情况下,macrotask队列我们会直接称为task queue,即任务队列。

也就是说macrotasks和microtasks的执行顺序是:

取task queue第一个task执行 -> 取microtask全部任务依次执行 -> 取task queue下一个任务执行 -> 再次取出microtask全部任务执行 -> … 循环往复

在浏览器环境的事件循环中, JavaScript 脚本也会作为一个 task 被推入任务队列,我们在运行这个事件后,该脚本中的macrotasks、microtasks才会被推入队列。

所以回顾我们的代码,执行顺序应该为:

  1. JavaScript 脚本也会作为一个 task 被推入任务队列。

  2. setTimeout进入执行栈,因为它是web API,JavaScript引擎会将它交给浏览器异步执行队列执行,异步执行队列在setTimeout完成计时后会将匿名回调函数推到macrotasks队列。

  3. new Promise进入执行栈,将Promise的状态从fulfilled切换到resolve。然后把then里面的匿名回调函数推到microtask队列中。然后执行console.log(2)

  4. console.log(4)进入执行栈,打印出4。

  5. 第一轮任务队列已经执行完毕,符合microtask执行条件,因此会将microtask队列中的任务优先执行,接下来从microtask队列取出then的回调函数并执行,输出3。

  6. 开始执行第二轮,取task queue下一个任务执行,也就是setTimeout的匿名回调函数,执行后输出1.

按照这样的分析,我们输出的顺序应该是:2、4、3、1。

到此,我们大致弄清楚了异步任务的执行机制,但是关于macrotask 和 microtask和异步任务执行机制跟深入的研究,并不应该在此终结,还需要更加深入的研究。

参考文献:

1.What is the heck is the event-loop anyway
2.通过microtasks和macrotasks看JavaScript异步任务执行顺序
3.Tasks, microtasks, queues and schedules
4.理解 JavaScript 中的 macrotask 和 microtask