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

网友投稿 790 2022-09-04

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

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

说明

图解 Google V8 学习笔记

宏任务和微任务

宏任务

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

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

微任务

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

为什么引入微任务?

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

微任务相关的知识栈

微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的。基于微任务,又可以延伸出​​协程​​​、​​Promise​​​、​​Generator​​​、​​await/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 时,会为这段代码创建一个环境对象,微任务队列就是存放在该环境对象中的。

微任务的执行时机:

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

例子:

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)

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

foobarglobalmicro-foomicro-barmicro-globalmacro-foomacro-barmacro-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,) { // 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 # 11:堆和栈:函数调用是如何影响到内存布局的?​​​​MDN:MutationObserver​​

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:MeeGo系统和SailFish系统_我是亲民_新浪博客
下一篇:【底层原理】进程与线程的一个简单解释(线程池底层原理)
相关文章

 发表评论

暂时没有评论,来抢沙发吧~