浏览器和Node不同的事件循环(Event Loop)

8,362 阅读7分钟

注意

在 Node 11 版本中,Node 的 Event Loop 已经与 浏览器趋于相同。

背景

Event Loop也是js老生常谈的一个话题了。2月底看了阮一峰老师的《Node定时器详解》一文后,发现无法完全对标之前看过的js事件循环执行机制,又查阅了一些其他资料,记为笔记,感觉不妥,总结成文。

浏览器中与node中事件循环与执行机制不同,不可混为一谈。 浏览器的Event loop是在HTML5中定义的规范,而node中则由libuv库实现。同时阅读《深入浅出nodeJs》一书时发现比较当时node机制已有不同,所以本文node部分针对为此文发布时版本。强烈推荐读下参考链接中的前三篇。

浏览器环境

js执行为单线程(不考虑web worker),所有代码皆在主线程调用栈完成执行。当主线程任务清空后才会去轮询取任务队列中任务。

任务队列

异步任务分为task(宏任务,也可称为macroTask)和microtask(微任务)两类。 当满足执行条件时,task和microtask会被放入各自的队列中等待放入主线程执行,我们把这两个队列称为Task Queue(也叫Macrotask Queue)和Microtask Queue。

  • task:script中代码、setTimeout、setInterval、I/O、UI render。
  • microtask: promise、Object.observe、MutationObserver。

具体过程

  1. 执行完主执行线程中的任务。
  2. 取出Microtask Queue中任务执行直到清空。
  3. 取出Macrotask Queue中一个任务执行。
  4. 取出Microtask Queue中任务执行直到清空。
  5. 重复3和4。

即为同步完成,一个宏任务,所有微任务,一个宏任务,所有微任务......

注意

  • 在浏览器页面中可以认为初始执行线程中没有代码,每一个script标签中的代码是一个独立的task,即会执行完前面的script中创建的microtask再执行后面的script中的同步代码。
  • 如果microtask一直被添加,则会继续执行microtask,“卡死”macrotask。
  • 部分版本浏览器有执行顺序与上述不符的情况,可能是不符合标准或js与html部分标准冲突。可阅读参考文章中第一篇。
  • new Promise((resolve, reject) =>{console.log(‘同步’);resolve()}).then(() => {console.log('异步')}),即promisethencatch才是microtask,本身的内部代码不是。
  • 个别浏览器独有API未列出。

伪代码

while (true) {
  宏任务队列.shift()
  微任务队列全部任务()
}

node环境

js执行为单线程,所有代码皆在主线程调用栈完成执行。当主线程任务清空后才会去轮询取任务队列中任务。

循环阶段

在node中事件每一轮循环按照顺序分为6个阶段,来自libuv的实现:

  1. timers:执行满足条件的setTimeout、setInterval回调。
  2. I/O callbacks:是否有已完成的I/O操作的回调函数,来自上一轮的poll残留。
  3. idle,prepare:可忽略
  4. poll:等待还没完成的I/O事件,会因timers和超时时间等结束等待。
  5. check:执行setImmediate的回调。
  6. close callbacks:关闭所有的closing handles,一些onclose事件。

执行机制

几个队列

除上述循环阶段中的任务类型,我们还剩下浏览器和node共有的microtask和node独有的process.nextTick,我们称之为Microtask Queue和NextTick Queue。

我们把循环中的几个阶段的执行队列也分别称为Timers Queue、I/O Queue、Check Queue、Close Queue。

循环之前

在进入第一次循环之前,会先进行如下操作:

  • 同步任务
  • 发出异步请求
  • 规划定时器生效的时间
  • 执行process.nextTick()

开始循环

按照我们的循环的6个阶段依次执行,每次拿出当前阶段中的全部任务执行,清空NextTick Queue,清空Microtask Queue。再执行下一阶段,全部6个阶段执行完毕后,进入下轮循环。即:

  • 清空当前循环内的Timers Queue,清空NextTick Queue,清空Microtask Queue。
  • 清空当前循环内的I/O Queue,清空NextTick Queue,清空Microtask Queue。
  • 清空当前循环内的Check Queu,清空NextTick Queue,清空Microtask Queue。
  • 清空当前循环内的Close Queu,清空NextTick Queue,清空Microtask Queue。
  • 进入下轮循环。

可以看出,nextTick优先级比promise等microtask高。setTimeoutsetInterval优先级比setImmediate高。

