<audio id="audio" title="18 | 异步编程（一）：V8是如何实现微任务的？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/f0/c02adb1ab8eaf240c9ba53d0ee3ac0f0.mp3"></audio>

你好，我是李兵。

上节我们介绍了通用的UI线程架构，每个UI线程都拥有一个消息队列，所有的待执行的事件都会被添加进消息队列中，UI线程会按照一定规则，循环地取出消息队列中的事件，并执行事件。而JavaScript最初也是运行在UI线程中的。换句话说，JavaScript语言就是基于这套通用的UI线程架构而设计的。

基于这套基础UI框架，JavaScript又延伸出很多新的技术，其中应用最广泛的当属**宏任务**和**微任务**。

**宏任务**很简单，**就是指消息队列中的等待被主线程执行的事件。**每个宏任务在执行时，V8都会重新创建栈，然后随着宏任务中函数调用，栈也随之变化，最终，当该宏任务执行结束时，整个栈又会被清空，接着主线程继续执行下一个宏任务。

**微任务**稍微复杂一点，其实你可以把**微任务看成是一个需要异步执行的函数，执行时机是在主函数执行结束之后、当前宏任务结束之前。**

JavaScript中之所以要引入微任务，主要是由于主线程执行消息队列中宏任务的时间颗粒度太粗了，无法胜任一些对精度和实时性要求较高的场景，那么**微任务可以在实时性和效率之间做一个有效的权衡**。另外使用微任务，可以改变我们现在的异步编程模型，使得我们可以使用同步形式的代码来编写异步调用。

虽然微任务如此重要，但是理解起来并不是太容易。我们先看下和微任务相关的知识栈，具体内容如下图所示：

<img src="https://static001.geekbang.org/resource/image/1b/46/1b0ea2180dd2406b988b424cc2933746.jpg" alt="">

从图中可以看出，微任务是基于消息队列、事件循环、UI主线程还有堆栈而来的，然后基于微任务，又可以延伸出协程、Promise、Generator、await/async等现代前端经常使用的一些技术。也就是说，如果对**消息队列、主线程**还有**调用栈**理解的不够深入，你在研究微任务时，就容易一头雾水。

今天，我们就先来打通微任务的底层技术，搞懂消息队列、主线程、调用栈的关联，然后抽丝剥茧地剖析微任务的实现机制。

## 主线程、调用栈、消息队列

我们先从**主线程**和**调用栈**开始分析。我们知道，**调用栈是一种数据结构，****用来管理在主线程上执行的函数的调用关系。**接下来我们通过执行下面这段代码，来分析下调用栈是如何管理主线程上函数调用的。

```
function bar() {
}
foo(fun){
  fun()
}
foo(bar)

```

当V8准备执行这段代码时，会先将全局执行上下文压入到调用栈中，如下图所示：

<img src="https://static001.geekbang.org/resource/image/82/62/82360525fb2bb064eb6a9916e4a81062.jpg" alt="">

然后V8便开始在主线程上执行foo函数，首先它会创建foo函数的执行上下文，并将其压入栈中，那么此时调用栈、主线程的关系如下图所示：

<img src="https://static001.geekbang.org/resource/image/6b/1e/6ba964d00e3528b4040a4ae12cc91e1e.jpg" alt="" title="准备执行">

然后，foo函数又调用了bar函数，那么当V8执行bar函数时，同样要创建bar函数的执行上下文，并将其压入栈中，最终效果如下图所示：

<img src="https://static001.geekbang.org/resource/image/28/fa/288d80945985a1cf0c0a4d4be9674ffa.jpg" alt="" title="调用bar函数">

等bar函数执行结束，V8就会从栈中弹出bar函数的执行上下文，此时的效果如下所示：

<img src="https://static001.geekbang.org/resource/image/9b/a6/9b7474d3e2d5ca840f2119ca64d40da6.jpg" alt="" title="bar函数执行结束">

最后，foo函数执行结束，V8会将foo函数的执行上下文从栈中弹出，效果如下所示：

<img src="https://static001.geekbang.org/resource/image/e9/ab/e9e2e01d56b9546745eaa24ac00d74ab.jpg" alt="" title="foo函数执行结束">

以上就是调用栈管理主线程上函数调用的方式，不过，这种方式会带来一种问题，那就是栈溢出。比如下面这段代码：

