美文网首页大前端
JavaScript中的事件循环与消息队列

JavaScript中的事件循环与消息队列

作者: jeff_nz | 来源:发表于2019-01-31 14:53 被阅读0次

    2019-4-01更新:采纳JSC引擎的术语,我们把宿主(浏览器、Node环境)发起的任务称为宏任务(如SetTimeout),把JavaScript引擎发起的任务称为微观任务(如Promise)。


    我们在接触到JavaScript语言的时候就经常听到别人介绍JavaScript 是单线程、异步、非阻塞、解释型脚本语言。
    究竟应该如何理解这句话呢?

    确切的说,对于开发者的开发过程来说,js确实只有一个线程(由JS引擎维护),这个线程用来负责解释和执行JavaScript代码,我们可以称其为主线程。例如在控制台输入如下代码:

    console.log("a");
    console.log("b");
    console.log("c");
    //依次输出a,b,c
    

    可以看出,这段代码在主线程上是按照顺序执行的。但是我们平时的任务处理可能并不会直接获取到结果,这种情况下如果仍然使用同步方法,例如发起一个ajax请求,大概500ms后受到响应,在这个过程中,后面的任务就会被阻塞,浏览器页面就会阻塞所有用户交互,呈“卡死”状态。

    console.log("a");
    $.ajax({
      url:"xxx",
      async:false, //同步请求ajax
      success:function(){
        console.log("b");
      }
    })
    console.log("c");
    

    这种同步的方式对于用户操作非常不友好,所以大部分耗时的任务在JS中都会通过异步的方式实现。虽然js引擎只维护一个主线程用来解释执行JS代码,但实际上浏览器环境中还存在其他的线程,例如处理AJAX,DOM,定时器等,我们可以称他们为工作线程。同时浏览器中还维护了一个消息队列,主线程会将执行过程中遇到的异步请求发送给这个消息队列,等到主线程空闲时再来执行消息队列中的任务。
    同步任务的缺点是阻塞,异步任务的缺点是会使代码执行顺序难以判断。
    两者比较一下我们还是更倾向于后者。
    到目前为止,我们已经涉及到了几个名词,主线程,js引擎,事件循环,消息队列等。接下来会对这些名词一一进行解释。

    js引擎

    我们所熟悉的引擎是chrome浏览器中和node.js中使用的V8引擎。它的大致组成如图:


    v8引擎

    这个引擎主要由两个部分组成,内存堆和调用栈。(只负责取消息,不负责生产消息)
    内存堆:进行内存分配。如变量赋值。
    调用栈:这是代码在栈帧中执行的地方。调用栈中顺序执行主线程的代码,当调用栈中为空时,js引擎会去消息队列取消息。取到后就执行。JavaScript是单线程的编程语言,意味着它有一个单一的调用栈。因此它只能在同一时间做一件事情。调用栈是一种数据结构,它基本上记录了我们在程序中的什么位置。如果我们步入一个函数中,我们会把这些数据放在堆栈的顶部。如果我们从一个函数中返回,这些数据将会从栈顶弹出。这就是堆栈的用途。调用栈中的每个条目叫做栈帧。当我们在chrome调试窗口中看到抛出的错误时,就能够看到大致的调用顺序。


    js运行时

    运行时

    我们经常使用的一些API并不是js引擎中提供的,例如setTimeout。它们其实是在浏览器中提供的,也就是运行时提供的,因此,实际上除了JavaScript引擎以外,还有其他的组件。其中有个组件就是由浏览器提供的,叫Web APIs,像DOM,AJAX,setTimeout等等。
    然后还有就是非常受欢迎的事件循环和回调队列。
    运行时负责给引擎线程发送消息,只负责生产消息,不负责取消息。

    消息队列和事件循环

    主线程在执行过程中遇到了异步任务,就发起函数或者称为注册函数,通过event loop线程通知相应的工作线程(如ajax,dom,setTimout等),同时主线程继续向后执行,不会等待。等到工作线程完成了任务,eventloop线程会将消息添加到消息队列中,如果此时主线程上调用栈为空就执行消息队列中排在最前面的消息,依次执行。
    新的消息进入队列的时候,会自动排在队列的尾端。
    单线程意味着js任务需要排队,如果前一个任务出现大量的耗时操作,后面的任务得不到执行,任务的积累会导致页面的“假死”。这也是js编程一直在强调需要回避的“坑”。
    主线程会循环上述步骤,事件循环就是主线程重复从消息队列中取消息、执行的过程。
    需要注意的是 GUI渲染线程与JS引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。因此页面渲染都是在js引擎主线程调用栈为空时进行的。

    其实 事件循环 机制和 消息队列 的维护是由事件触发线程控制的。

    事件触发线程 同样是浏览器渲染引擎提供的,它会维护一个 消息队列

    JS引擎线程遇到异步(DOM事件监听、网络请求、setTimeout计时器等...),会交给相应的线程单独去维护异步任务,等待某个时机(计时器结束、网络请求成功、用户点击DOM),然后由 事件触发线程 将异步对应的 回调函数 加入到消息队列中,消息队列中的回调函数等待被执行。

    同时,JS引擎线程会维护一个 执行栈,同步代码会依次加入执行栈然后执行,结束会退出执行栈。
    如果执行栈里的任务执行完成,即执行栈为空的时候(即JS引擎线程空闲),事件触发线程才会从消息队列取出一个任务(即异步的回调函数)放入执行栈中执行。

    执行顺序

    了解了事件循环和消息队列之后,接下来就是弄清楚当同步任务和异步任务都存在时,代码执行的顺序究竟是怎么样的。
    举个例子:

    console.log("a");
    setTimeout(function(){
      console.log("b")},1000
    );
    console.log("c");
    

    相信所有人都知道执行顺序是 a, c , b。
    如果变化一下:

    console.log("a");
    setTimeout(function(){
      console.log("b")},0
    );
    console.log("c");
    

    相信通过上面的内容,大部分人也都知道执行顺序还是a,c,b。setTimeout在主线程执行时被添加到了消息队列中,等待主线程调用栈为空时,再从消息队列中取出执行。因此setTimeout中的延时时间并非确切的执行时间,实际上应该理解为添加到消息队列中的延迟时间。以上述代码为例,如果console.log("c")处是一个计算量很大的任务,或者消息队列中已经存在了若干个等待处理的消息。setTimeout都将延迟都将大于设置的延迟时间。

    ES6 Promise

    以上的内容在ES6之前就基本cover了执行顺序的问题,但是在ES6引入了promise后,产生了一个新的名词”微任务(microtask)“。微任务的执行顺序与之前我们所说的任务(我们可以称之为”宏任务“)是不同的。

    console.log('script start')
    
    setTimeout(function() {
        console.log('timer over')
    }, 0)
    
    Promise.resolve().then(function() {
        console.log('promise1')
    }).then(function() {
        console.log('promise2')
    })
    
    console.log('script end')
    

    输出的结果是:
    script start
    script end
    promise1
    promise2
    timer over
    你答对了吗?
    我猜这里让你困惑的一定是为什么promise1和promise2在timer over之前输出了。下面我们来解释一下微任务这个概念。

    • 一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。
    • 任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。
    • macro-task大概包括:script(整体代码), setTimeout, setInterval, setImmediate, I/O, UI rendering。
    • micro-task大概包括: process.nextTick, Promise, Object.observe(已废弃), MutationObserver(H5新特性)
    • setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。
    • 来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。
    • 事件循环的顺序,决定了JavaScript代码的执行顺序。它从script(整体代码)开始第一次循环。之后全局上下文进入函数调用栈。直到调用栈清空(只剩全局),然后执行所有的micro-task。当所有可执行的micro-task执行完毕之后。循环再次从macro-task开始,找到其中一个任务队列执行完毕,然后再执行所有的micro-task,这样一直循环下去。
    • 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成。

    举个例子:

    setTimeout(function() {
        console.log('timeout1');
    })
     
    new Promise(function(resolve) {
        console.log('promise1');
        for(var i = 0; i < 1000; i++) {
            i == 99 && resolve();
        }
        console.log('promise2');
    }).then(function() {
        console.log('then1');
    })
     
    console.log('global1');
    

    执行结果为:
    promise1
    promise2
    global1
    then1
    timeout1
    分析一下代码,首先程序开始执行,遇到setTimeout时将它添加到消息队列,等待后续处理,遇到Promise时会创建微任务(.then()里面的回调),注意此时new promise构造函数中的代码还是同步执行的,只有.then中的回调会被添加到微任务队列。因此会连续输出promise1和promise2。继续执行到console.log('global1')输出global1,到此调用栈中已经为空。此时微任务队列里有一个任务.then,宏任务队列里也有一个任务setTimout。
    microtask必然是在某个宏任务执行的时候创建的,而在下一个宏任务开始之前,浏览器会对页面重新渲染(task >> 渲染 >> 下一个task(从任务队列中取一个))。同时,在上一个宏任务执行完成后,渲染页面之前,会执行当前微任务队列中的所有微任务。也就是说,在某一个宏任务执行完后,在重新渲染与开始下一个宏任务之前,就会将在它执行期间产生的所有微任务都执行完毕(在渲染前)。因此会执行.then输出then1,然后进行下一轮事件循环,取出任务队列中的setTimeout输出timeout1。
    总结一下执行机制:

    1. 执行一个宏任务(栈中没有就从事件队列中获取)

    2. 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中

    3. 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)

    4. 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染

    5. 渲染完毕后,JS引擎线程继续,开始下一个宏任务(从宏任务队列中获取)

    参考文章:
    https://www.cnblogs.com/jymz/p/7900439.html
    https://juejin.im/post/5a6547d0f265da3e283a1df7#heading-6
    https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

    相关文章

      网友评论

        本文标题:JavaScript中的事件循环与消息队列

        本文链接:https://www.haomeiwen.com/subject/raqksqtx.html