# 事件循环

事件循环(Event Loop)分为浏览器事件循环和 node 事件循环。

# 浏览器

事件循环中异步队列分为两种:宏任务队列(MacroTask)和微任务队列(MicroTask),宏任务队列可以有多个,微任务队列只有一个。

  • 常见的 MacroTask:setTimeout、setInterval、 setImmediate、script(最外层整体代码)、 I/O 操作、UI 渲染等
  • 常见的 MicroTask: process.nextTick、new Promise().then(回调)、MutationObserver(html5 新特性) 等

# 执行顺序

浏览器事件队列比较简单,代码执行时,会在宏任务队列取出一个宏任务执行,执行完毕,然后执行所有微任务队列的任务。直到微任务队列为空,再去执行下一个宏任务,依次循环。

# Chrome 对 async/await 的优化

先说结论:async 函数会隐式的返回一个 Promise,当 await 等待的函数是一个同步函数,则会将 await 下方要执行的代码直接放入微任务中(这就是优化),当 await 等待的是异步的时候,则会在异步函数执行完成之后将 await 下方要执行的代码放入微任务中。举个例子:

setTimeout(() => {
  console.log('setTimeout')
})

async function async1() {
  await async2()
  console.log('async1')
}

async function async2() {
	console.log('async2')
}
async1()

