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

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

说明

图解 Google V8 学习笔记



宏任务和微任务


宏任务


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


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



微任务

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



为什么引入微任务?

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


微任务相关的知识栈

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

示意图:

bd7f179ae2f048c18d9591cd3fd29cdd.png


微任务的实现机制

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


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

例子:

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


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

d63fa40c118d4387b99228277bec086a.png

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

e9c0bab1a21d434c93009623b6a7ecd2.png


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


405553c4d59340c2ad29346bbc680d33.png


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

7574634694fb4c7e943b6c4ed4acf0f9.png


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

97a9c00f841f4b85b2eb34c08111bf51.png


栈溢出


例子:

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

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


示意图:


7ad51c4da6514ec9a5b4994d56910289.png




setTimeout 是怎么解决栈溢出的?


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


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

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


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

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

326d0dddb5194dcda1666b0759c67d29.png


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

356538297ab0412fbaa7618c1bf56774.png


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


23a0ade295324939aa6cea800bcca2ec.png


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

9403e821b5b144d4814d9883f4ea878a.png

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

c1b80938fbde4c07b0cf34743dccc22b.png



上面就是 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


2db900c2f0f342c1baddd398f4f17ff2.png


上面代码执行流程:

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

cd111fa471a74b099a09cc5dfb32321e.png



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

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

   执行 Promise.resolve,会触发一个 micro-foo 微任务,V8 会将该微任务添加进微任务队列。

   执行 setTimeout 方法,会触发了一个 macro-foo 宏任务,V8 会将该宏任务添加进消息队列。


dfb243ed7ec4442f872b2c89d1e95990.png

dfb243ed7ec4442f872b2c89d1e95990.png



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

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

   执行 Promise.resolve,会触发一个 micro-bar 微任务,V8 会将该微任务添加进微任务队列。

   执行 setTimeout 方法,会触发了一个 macro-bar 宏任务,V8 会将该宏任务添加进消息队列。


a46b122e8e124d1e85304722da301aed.png


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

ff93943e6e654afe99e75d713d281a0e.png


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

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


ecb213e361b34a4b9bd7e7a675470874.png


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


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

9f61927b6afb4d59b7d3258cfe2176f8.png


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

478ee64e969a42c5adffdbabe329b2d6.png



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

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

function kaimo() {
  kaimo()
}
kaimo()



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


71894f05be3d4f0092699e771a7a1be8.png

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

8faf6fe78b354ec9b81c014a44a47026.png


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

9a4ee3625f1942caa75bbbb8b27d62d3.png


重点在看一下第三种:由于 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。









目录
相关文章
|
Web App开发 缓存 JavaScript
图解 Google V8 # 13:字节码(一):V8为什么又重新引入字节码?
图解 Google V8 # 13:字节码(一):V8为什么又重新引入字节码?
236 0
图解 Google V8 # 13:字节码(一):V8为什么又重新引入字节码?
|
机器学习/深度学习 人工智能 自然语言处理
Google探索全新NLU任务「自然语言评估」,正式面试前让AI帮你热个身!
Google探索全新NLU任务「自然语言评估」,正式面试前让AI帮你热个身!
120 0
|
缓存 JavaScript 前端开发
图解 Google V8 # 22 :关于内存泄漏、内存膨胀、频繁垃圾回收的解决策略(完结篇)
图解 Google V8 # 22 :关于内存泄漏、内存膨胀、频繁垃圾回收的解决策略(完结篇)
307 0
图解 Google V8 # 22 :关于内存泄漏、内存膨胀、频繁垃圾回收的解决策略(完结篇)
|
Web App开发 JavaScript 前端开发
图解 Google V8 # 21 :垃圾回收(二):V8是如何优化垃圾回收器执行效率的?
图解 Google V8 # 21 :垃圾回收(二):V8是如何优化垃圾回收器执行效率的?
118 0
图解 Google V8 # 21 :垃圾回收(二):V8是如何优化垃圾回收器执行效率的?
|
算法 JavaScript Java
图解 Google V8 # 20 :垃圾回收(一):V8的两个垃圾回收器是如何工作的?
图解 Google V8 # 20 :垃圾回收(一):V8的两个垃圾回收器是如何工作的?
102 0
图解 Google V8 # 20 :垃圾回收(一):V8的两个垃圾回收器是如何工作的?
|
前端开发 JavaScript
图解 Google V8 # 19 :异步编程(二):V8 是如何实现 async/await 的?
图解 Google V8 # 19 :异步编程(二):V8 是如何实现 async/await 的?
132 0
图解 Google V8 # 19 :异步编程(二):V8 是如何实现 async/await 的?
|
消息中间件 程序员 Android开发
图解 Google V8 # 17:消息队列:V8是怎么实现回调函数的?
图解 Google V8 # 17:消息队列:V8是怎么实现回调函数的?
109 0
图解 Google V8 # 17:消息队列:V8是怎么实现回调函数的?
|
存储 缓存 索引
图解 Google V8 # 16:V8是怎么通过内联缓存来提升函数执行效率的?
图解 Google V8 # 16:V8是怎么通过内联缓存来提升函数执行效率的?
145 0
图解 Google V8 # 16:V8是怎么通过内联缓存来提升函数执行效率的?
|
JavaScript 前端开发 编译器
图解 Google V8 # 15:隐藏类:如何在内存中快速查找对象属性?
图解 Google V8 # 15:隐藏类:如何在内存中快速查找对象属性?
140 0
图解 Google V8 # 15:隐藏类:如何在内存中快速查找对象属性?
|
JavaScript 前端开发 Java
图解 Google V8 # 14:字节码(二):解释器是如何解释执行字节码的?
图解 Google V8 # 14:字节码(二):解释器是如何解释执行字节码的?
242 0
图解 Google V8 # 14:字节码(二):解释器是如何解释执行字节码的?