了解Event Loop

484 阅读4分钟

Event Loop事件循环简介

JavaScript 是单线程的,由于单线程会造成I/O阻塞,比如发送请求时未响应就可能造成页面停滞,为了解决这个问题,浏览器开始支持异步JS,异步JS就是把一些异步任务(ajax、定时器)等放到任务队列中,然后通过事件循环不断读取、触发任务队列中的异步代码,这种机制就叫做事件循环Event Loop。

Event 是事件的意思,常见的有文件读取、计时器。loop 是循环的意思,也就是采用轮询的方式来执行事件,要执行事件必须有顺序,整个Event loop 就是对事件的执行顺序的管理。

Event Loop的核心代码是采用c++写的(属于NodeJs的范畴),本质上来说Event Loop就是采用轮询的方式不断读取、执行事件,今天我们要讨论的就是事件循环中的细节。

阶段

Event Loop内部分为以下阶段

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

上面的每一个阶段都有一个队列(先进先出),里面存放回调函数。每当Event Loop到达一个阶段,一般来说都会执行队列中的某些函数(也有可能不操作)

各阶段概览

  • timers 阶段:这个阶段执行 setTimeout 和 setInterval 的回调函数。
  • I/O callbacks 阶段:不在 timers 阶段、close callbacks 阶段和 check 阶段这三个阶段执行的回调,都由此阶段负责,这几乎包含了所有回调函数。
  • idle, prepare 阶段(看起来是两个阶段,不过这不重要):event loop 内部使用的阶段(我们不用关心这个阶段)
  • poll 阶段:获取新的 I/O 事件。在某些场景下 Node.js 会阻塞在这个阶段。
  • check 阶段:执行 setImmediate() 的回调函数。
  • close callbacks 阶段:执行关闭事件的回调函数,如 socket.on('close', fn) 里的 fn。

一个 Node.js 程序结束时,Node.js 会检查 event loop 是否在等待异步 I/O 操作结束,是否在等待计时器触发,如果没有,就会关掉 event loop。

timers

这个阶段很有可能是Event Loop开始的第一个阶段,主要存放setTimeout或者setInterval等宏任务

poll

这个阶段主要用来获取新的I/O事件,当 event loop 进入 poll 阶段,发现 poll 队列为空,event loop 检查了一下最近的计时器,大概还有 100 毫秒时间,于是 event loop 决定这段时间就停在 poll 阶段,当定时器任务快开始的时候,Event Loop会绕过poll阶段进入check阶段

check

这个阶段有一个API,面试的时候经常用的到,属于nodeJS的setImmediate,它同样属于宏任务,但是相对于定时器来说,它的特点是要求更快执行

setImmediate和setTimeout

setImmediate 和 setTimeout 很相似,但是其回调函数的调用时机却不一样。

从所属的阶段队列来看,setImmediate属于check阶段,setTimeout属于timers阶段,那么两者之间到底谁先执行呢?

先看一段代码

setTimeout(()=>{console.log('timeout')},0)
setImmediate(()=>{console.log('immediate')},0)

一般来说,都会优先执行setTmmediate,但是上面的代码实际执行顺序是这样的 为什么会有时先执行timeout,有时先执行immediate呢,这要从顺序看起,如果上面的代码setTimeout的时间设定为1000ms,那大家一定不会感到困惑,由于immediate存在于check阶段,当时间设定为1000ms时,Event Loop处于poll阶段,毕竟要等到1000ms才执行timers队列中的函数,所以Loop打算休息一下。

然后呢好像时间差不多了,Loop发现check阶段有个immediate函数,于是跑过去执行一下,执行完了就再跑到timers阶段执行。

而上面产生困惑的最大原因是定时器设置时间为0,这就要看Event Loop开始时,所处的阶段。

如果Event Loop此时在timers阶段,队列中还没有定时器任务,又或者定时器任务还没到时间,那么必然会跳过此阶段,优先执行immediate任务。

如果此时有任务,而且时间到了,那么必然会先执行setTimeout,这也是上述代码产生困惑的原因。

下面我们对它进行改写

setTimeout(() => {
  setTimeout(() => {
    console.log("timeout");
  }, 0);

  setImmediate(() => {
    console.log("immediate");
  });
}, 1000);

上面的代码,1秒后,执行箭头函数,此时Event Loop并不在timers阶段,由于顺序是不可变的,所以总是会优先执行immediate

process.nextTick()

你可能发现 process.nextTick() 这个重要的异步 API 没有出现在任何一个阶段里,那是因为从技术上来讲 process.nextTick() 并不是 event loop 的一部分。实际上,不管 event loop 当前处于哪个阶段,nextTick 队列都是在当前阶段后就被执行了。

setTimeout(() => {
  setTimeout(() => {
    console.log("timeout");
  }, 0);

  setImmediate(() => {
    console.log("immediate");
  });
  
  process.nextTick(()=>{
     console.log('nexTick')
  })
}, 1000);

上面的代码执行顺序是这样的

nextTick是在当前阶段马上执行,由于上面的代码执行后Loop处于poll阶段,所以会优先执行nextTick

为了更好得实验,我们再改一下代码

setTimeout(() => {
  setTimeout(() => {
    console.log("timeout");
    process.nextTick(() => {
      console.log("nexTick2");
    });
  }, 0);

  setImmediate(() => {
    console.log("immediate");
  });

  process.nextTick(() => {
    console.log("nexTick");
  });
}, 1000);

下面是结果,可以发现nextTick是在当前阶段马上执行的

nexTick
immediate
timeout
nexTick2

process.nextTick() 和 setImmediate()

这两个函数功能很像,而且名字也很令人疑惑。

process.nextTick() 的回调会在当前 event loop 阶段「立即」执行。 setImmediate() 的回调会在后续的 event loop 周期(tick)执行。

二者的名字应该互换才对。process.nextTick() 比 setImmediate() 更 immediate(立即)一些。

这是一个历史遗留问题,而且为了保证向后兼容性,也不太可能得到改善。所以就算这两个名字听起来让人很疑惑,也不会在未来有任何变化。

我们推荐开发者在任何情况下都使用 setImmediate(),因为它的兼容性更好,而且它更容易理解。

宏任务和微任务

异步任务中分宏任务和微任务,微任务总是比宏任务先执行

常见宏任务

常见微任务

经典面试题

setTimeout(()=> console.log(4))//宏任务

new Promise(resolve => {
  resolve()//同步任务
  console.log(1) //同步任务
}).then(()=> {
  console.log(3) //微任务
})

console.log(2) //同步任务

改造成await

setTimeout(_ => console.log(4)) //宏任务

async function main() {
  console.log(1) //同步任务
  await Promise.resolve() //同步任务 相当于 resolve()
  console.log(3) //相当于promise.then //微任务
}
main()
console.log(2) //同步任务