Promise.resolve().then(function() {
	console.log('promise')
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这段代码现在打印顺序是:

async2 -> async1 -> promise -> setTimeout

优化之前打印顺序:

async2 -> promise -> async1 -> setTimeout

这是 Chrome V8 团队关于对 async/await 优化的 PR (https://github.com/tc39/ecma262/pull/1250#issue-197979338)

对于异步函数,如下面代码:

setTimeout(() => {
  console.log('setTimeout')
})

async function async1() {
  await async2()
  console.log('async1')
}

async function async2() {
	console.log('async2')
  return Promise.resolve().then(() => {
    console.log('async2 async')
  })
}
async1()

Promise.resolve().then(function() {
	console.log('promise')
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

打印顺序为:

async2 -> async2 async -> promise -> async1 -> setTimeout

# node

同样是使用 V8 引擎 Node 中实现事件循环依赖的是 libuv (opens new window) 引擎,事件循环是 Node 处理非阻塞 I/O 操作的机制。

根据 nodejs 官方文档,在通常情况下,nodejs 中的事件循环根据不同的操作系统可能存在特殊的阶段,但总体是可以分为以下 6 个阶段:

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

  1. timer 阶段,用于执行所有通过计时器函数(即 setTimeout 和 setInterval)注册的回调函数。

  2. pending callbacks 阶段。虽然大部分 I/O 回调都是在 poll 阶段被立即执行,但是会存在一些被延迟调用的 I/O 回调函数。那么此阶段就是为了调用之前事件循环延迟执行的 I/O 回调函数。

  3. idle prepare 阶段,仅在 Node 内部模块使用。

  4. poll(轮询)阶段,此阶段有两个主要职责:

  • 检索新的 I / O 事件
  • 执行与 I / O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些计时器和 setImmediate 调度之外)
  • 其余情况 node 将在适当的时候在此阻塞
  1. check 阶段,用于在 poll 阶段的回调函数队列为空时,使用 setImmediate 实现调度执行特定代码片段。

  2. close 回调函数阶段,执行所有注册 close 事件的回调函数

每一个 Node 事件循环 tick 总是要经历以上阶段,由 timer 阶段开始,由 close 回调函数阶段结束。每一个阶段都会循环执行当前阶段的回调函数队列,直至队列为空或到达最大可执行回调函数次数。

每一个阶段都可以看做有一个宏任务队列,微任务执行时机和浏览器一样,会等一个宏任务执行完然后清空微任务队列里面的所有事件。并且 nextTick 一定早于 new Promise().then。(Node 11 版本之前在每个阶段完成后才会去检查 nextTick 队列,Node 11 之后,process.nextTick 被视为微任务,和浏览器时机一致

# 重点阶段

# timer 阶段

nodejs 事件循环的一个 tick 始终以 timer 阶段开始,其中包含一个由所有 setTimeout 和 setInterval 注册的待执行回调函数队列。此阶段的核心职责是执行由所有到达时间阈值的计时器注册的回调函数。所有的计时器实现都不能保证在到达时间阈值后回调函数一定会被立即执行,它们只能保证在到达时间阈值后,尽快执行由计时器注册的回调函数。

所有计时器在 libuv 中是以计时器回调函数的 执行时间节点(即 time + timeout,而不是计时器时间阈值) 构成的 二叉最小堆 结构来存储通过 二叉最小堆的根节点来获取时间线上最近的 timer 对应的回调函数的句柄。

在现行 nodejs 中,有且仅有两种计时器,其中之一就是是 setTimeout/setInterval。

poll 阶段决定了 timer 回调函数的执行时机,下面有介绍。

# poll 阶段

poll 阶段主要是负责处理当前事件,并且阻塞在 poll 阶段维持状态。

poll 阶段阻塞需要计算因处理 I/O 需要阻塞当前事件循环 tick 的时间;该阻塞时间表示当前事件循环 tick 应该在当前 poll 阶段停留多久,这个时间一般是根据最小的 setTimeout/setInterval 的时间阈值,如果后续 tick 各个阶段队列不为空,则这个时间为 0,不阻塞并进入后续阶段。具体判断为:

int uv_backend_timeout(const uv_loop_t* loop) {
  if (loop->stop_flag != 0)
    return 0;

  if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
    return 0;

  if (!QUEUE_EMPTY(&loop->idle_handles))
    return 0;

  if (!QUEUE_EMPTY(&loop->pending_queue))
    return 0;

  if (loop->closing_handles)
    return 0;

  return uv__next_timeout(loop);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 当事件循环 tick 被 uv_stop() 函数标记为停止时,返回 0,即不阻塞。
  • 当事件循环 tick 不处于活动状态时且不存在活动的 request 时返回 0,即不阻塞。
  • 当 idle 句柄队列不为空时,返回 0,即不阻塞。
  • 当 pending callbacks 的回调队列不为空时,返回 0,即不阻塞。
  • 当存在 closing 句柄,即存在 close 事件回调时,返回 0,即不阻塞。
  • 其他情况都会阻塞在 poll 阶段,等待达到最小的 timer 时间阈值,如果不存在 timer,那么将一直阻塞在 poll 阶段。

之所以上文说 timer 阶段受 poll 阶段影响,是因为如果 poll 阶段一直有事件里面的逻辑在执行,则不会进入下一次事件循环的 timer 阶段,也就不会触发 setTimeout/setInterval 注册的回调事件。

# check 阶段

如果 poll 阶段队列空闲并且 setImmediate 函数存在回调函数时,那么 poll 阶段将打破无限制的等待状态,并进入 check 阶段执行 check 阶段的回调函数。check 阶段的回调函数队列中所有的回调函数都是来自 poll 阶段的 setImmediate 函数。

# setTimeout VS setImmediate

setTimeout 和 setImmediate 同作为 Node 中的定期器使用,那么哪个会先执行呢?

setTimeout(() => console.log('setTimeout'), 0)
setImmediate(() => console.log('setImmediate'))
1
2

上面代码输出结果可能会是 setTimeout -> setImmediate,也可能是 setImmediate -> setTimeout。

原因是 setTimeout 0s 会被重置为 1ms,当执行 js 上下文中,进入首次事件循环的 timer 阶段,

此时如果准备和进入的时间小于 1ms,那么 timer 阶段没有达到阈值的定时器,则进入 poll 阶段,在 poll 阶段在 poll queue 为空时进入 check 阶段清空 setImmediate 注册的所有回调函数,打印出 "setImmediate",然后在后续事件循环又进入到 timer 阶段,此时时间超过 1ms 达到阈值,则打印 "setTimeout";

相反,如果首次进入到 timer 阶段的时候就超过了 1ms,那么此时达到了 setTimeout 注册的回调的阈值,则会打印 "setTimeout",然后依次进入pending callbacks、idle、poll、check,在 check 阶段打印 "setImmediate"。

再看以下代码:

fs.readFile('main.js', () => {
  setTimeout(() => console.log('setTimeout2'), 0)
  setImmediate(() => console.log('setImmediate2'))
})
1
2
3
4

执行顺序一定是 setImmediate2 -> setTimeout2。

原因是除了第一次事件循环外,timer 阶段一定是由于 poll 阶段达到某个 timer 时间阈值触发的,而 poll 阶段之后是 check 阶段,会先执行完 setImmediate 注册的事件,再进入下一次事件循环的 timer。

# 浏览器事件循环 VS Node事件循环

nodejs 与浏览器端的 Web API 版本的事件循环最大的不同的是:

在 nodejs 中事件循环不再是由单一个 MacroTask queue 和 MicroTask queue 组成,而是由多个 阶段 phase 的多个回调函数队列 callbacks queues 组成一次事件循环 tick。 并且在每一个单独的阶段都存在一个单独的 回调函数 FIFO 队列。

最后更新时间: 11/21/2021, 2:05:57 PM