```
function foo(){
  foo()
}
foo()

```

由于foo函数内部嵌套调用它自己，所以在调用foo函数的时候，它的栈会一直向上增长，但是由于栈空间在内存中是连续的，所以通常我们都会限制调用栈的大小，如果当函数嵌套层数过深时，过多的执行上下文堆积在栈中便会导致栈溢出，最终如下图所示：

<img src="https://static001.geekbang.org/resource/image/81/25/814b88dc157ef6f43403f46e271ae625.jpg" alt="" title="栈溢出">

我们可以使用setTimeout来解决栈溢出的问题，setTimeout的本质是将同步函数调用改成异步函数调用，这里的异步调用是将foo封装成事件，并将其添加进**消息队列**中，然后主线程再按照一定规则循环地从消息队列中读取下一个任务。使用setTimeout改造后代码代码如下所示：

```
function foo() {
  setTimeout(foo, 0)
}
foo()

```

那么现在我们就可以从**调用栈**、**主线程**、**消息队列**这三者的角度来分析这段代码的执行流程了。

首先，主线程会从消息队列中取出需要执行的宏任务，假设当前取出的任务就是要执行的这段代码，这时候主线程便会进入代码的执行状态。这时关于主线程、消息队列、调用栈的关系如下图所示：

<img src="https://static001.geekbang.org/resource/image/bf/65/bfea2ecc835f8324e034db33339ed965.jpg" alt="">

接下来V8就要执行foo函数了，同样执行foo函数时，会创建foo函数的执行上下文，并将其压入栈中，最终效果如下图所示：

<img src="https://static001.geekbang.org/resource/image/48/dc/48706d55e9c490d84d676b5000d56bdc.jpg" alt="">

当V8执行执行foo函数中的setTimeout时，setTimeout会将foo函数封装成一个新的宏任务，并将其添加到消息队列中，在V8执行setTimeout函数时的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/dc/e1/dc84bd1ee456789905e734415eecdce1.jpg" alt="">

等foo函数执行结束，V8就会结束当前的宏任务，调用栈也会被清空，调用栈被清空后状态如下图所示：

<img src="https://static001.geekbang.org/resource/image/2a/89/2aeacb651b6aec591105ce2e4a8ed889.jpg" alt="">

当一个宏任务执行结束之后，忙碌的主线程依然不会闲下来，它会一直重复这个取宏任务、执行宏任务的过程。刚才通过setTimeout封装的回调宏任务，也会在某一时刻被主线取出并执行，这个执行过程，就是foo函数的调用过程。具体示意图如下所示：

<img src="https://static001.geekbang.org/resource/image/26/bc/260d8a7294472f4ee7b194bdb7d513bc.jpg" alt="">

因为foo函数并不是在当前的父函数内部被执行的，而是封装成了宏任务，并丢进了消息队列中，然后等待主线程从消息队列中取出该任务，再执行该回调函数foo，这样就解决了栈溢出的问题。

## 微任务解决了宏任务执行时机不可控的问题

不过，对于栈溢出问题，虽然我们可以通过将某些函数封装成宏任务的方式来解决，但是宏任务需要先被放到消息队列中，如果某些宏任务的执行时间过久，那么就会影响到消息队列后面的宏任务的执行，而且这个影响是不可控的，因为你无法知道前面的宏任务需要多久才能执行完成。

于是JavaScript中又引入了微任务，微任务会在当前的任务快要执行结束时执行，利用微任务，你就能比较精准地控制你的回调函数的执行时机。

通俗地理解，V8会为每个宏任务维护一个微任务队列。当V8执行一段JavaScript时，会为这段代码创建一个环境对象，微任务队列就是存放在该环境对象中的。当你通过Promise.resolve生成一个微任务，该微任务会被V8自动添加进微任务队列，等整段代码快要执行结束时，该环境对象也随之被销毁，但是在销毁之前，V8会先处理微任务队列中的微任务。

理解微任务的执行时机，你只需要记住以下两点：

- 首先，如果当前的任务中产生了一个微任务，通过Promise.resolve()或者Promise.reject()都会触发微任务，触发的微任务不会在当前的函数中被执行，所以执行微任务时，不会导致栈的无限扩张；
- 其次，和异步调用不同，微任务依然会在当前任务执行结束之前被执行，这也就意味着在当前微任务执行结束之前，消息队列中的其他任务是不可能被执行的。

