浏览器中的 Event Loop

当我们执行 JS 代码的时候其实就是往执行栈中放入函数,那么遇到异步代码的时候该怎么办?其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。

Event Loop事件循环机制 - 图1事件循环

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task

Event Loop 执行顺序如下所示:
  • 首先执行同步代码,这属于宏任务
  • 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行
  • 执行所有微任务
  • 当执行完所有微任务后,如有必要会渲染页面
  • 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数

这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。

Node 中的 Event Loop

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。

Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

Event Loop事件循环机制 - 图2

timer

timers 阶段会执行 setTimeoutsetInterval 回调,并且是由 poll 阶段控制的。

同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。

I/O

I/O 阶段会处理一些上一轮循环中的少数未执行的 I/O 回调

idle, prepare

idle, prepare 阶段内部实现,这里就忽略不讲了。

poll

poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情。

  1. 回到 timer 阶段执行回调
  2. 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
  • 如果 poll 队列为空时,会有两件事发生
    • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
    • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

check

check 阶段执行 setImmediate

close callbacks

close callbacks 阶段执行 close 事件

首先在有些情况下,定时器的执行顺序其实是随机

  1. setTimeout(() => {
  2. console.log('setTimeout')
  3. }, 0)
  4. setImmediate(() => {
  5. console.log('setImmediate')
  6. })

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后。

  • 首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的
  • 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调
  • 那么如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了

当然在某些情况下,他们的执行顺序一定是固定的,比如以下代码:

  1. const fs = require('fs')
  2. fs.readFile(__filename, () => {
  3. setTimeout(() => {
  4. console.log('timeout');
  5. }, 0)
  6. setImmediate(() => {
  7. console.log('immediate')
  8. })
  9. })

在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

上面介绍的都是 macrotask 的执行情况,对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask。

Event Loop事件循环机制 - 图3

  1. setTimeout(() => {
  2. console.log('timer21')
  3. }, 0)
  4. Promise.resolve().then(function() {
  5. console.log('promise1')
  6. })

对于以上代码来说,其实和浏览器中的输出是一样的,microtask 永远执行在 macrotask 前面。

最后我们来讲讲 Node 中的 process.nextTick,这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

  1. setTimeout(() => {
  2. console.log('timer1')
  3. Promise.resolve().then(function() {
  4. console.log('promise1')
  5. })
  6. }, 0)
  7. process.nextTick(() => {
  8. console.log('nextTick')
  9. process.nextTick(() => {
  10. console.log('nextTick')
  11. process.nextTick(() => {
  12. console.log('nextTick')
  13. process.nextTick(() => {
  14. console.log('nextTick')
  15. })
  16. })
  17. })
  18. })

对于以上代码,大家可以发现无论如何,永远都是先把 nextTick 全部打印出来。