关于Event Loop

前言

Event Loop也就是事件循环,是个老生常谈的问题了,而且都是些概念性的东西,略显枯燥,所以我也一直没有对这块进行过整理,奈何面试官总是喜欢去考这一块的知识,没办法只能硬着头皮进行学习,这篇文章也是对我学习到的一点关于Event Loop的只是进行的总结

JavaScript是一门单线程语言

大家都知道,JS是一门单线程非阻塞的语言,在执行任何任务的时候,都通过一个主线程来进行。试想一种情况: 如果JS是多线程的原因,那么同时有两个线程,一个线程对dom元素添加事件,另一个线程则是删除这个dom,那么这种情况要如何处理呢?
为了避免这样的情况发生,当时的布兰登(JavaScript创造者)只能将其设计成单线程非阻塞的语言,用以确保操作上的统一性。
也正是因为JS的单线程特性,也就是所有操作都进行排队,每次都只能执行一个任务,那么为了保证一些耗时较长的操作不会阻塞后续任务的执行,应该如何做呢? 当然就是使用Event Loop机制了。
但是由于在浏览器中的Event Loop是各大厂商根据规范自行实现的,而node中的Event Loop是根据libuv这个库实现的,所以会有不同,因此要分开进行讨论。

浏览器中的事件循环机制

  • 关于调用栈和异步事件处理
    • 调用栈
      首先我们来看如下代码,最开始声明了两个变量a和b,然后按照顺序分别打印出他们:


      image.png

      那么JS是如何执行他们的呢?
      答案是进行排队,然后按照顺序调用,这个排队的地方就是调用栈,上面代码的调用栈顺序如下:


      image.png

      执行完毕后,该调用栈被销毁,之后开始下一个调用栈的堆栈和执行,这个过程不断重复就是事件循环。
      • 异步事件处理
        讨论这点的时候我们先抛开Promise以及async/await,只从最开始的setTimeOut(setIntervalsetTimeOut机制类似,所以不讨论)来看,大家都知道setTimeOut是浏览器自行实现的用于处理异步操作的方法,用法如下,打印结果是123:
        image.png

        上面代码的意思是先打印一个1,然后将console.log(3)放入到下一个事件循环的调用栈的首位,然后打印一个2,到了下一个事件循环期间,再打印一个3,如下图所示:
        image.png

        在该例子中,最多只是存在两个队列,但是后来ECMAScript设计组推出了Promiseasync/await,这种情况就产生了变化,因为他引入了一个新的队列。
    • macro task和micro task
      macro task一般叫做宏任务,micro task叫做微任务,上面例子讨论的只是在宏任务阶段所发生的事,而在Promiseasync/await被引入后,也同时引入了微任务的概念。也就是说setTimeOut和同步的操作是属于宏任务阶段的,而Promiseasync/await是属于微任务队列的。
      那么什么是微任务呢?
      微任务实际上就是另外一个队列,这个队列中被放入的操作是在前一个宏任务阶段的调用栈中的操作被执行完毕后就立刻开始执行的,执行的顺序为:

宏任务队列 -> 微任务队列 -> 宏任务队列

那么也就是说Promiseasync/await的操作会在setTimeOut的操作之前被执行。
来看下面例子:

image.png

可以很清晰的看出来,先是打印1,然后打印Promise中2,最后才是打印setTimeOut中的3,虽然他们的顺序是反过来的,反映到图就是如下:
image.png

node中的事件循环

node中的事件循环是基于libuv库实现的,所以与上面浏览器中的会有所不同,两者不可混为一谈。
并且在node中,存在的处理异步的方法比浏览器要多,除了setTimeOutsetInterval以及Promiseasync/await之外,还存在setImmediateprocess.nexttick这两种方法,他们的执行时机也有所不同。
首先还是来看看node中事件循环的阶段吧。

  • 阶段
    在node中,事件循环分为如下几个阶段:


    image.png

    他们对应的作用如下:

    • timers: 执行setTimeOutsetInterval中的操作
    • I/O callbacks: 执行系统操作回调事件,例如TCP报错等
    • idle, prepare: 该阶段用于内部使用,不用管
    • poll: 执行同步代码,注意这是启用事件队列时候首先进入的阶段,而不是timers,node有可能会被阻塞在该阶段
    • check: 执行setImmediate中的操作
    • close callbacks: 执行一些事件关闭的回调操作,例如socket.on(close, xxx)的回调
  • 执行顺序

    • 首先进入到poll阶段,查看poll队列中是否有任务,有任务的话就按照先进先出的顺序进行执行,当任务执行完毕,poll队列为空时候又或者是poll计时器到达最大限制时候(这个最大限时不是固定的),就将setTimeOutsetInterval中的操作放入到timers队列中,将setImmediate中的操作放入到check队列中,然后进入check阶段
    • check
      执行check队列中的setImmediate中的操作
    • close callbacks
      执行时间关闭回调
    • timers
      执行timers队列中setTimeOutsetInterval中的操作
    • I/O callbacks
      执行系统操作回调事件
  • setTimeOut执行超时问题
    setTimeOut的第二个参数用于设置执行延后的时间,以毫秒(ms)为单位,但是这个延时的操作其实并不准确,因为在poll阶段有可能因为被阻塞而导致延后时间边长,比如下面的例子就可以看出延时到了2800多毫秒才执行了setTimeOut中的操作:

    image.png

    并且上面这个理由有个有趣的地方,当我不对数字n进行访问的时候,延时是2800多毫秒,当我在setTimeOut中对n进行访问的时候,延时就变成了23000多毫秒,多了将近十倍:
    image.png

    这个也是说明了在不访问n的时候,poll阶段的操作其实并没有执行完,而是因为poll的计时器到达了最大值,所以poll阶段被强制终止进入了后续阶段,而我访问n的时候,这个计时器的功能就失效了,一定会算出n才会进行下一步,我猜测这也是为了保证操作的准确性而做出的优化吧。

  • setTimeOut和setImmediate执行顺序
    关于这两者最重要的就是执行顺序问题了,其实从上面node的事件循环阶段就已经能够看出来了,setImmediate是在setTimeOut之前执行的,因为setImmediate是在poll后面的check阶段执行,而setTimeOut是在timers阶段执行,但是实际情况却有所不同,请看下面例子:

    image.png

    这是因为在主线程中,回调的执行顺序取决于当前进程的性能,所以顺序上会有不同,但是当我们将这两者都放入到同一个回调中去执行的时候,他们的顺序就能保证了,如下:
    image.png

  • 关于process.nextTick
    另外在node中还有一个process.nextTick用于推迟操作,这个process.nextTick不存在于上面任何一个阶段,他只在当前阶段(无论是timers亦或是check等)结束后立即执行,可以从下面两个例子中看出:

    image.png

    image.png

后记

关于Event Loop的总结就到这里为止,这些在实际工作中用到的可能较少,面对面试时,主要还是在于区分好浏览器还是node环境,阐述清楚概念以及举出几个针对setTimeOutPromiseasync/awaitsetImmediateprocess.nextTick相关执行顺序即可。

推荐阅读更多精彩内容