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


    说明

    图解 Google V8 学习笔记

    宏任务和微任务

    宏任务

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

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

    微任务

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

    为什么引入微任务?

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

    微任务相关的知识栈

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

    示意图:

    在这里插入图片描述

    微任务的实现机制

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

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

    例子:

    function bar() {
    }
    foo(fun){
      fun()
    }
    foo(bar)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

    在这里插入图片描述

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

    在这里插入图片描述

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

    在这里插入图片描述
    4、bar 函数执行结束,V8 就会从栈中弹出 bar 函数的执行上下文:

    在这里插入图片描述
    5、最后,foo 函数执行结束,V8 会将 foo 函数的执行上下文从栈中弹出:

    在这里插入图片描述

    栈溢出

    例子:

    function foo(){
      foo()
    }
    foo()
    
    • 1
    • 2
    • 3
    • 4

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

    示意图:

    在这里插入图片描述

    setTimeout 是怎么解决栈溢出的?

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

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

    function foo() {
      setTimeout(foo, 0)
    }
    foo()
    
    • 1
    • 2
    • 3
    • 4

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

    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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

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

    foo
    bar
    global
    micro-foo
    micro-bar
    micro-global
    macro-foo
    macro-bar
    macro-global
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在这里插入图片描述
    上面代码执行流程:

    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
    • 2
    • 3
    • 4

    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();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

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

    参考资料

  • 相关阅读:
    文献管理软件Zotero之插件篇(3)
    y116.第七章 服务网格与治理-Istio从入门到精通 -- Istio基础(二)
    VScode折叠代码
    express学习40-多人管理31数据分页2
    android 12.0 去掉未知来源弹窗 默认授予安装未知来源权限
    mysql索引为啥使用B+tree?
    (八)cookieAndSession——PHP
    Net6 用imagesharp 实现跨平台图片处理并存入oss
    2024.05.10作业
    前端学习案例-重写foreach
  • 原文地址:https://blog.csdn.net/kaimo313/article/details/125408324