因此在函数内部触发的微任务，一定比在函数内部触发的宏任务要优先执行。为了验证这个观点，我们来分析一段代码：

```
function bar(){
  console.log('bar')
  Promise.resolve().then(
    (str) =&gt;console.log('micro-bar')
  ) 
  setTimeout((str) =&gt;console.log('macro-bar'),0)
}


function foo() {
  console.log('foo')
  Promise.resolve().then(
    (str) =&gt;console.log('micro-foo')
  ) 
  setTimeout((str) =&gt;console.log('macro-foo'),0)
  
  bar()
}
foo()
console.log('global')
Promise.resolve().then(
  (str) =&gt;console.log('micro-global')
) 
setTimeout((str) =&gt;console.log('macro-global'),0)

```

在这段代码中，包含了通过setTimeout宏任务和通过Promise.resolve创建的微任务，你认为最终打印出来的顺序是什么？

执行这段代码，我们发现最终打印出来的顺序是：

```
foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global

```

我们可以清晰地看出，微任务是处于宏任务之前执行的。接下来，我们就来详细分析下V8是怎么执行这段JavaScript代码的。

首先，当V8执行这段代码时，会将全局执行上下文压入调用栈中，并在执行上下文中创建一个空的微任务队列。那么此时：

- 调用栈中包含了全局执行上下文；
- 微任务队列为空。

此时的消息队列、主线程、调用栈的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/b9/2a/b9bf0027185405e762b46cd4b77c892a.jpg" alt="">

然后，执行foo函数的调用，V8会先创建foo函数的执行上下文，并将其压入到栈中。接着执行Promise.resolve，这会触发一个micro-foo1微任务，V8会将该微任务添加进微任务队列。然后执行setTimeout方法。该方法会触发了一个macro-foo1宏任务，V8会将该宏任务添加进消息队列。那么此时：

- 调用栈中包含了**全局执行上下文**、**foo函数的执行上下文**；
- 微任务队列有了一个微任务，**micro-foo**；
- 消息队列中存放了一个通过setTimeout设置的宏任务，**macro-foo。**

此时的消息队列、主线程和调用栈的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/f8/2d/f85b1b316f669316cef23c4714d4ce2d.jpg" alt="">

接下来，foo函数调用了bar函数，那么V8需要再创建bar函数的执行上下文，并将其压入栈中，接着执行Promise.resolve，这会触发一个micro-bar微任务，该微任务会被添加进微任务队列。然后执行setTimeout方法，这也会触发一个macro-bar宏任务，宏任务同样也会被添加进消息队列。那么此时：

- 调用栈中包含了**全局执行上下文**、**foo函数的执行上下文、bar的执行上下文**；
- 微任务队列中的微任务是**micro-foo、micro-bar**；
- 消息队列中，宏任务的状态是**macro-foo、macro-bar。**

此时的消息队列、主线程和调用栈的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/33/1c/338875c3ff58e389af86cf2acab5bd1c.jpg" alt="">

接下来，bar函数执行结束并退出，bar函数的执行上下文也会从栈中弹出，紧接着foo函数执行结束并退出，foo函数的执行上下文也随之从栈中被弹出。那么此时：

- 调用栈中包含了**全局执行上下文，**因为bar函数和foo函数都执行结束了，所以它们的执行上下文都被弹出调用栈了；
- 微任务队列中的微任务同样还是**micro-foo、micro-bar**；
- 消息队列中宏任务的状态同样还是**macro-foo、macro-bar。**

此时的消息队列、主线程和调用栈的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/d2/e0/d24acef2cc39b1688dff9f19b9cdb9e0.jpg" alt="">

主线程执行完了foo函数，紧接着就要执行全局环境中的代码Promise.resolve了，这会触发一个micro-global微任务，V8会将该微任务添加进微任务队列。接着又执行setTimeout方法，该方法会触发了一个macro-global宏任务，V8会将该宏任务添加进消息队列。那么此时：

- 调用栈中包含的是**全局执行上下文**；
- 微任务队列中的微任务同样还是**micro-foo、micro-bar、micro-global**；
- 消息队列中宏任务的状态同样还是**macro-foo、macro-bar、macro-global。**