注意

  • 如果在timers阶段执行时创建了setImmediate则会在此轮循环的check阶段执行,如果在timers阶段创建了setTimeout,由于timers已取出完毕,则会进入下轮循环,check阶段创建timers任务同理。
  • setTimeout优先级比setImmediate高,但是由于setTimeout(fn,0)的真正延迟不可能完全为0秒,可能出现先创建的setTimeout(fn,0)而比setImmediate的回调后执行的情况。

伪代码

while (true) {
  loop.forEach((阶段) => {
    阶段全部任务()
    nextTick全部任务()
    microTask全部任务()
  })
  loop = loop.next
}

测试代码

function sleep(time) {
  let startTime = new Date()
  while (new Date() - startTime < time) {}
  console.log('1s over')
}
setTimeout(() => {
  console.log('setTimeout - 1')
  setTimeout(() => {
      console.log('setTimeout - 1 - 1')
      sleep(1000)
  })
  new Promise(resolve => resolve()).then(() => {
      console.log('setTimeout - 1 - then')
      new Promise(resolve => resolve()).then(() => {
          console.log('setTimeout - 1 - then - then')
      })
  })
  sleep(1000)
})

setTimeout(() => {
  console.log('setTimeout - 2')
  setTimeout(() => {
      console.log('setTimeout - 2 - 1')
      sleep(1000)
  })
  new Promise(resolve => resolve()).then(() => {
      console.log('setTimeout - 2 - then')
      new Promise(resolve => resolve()).then(() => {
          console.log('setTimeout - 2 - then - then')
      })
  })
  sleep(1000)
})
  • 浏览器输出:
    setTimeout - 1 //1为单个task
    1s over
    setTimeout - 1 - then
    setTimeout - 1 - then - then 
    setTimeout - 2 //2为单个task
    1s over
    setTimeout - 2 - then
    setTimeout - 2 - then - then
    setTimeout - 1 - 1
    1s over
    setTimeout - 2 - 1
    1s over
    
  • node输出:
    setTimeout - 1 
    1s over
    setTimeout - 2 //1、2为单阶段task
    1s over
    setTimeout - 1 - then
    setTimeout - 2 - then
    setTimeout - 1 - then - then
    setTimeout - 2 - then - then
    setTimeout - 1 - 1
    1s over
    setTimeout - 2 - 1
    1s over
    

由此也可看出事件循环在浏览器和node中的不同。

由于新版 node 执行情况与浏览器相同,所以浏览器环境为例,以 console 输出值代指值所在函数,执行过程如下

<!--执行完主执行线程中的任务。-->
<!--取出Microtask Queue中任务执行直到清空。-->
<!--取出Macrotask Queue中一个任务执行。-->
<!--取出Microtask Queue中任务执行直到清空。-->
<!--重复3和4。-->
以 IQ 代指微任务队列,AQ 代指宏任务队列
1. 执行完主线程中任务:主执行线程执行完毕,setTimeout-1、setTimeout-2 进入等待
2. 清空 IQ:此时 IQ 中无任务
2. 执行 AQ 中一个任务: setTimeout-1 到时间后进入 AQ 中,被执行,执行过程中 setTimeout-1-1 进入等待状态,setTimeout-1-then 直接进入 IQ 队列,由于 setTimeout-1 中有 1s 等待,此时 setTimeout-2 肯定已经进入 AQ,setTimeout-1-1 也随后进入 AQ,此时结束状态为 IQ: [setTimeout-1-then],AQ: [setTimeout-2, setTimeout-1-1]
3. 清空 IQ: 此时 IQ 中有 setTimeout-1-then,执行 setTimeout-1-then,执行过程中,setTimout-1-then-then 直接被加入 IQ,所以 IQ 没清空,所以继续执行 setTimout-1-then-then,IQ 被清空,此时结束状态为 IQ: [], AQ:  [setTimeout-2, setTimeout-1-1]
4. 执行 AQ 中一个任务:即执行 setTimeout-2
5. 清空 IQ: 这一步与 3 相似,所以输出 setTimeout-2-then、setTimeout-2-then-then,IQ 清空,此时结束状态为 IQ: [], AQ: [setTimeout-1-1, setTimeout-2-1]
6. 执行 AQ 中一个任务:即 setTimeout-1-1
7. 清空 IQ: 本身就为空
8. 执行 AQ 中一个任务:即 setTimeout-2-1

参考文章