Skip to main content

15 异步编程:V8如何实现微任务

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

微任务是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

引入微任务:

  • 主线程执行消息队列中宏任务的时间颗粒度太粗,无法胜任一些对精度和实时性要求较高的场景,微任务可以在实时性和效率之间做有效的权衡。
  • 改变现在的异步编程模型,可以使用同步形式的代码来编写异步调用。

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

调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。

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

V8 准备执行这段代码时,会先将全局执行上下文压入到调用栈中:

V8 开始在主线程上执行 foo 函数,创建 foo 函数的执行上下文,并将其压入栈中:

foo 函数又调用 bar 函数,当 V8 执行 bar 函数时,同样创建 bar 函数的执行上下文,并将其压入栈中:

bar 函数执行结束,V8 从栈中弹出 bar 函数的执行上下文:

foo 函数执行结束,V8 将 foo 函数的执行上下文从栈中弹出:

存在**栈溢出问题**

function foo() {
foo();
}
foo();

foo 函数内部嵌套调用自己,在调用 foo 函数时,栈会一直向上增长,由于栈空间在内存中是连续的,通常会限制调用栈的大小,函数嵌套层数过深时,过多的执行上下文堆积在栈中便会导致栈溢出:

可以使用 setTimeout 解决栈溢出问题,本质是将同步函数调用改成异步函数调用,异步调用是将 foo 封装成事件,并将其添加进消息队列中,主线程按照一定规则循环地从消息队列中读取下一个任务:

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

主线程从消息队列中取出需要执行的宏任务,进入代码的执行状态:

V8 执行 foo 函数,创建 foo 函数的执行上下文,并将其压入栈中:

当 V8 执行执行 foo 函数中的 setTimeout 时,setTimeout 会将 foo 函数封装成一个新的宏任务,并将其添加到消息队列中:

foo 函数执行结束,V8 会结束当前的宏任务,调用栈也会被清空:

当一个宏任务执行结束之后,主线程会一直重复取宏任务、执行宏任务的过程。通过 setTimeout 封装的回调宏任务,也会在某一时刻被主线取出并执行,这个执行过程,就是 foo 函数的调用过程:

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

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

宏任务需要先被放到消息队列中,如果某些宏任务的执行时间过久,就会影响到消息队列后面的宏任务的执行,这个影响是不可控的,因为无法知道前面的宏任务需要多久才能执行完成。

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

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

微任务的执行时机:

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

在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行。

function bar() {
console.log("bar");
Promise.resolve().then((str) => console.log("micro-bar"));
setTimeout((str) => console.log("macro-bar"), 0);
}

function foo() {
console.log("foo");
Promise.resolve().then((str) => console.log("micro-foo"));
setTimeout((str) => console.log("macro-foo"), 0);

bar();
}
foo();
console.log("global");
Promise.resolve().then((str) => console.log("micro-global"));
setTimeout((str) => console.log("macro-global"), 0);

V8 执行这段代码时,会将全局执行上下文压入调用栈中,并在执行上下文中创建一个空的微任务队列:

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

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

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

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

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

bar 函数执行结束并退出,bar 函数的执行上下文也会从栈中弹出,紧接着 foo 函数执行结束并退出,foo 函数的执行上下文也随之从栈中被弹出:

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

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

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

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

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

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

在微任务中循环地触发新的微任务

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

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

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

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