此时的消息队列、主线程和调用栈的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/34/db/34fb1a481b60708360b48ba04821f6db.jpg" alt="">

等到这段代码即将执行完成时，V8便要销毁这段代码的环境对象，此时环境对象的析构函数被调用（注意，这里的析构函数是C++中的概念），这里就是V8执行微任务的一个检查点，这时候V8会检查微任务队列，如果微任务队列中存在微任务，那么V8会依次取出微任务，并按照顺行执行。因为微任务队列中的任务分别是：micro-foo、micro-bar、micro-global，所以执行的顺序也是如此。

此时的消息队列、主线程和调用栈的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/26/c9/267e549592913e25bdb6dfe716eeddc9.jpg" alt="">

等微任务队列中的所有微任务都执行完成之后，当前的宏任务也就执行结束了，接下来主线程会继续重复执行取出任务、执行任务的过程。由于正常情况下，取出宏任务的顺序是按照先进先出的顺序，所有最后打印出来的顺序是：macro-foo、macro-bar、macro-global。

等所有的任务执行完成之后，消息队列、主线程和调用栈的状态图如下所示：

<img src="https://static001.geekbang.org/resource/image/6c/4a/6caa4a24f1ddd918af62f6dbcb1c464a.jpg" alt="">

以上就是完整的执行流程的分析，到这里，相信你已经了解微任务和宏任务的执行时机是不同的了，微任务是在当前的任务快要执行结束之前执行的，宏任务是消息队列中的任务，主线程执行完一个宏任务之后，便会接着从消息队列中取出下一个宏任务并执行。

## 能否在微任务中循环地触发新的微任务？

既然宏任务和微任务都是异步调用，只是执行的时机不同，那能不能在setTimeout解决栈溢出的问题时，把触发宏任务改成是触发微任务呢？

比如，我们将代码改为：

```
function foo() {
  return Promise.resolve().then(foo)
}
foo()

```

当执行foo函数时，由于foo函数中调用了Promise.resolve()，这会触发一个微任务，那么此时，V8会将该微任务添加进微任务队列中，退出当前foo函数的执行。

然后，V8在准备退出当前的宏任务之前，会检查微任务队列，发现微任务队列中有一个微任务，于是先执行微任务。由于这个微任务就是调用foo函数本身，所以在执行微任务的过程中，需要继续调用foo函数，在执行foo函数的过程中，又会触发了同样的微任务。

那么这个循环就会一直持续下去，当前的宏任务无法退出，也就意味着消息队列中其他的宏任务是无法被执行的，比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中，页面无法响应这些事件，具体的体现就是页面的卡死。

不过，由于V8每次执行微任务时，都会退出当前foo函数的调用栈，所以这段代码是不会造成栈溢出的。

## 总结

这节课我们主要从**调用栈**、**主线程**、**消息队列**这三者关联的角度来分析了微任务。

调用栈是一种数据结构，用来管理在主线程上执行的函数的调用关系。主线在执行任务的过程中，如果函数的调用层次过深，可能造成栈溢出的错误，我们可以使用setTimeout来解决栈溢出的问题。

setTimeout的本质是将同步函数调用改成异步函数调用，这里的异步调用是将回调函数封装成宏任务，并将其添加进**消息队列**中，然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。

消息队列中事件又被称为宏任务，不过，宏任务的时间颗粒度太粗了，无法胜任一些对精度和实时性要求较高的场景，而**微任务可以在实时性和效率之间做有效的权衡**。

微任务之所以能实现这样的效果，主要取决于微任务的执行时机，**微任务其实是一个需要异步执行的函数，执行时机是在主函数执行结束之后、当前宏任务结束之前。**

因为微任务依然是在当前的任务中执行的，所以如果在微任务中循环触发新的微任务，那么将导致消息队列中的其他任务没有机会被执行。

## 思考题

浏览器中的MutationObserver接口提供了监视对DOM树所做更改的能力，它在内部也使用了微任务的技术，那么今天留给你的作业是，查找MutationObserver相关资料，分析它是如何工作的，其中微任务的作用是什么？欢迎你在留言区与我分享讨论。

感谢你的阅读，如果你觉得这一讲的内容对你有所启发，也欢迎把它分享给你的朋友。
