图解 Google V8 # 18 :异步编程:V8是如何实现微任务的?

Posted 凯小默

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了图解 Google V8 # 18 :异步编程:V8是如何实现微任务的?相关的知识,希望对你有一定的参考价值。

说明

图解 Google V8 学习笔记

宏任务和微任务

宏任务

指消息队列中的等待被主线程执行的事件。

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

微任务

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

为什么引入微任务?

由于主线程执行消息队列中宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而微任务可以在实时性和效率之间做一个有效的权衡。另外一个好处就是可以使用同步形式的代码来编写异步调用。

微任务相关的知识栈

微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的。基于微任务,又可以延伸出协程PromiseGeneratorawait/async 等现代前端经常使用的一些技术。

示意图:

微任务的实现机制

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

调用栈是如何管理主线程上函数调用的?

例子:

function bar() 

foo(fun)
  fun()

foo(bar)

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

2、V8 便开始在主线程上执行 foo 函数,首先它会创建 foo 函数的执行上下文,并将其压入栈中:

3、V8 执行 bar 函数时,同样要创建 bar 函数的执行上下文,并将其压入栈中:


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


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

栈溢出

例子:

function foo()
  foo()

foo()

由于栈空间在内存中是连续的,调用栈的大小有限制,上面代码嵌套层数过深时,会导致栈一直向上增长,而过多的执行上下文堆积在栈中便会导致栈溢出。

示意图:

setTimeout 是怎么解决栈溢出的?

setTimeout 的本质是将同步函数调用改成异步函数调用。

可以将上面的代码改成:将 foo 封装成事件,并将其添加进消息队列中,然后主线程再按照一定规则循环地从消息队列中读取下一个任务。

function foo() 
  setTimeout(foo, 0)

foo()

从调用栈、主线程、消息队列分析执行流程:

1、主线程会从消息队列中取出需要执行的宏任务:

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

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

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

5、刚才通过 setTimeout 封装的回调宏任务,会在在某一时刻被主线取出并执行:

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

注意:像 setTimeout 、XMLHttpRequest 这种 web APIs 是浏览器内核提供的,相当于宿主对 V8 的扩展。

微任务和宏任务的执行时机

V8 会为每个宏任务维护一个微任务队列。当 V8 执行一段 javascript 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。

微任务的执行时机:

  1. 微任务不会在当前的函数中被执行,不会导致栈的无限扩张。
  2. 在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的。

例子:

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)

输出结果:可以看到微任务是处于宏任务之前执行的。

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


上面代码执行流程:

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

2、执行 foo 函数的调用时:

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

3、foo 函数调用了 bar 函数时:

  • V8 创建 bar 函数的执行上下文,并将其压入栈中
  • 执行 Promise.resolve,会触发一个 micro-bar 微任务,V8 会将该微任务添加进微任务队列。
  • 执行 setTimeout 方法,会触发了一个 macro-bar 宏任务,V8 会将该宏任务添加进消息队列。

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

5、主线程执行完了 foo 函数之后:

  • 执行 Promise.resolve,会触发一个 micro-global 微任务,V8 会将该微任务添加进微任务队列。
  • 执行 setTimeout 方法,会触发了一个 macro-global 宏任务,V8 会将该宏任务添加进消息队列。

6、等到这段代码即将执行完成时,V8 便要销毁这段代码的环境对象,此时环境对象的析构函数被调用,这是 V8 执行微任务的一个检查点,V8 会检查是否存在微任务队列,如果有,会依次取出微任务,并按照顺行执行。

**析构函数(destructor) **:与构造函数相反,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。析构函数往往用来做“清理善后” 的工作(例如在建立对象时用 new 开辟了一片内存空间,delete 会自动调用析构函数后释放内存)。

7、最后微任务队列中的所有微任务都执行完成之后,当前的宏任务也就执行结束了,接下来主线程会继续重复执行取出任务、执行任务的过程。

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

图解 Google V8 # 11:堆和栈:函数调用是如何影响到内存布局的?文章里,我们有过三个例子的对比:

function kaimo() 
	kaimo()

kaimo()

1、在同一个任务中重复调用嵌套的 kaimo 函数。V8 会报栈溢出的错误:

2、使用 setTimeout 让 kaimo 函数在不同的任务中执行。V8 能够正确执行。


3、使用 Promise.resolve() 在同一个任务中执行 kaimo 函数,但是却不是嵌套执行。

重点在看一下第三种:由于 V8 每次执行微任务时,都会退出当前 kaimo 函数的调用栈,所以这段代码是不会造成栈溢出的。而这个微任务就是调用 kaimo 函数本身,所以在执行微任务的过程中,需要继续调用 kaimo 函数,在执行 kaimo 函数的过程中,又会触发了同样的微任务。那么这个循环就会一直持续下去,当前的宏任务无法退出,也就意味着消息队列中其他的宏任务是无法被执行的,比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中,页面无法响应这些事件,具体的体现就是页面的卡死。

拓展:MutationObserver

MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。

 // 选择需要观察变动的节点
const targetNode = document.getElementById('some-id');

// 观察器的配置(需要观察什么变动)
const config =  attributes: true, childList: true, subtree: true ;

// 当观察到变动时执行的回调函数
const callback = function(mutationsList, observer) 
    // Use traditional 'for loops' for IE 11
    for(let mutation of mutationsList) 
        if (mutation.type === 'childList') 
            console.log('A child node has been added or removed.');
        
        else if (mutation.type === 'attributes') 
            console.log('The ' + mutation.attributeName + ' attribute was modified.');
        
    
;

// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);

// 以上述配置开始观察目标节点
observer.observe(targetNode, config);

// 之后,可停止观察
observer.disconnect();

MutationObserver 是一个微任务,通过浏览器的 requestIdleCallback,在浏览器每一帧的空闲时间执行 MutationObserver 监听的回调,该监听是不影响主线程的,但是回调会阻塞主线程。当然有一个限制,如果100ms 内主线程一直处于未空闲状态,那会强制触发 MutationObserver。

参考资料

以上是关于图解 Google V8 # 18 :异步编程:V8是如何实现微任务的?的主要内容,如果未能解决你的问题,请参考以下文章

图解 Google V8 # 17:消息队列:V8是怎么实现回调函数的?

图解 Google V8 # 16:V8是怎么通过内联缓存来提升函数执行效率的?

图解Google V8,搞懂 JavaScript 执行逻辑

图解 Google V8 # 01:V8 是如何执行一段 JavaScript 代码的?

图解 Google V8 # 06:原型链:V8是如何实现对象继承的?

图解 Google V8 # 08:类型转换:V8是怎么实现 1 + “2” 的?