从 libuv 看 nodejs 事件循环

2020 • 3月 17 • 29 分钟阅读

首先所有平台,不论是浏览器还是 nodejsJS 事件循环都不是由 ECMA 262 规范定义。事件循环并不是 ECMA 262 规范的一部分。浏览器端的事件循环由 Web API 中定义,并由 W3CHTML living standard 来维护。而 nodejs 是基于 libuv 的事件循环,其并没有一个事件循环规范标准,那么了解 nodejs 事件循环的最好方式就是 nodejs 的源码和官方文档和 libuv 的源码和官方文档。

文章中引用的参考尽可能选取官方文档、nodejs/libuv 仓库,nodejs/libuv 贡献者解答,google/microsoft 工程师,高赞 stackoverflow 回答等来源。


事件循环概述

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

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
  1. timer 阶段,用于执行所有通过计时器函数(即 setTimeoutsetInterval)注册的回调函数。
  2. pending callbacks 阶段。虽然大部分 I/O 回调都是在 poll 阶段被立即执行,但是会存在一些被延迟调用的 I/O 回调函数。那么此阶段就是为了调用之前事件循环延迟执行的 I/O 回调函数。

    引自在 libuv 的设计文档 the I/O loop - step.4

  3. idle prepare 阶段,仅用于 nodejs 内部模块使用。
  4. poll(轮询)阶段,此阶段有两个主要职责:1. 计算当前轮询需要阻塞后续阶段的时间;2. 处理事件回调函数。

    nodejs 中事件循环中存在一种维持在此阶段的趋势,后文会做详细说明

  5. check 阶段,用于在 poll 阶段的回调函数队列为空时,使用 setImmediate 实现调度执行特定代码片段。
  6. close 回调函数阶段,执行所有注册 close 事件的回调函数。

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

事件循环实现

nodejs 官方文档,nodejs 中的事件循环是依赖于名为 libuvC 语言库实现。本质上 libuv 的执行方式决定了 nodejs 中的事件循环的执行方式。

至本文发布之际,最新 libuv 的版本为 v1.35.0.

Q: libuv 是什么?

A: libuv 是使用 C 语言实现的单线程非阻塞异步 I/O 解决方案,本质上它是对常见操作系统底层异步 I/O 操作的封装,并对外暴露功能一致的 API, 首要目的是尽可能的为 nodejs 在不同系统平台上提供统一的事件循环模型。

nodejs 的事件循环核心对应 libuv 中的 uv_run 函数,核心逻辑如下:

// http://docs.libuv.org/en/v1.x/loop.html#c.uv_loop_alive
r = uv__loop_alive(loop);
if (!r)
  uv__update_time(loop);

// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
  // http://docs.libuv.org/en/v1.x/loop.html#c.uv_update_time
  uv__update_time(loop);
  // timer 阶段
  uv__run_timers(loop);
  // pending callbacks 阶段
  ran_pending = uv__run_pending(loop);
  uv__run_idle(loop);
  uv__run_prepare(loop);

  timeout = 0;
  if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
    timeout = uv_backend_timeout(loop);

  // poll 阶段
  uv__io_poll(loop, timeout);
  // check 阶段
  uv__run_check(loop);
  // close callbacks 阶段
  uv__run_closing_handles(loop);

  if (mode == UV_RUN_ONCE) {
    /* UV_RUN_ONCE implies forward progress: at least one callback must have
      * been invoked when it returns. uv__io_poll() can return without doing
      * I/O (meaning: no callbacks) when its timeout expires - which means we
      * have pending timers that satisfy the forward progress constraint.
      *
      * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
      * the check.
      */
    uv__update_time(loop);
    uv__run_timers(loop);
  }

  r = uv__loop_alive(loop);
  if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
    break;
}

libuv 文档中对 IO loop 的描述,原则上,一个线程中至多仅有一个事件循环,在多个线程中可以存在多个并行的事件循环。事件循环遵循常规的单线程异步 I/O 方案。所有的 (网络)I/O 都是在非阻塞的 socket 上执行。这些 socket 使用了如下表给定平台的最佳轮询机制。

mechanism platform
epoll Linux
kqueue OSX, BSD
IOCP Windows
event ports SunOS

单个事件循环 loop 作为整个事件循环迭代 loop iteration 的一部分,它会 阻塞 等待已经添加到 poller 中的 sockets 上的 IO 活动,并间触发对应的回调函数以指示 socket 的条件(即可读,可写,挂起),以便句柄可以读取,写入,或执行所期望的 IO 操作。

libuv loop iteration

r = uv__loop_alive(loop);
if (!r)
  uv__update_time(loop);

根据源代码和 libuv 官方文档,事件循环首先会缓存当前事件循环 tick 的开始时间,用于减少时间相关的系统调用。

缓存时间的做法是因为系统内的时间调用会受到系统内其他应用的影响,所以为了尽可能避免其他应用对 nodejs 的影响而在事件循环的 tick 开始之时缓存时间。

如果事件循环是活动的,那么开始当前事件循环,否则立即退出整个事件循环迭代。那么如何界定一个事件循环迭代是活动的?如果一个事件循环拥有活动的句柄或引用句柄,活动的请求或 closing 句柄,那么该事件循环被认为是活动的。

// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
  // ...
}

从以上示例代码不难看出,整个事件循环迭代就是一个 while 无限循环,正是这个 while 语句在不断地推动事件循环的迭代。在每一次循环迭代开始时,都会不断验证当前事件循环 tick 是否是活动的,且没有 stop 标识。在进入循环之后首先会更新当前事件循环的开始时间并继续执行事件循环的各个阶段的回调函数队列。

结合前文对 nodejs 中事件循环的生命周期抽象归纳,不难依据 uv_run()doc 的核心逻辑得出:

  1. timer 阶段: uv__run_timers(loop)
  2. pending callbacks 阶段:uv__run_pending(loop)
  3. idle 阶段:uv__run_idle(loop)
  4. poll 阶段:uv__io_poll(loop, timeout)
  5. check 阶段:uv__run_check(loop)
  6. close callbacks 阶段:uv__run_closing_handles(loop)函数定义

timer 阶段

nodejs 事件循环的一个 tick 始终以 timer 阶段开始,其中包含一个由所有 setTimeoutsetInterval 注册的待执行回调函数队列。此阶段的 核心职责 是执行由所有到达时间阈值的计时器注册的回调函数。

待执行,表示在已经到达计时器的时间阈值时,被加入到 timer 阶段的回调函数队列中等待执行的由计时器注册的回调函数。

值得声明的一点是,不论是在 nodejs 还是 web 浏览器中,所有的计时器实现都 不能保证 在到达时间阈值后回调函数一定会被立即执行,它们只能保证在到达时间阈值后,尽快 执行由计时器注册的回调函数。

const NS_PER_SEC = 1e9
const time = process.hrtime()
// [ 1800216, 25 ]

setTimeout(() => {
  const diff = process.hrtime(time)
  // [ 1, 552 ]

  console.log(`Benchmark took ${diff[0] * NS_PER_SEC + diff[1]} nanoseconds`)
  // Benchmark took 1000000552 nanoseconds
}, 1000)

另外,从技术上讲,poll 阶段决定了 timer 回调函数的执行时机。详情可见后文关于 poll 对 timer 的影响 的说明。

libuv 如何调度计时器

如前文所述,timer 阶段对应 libuvC 函数为 uv__run_timers(loop);。且在 uv_run 函数体中对应的核心调用逻辑如下:

int timeout;
int r;
int ran_pending;

r = uv__loop_alive(loop);
if (!r)
  uv__update_time(loop);

// http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-loop
while (r != 0 && loop->stop_flag == 0) {
  uv__update_time(loop);
  uv__run_timers(loop);
  // ...
}

在开始事件循环的一个 tick 时,总是会首先调用 uv__update_time(loop); 来更新当前事件循环 tick 的开始时间。

UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
  /* Use a fast time source if available.  We only need millisecond precision.
   */
  loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}

此处 uv__hrtime 函数内部包含当前操作系统的暴露的时间相关系统调用。在此处对系统的时间调用时,可能会受到其他其他应用的影响。一旦更新 loop 结构体的 time 后,接着会开始执行 timer 阶段的回调函数队列。如下:

void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) {
    heap_node = heap_min(timer_heap(loop));
    if (heap_node == NULL)
      break;

    // container_of 由 preprocesser 来实现编译前文本替换
    // https://github.com/libuv/libuv/blob/v1.35.0/src/uv-common.h#L57-L58
    handle = container_of(heap_node, uv_timer_t, heap_node);

    if (handle->timeout > loop->time)
      break;

    // http://docs.libuv.org/en/v1.x/timer.html#c.uv_timer_stop
    uv_timer_stop(handle);
    // http://docs.libuv.org/en/v1.x/timer.html#c.uv_timer_again
    uv_timer_again(handle);
    handle->timer_cb(handle);
  }
}

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

  • 当该值大于当前事件循环 tick 的开始时间时,即表示还没有到执行时机,回调函数还不应该被执行。那么根据二叉最小堆的性质,父节点始终比子节点小,那么根节点的时间节点都不满足执行时机的话,其他的 timer 时间节点肯定也没有过期。此时,退出 timer 阶段的回调函数执行,进入事件循环 tick 的下一阶段。
  • 当该值小于当前事件循环 tick 的开始时间时,表示至少存在一个过期的计时器,那么循环迭代计时器最小堆的根节点,并调用该计时器所对应的回调函数。每次循环迭代时都会更新最小堆的根节点为最近时间节点的计时器。

nodejs 内置计时器

在现行 nodejs 中,有且仅有两种计时器,其中之一就是是 setTimeout/setInterval。 在使用 setTimeout/setInterval 时,值得注意的一点是:

时间阈值的取值范围是 1 ~ 231-1 ms,且为整数。

nodejs/node 源码中不论是 setTimeout 源码实现 还是 setInterval 源码实现本质上都是内置类 Timeout 的实例,如下:

// Timeout values > TIMEOUT_MAX are set to 1.
const TIMEOUT_MAX = 2 ** 31 - 1

// Timer constructor function.
// The entire prototype is defined in lib/timers.js
function Timeout(callback, after, args, isRepeat, isRefed) {
  after *= 1 // Coalesce to number or NaN
  if (!(after >= 1 && after <= TIMEOUT_MAX)) {
    if (after > TIMEOUT_MAX) {
      process.emitWarning(
        `${after} does not fit into` +
          ' a 32-bit signed integer.' +
          '\nTimeout duration was set to 1.',
        'TimeoutOverflowWarning'
      )
    }
    after = 1 // Schedule on next tick, follows browser behavior
  }

  this._idleTimeout = after
  this._idlePrev = this
  this._idleNext = this
  this._idleStart = null
  // This must be set to null first to avoid function tracking
  // on the hidden class, revisit in V8 versions after 6.2
  this._onTimeout = nullv
  this._onTimeout = callback
  this._timerArgs = args
  this._repeat = isRepeat ? after : null
  this._destroyed = false

  if (isRefed) incRefCount()
  this[kRefed] = isRefed

  initAsyncResource(this, 'Timeout')
}

从构造函数的函数体可见,nodejs 中所有计时器是通过一个 双向链表 实现关联,并且所有超出时间阈值范围的时间阈值都会被 重置为 1ms,且所有非整数值会被转换为 整数值

那么一种常见的写法 setTimeout(callback, 0) 会被 nodejs 内部模块转换为 setTimeout(callback, 1) 来执行。

pending callbacks

pending callbacks 阶段用于执行先前事件循环 tick 中延迟执行的 I/O 回调函数。

poll 阶段

poll 阶段的首要职责是:

  1. 计算因处理 I/O 需要阻塞当前事件循环 tick 的时间;该阻塞表示当前事件循环 tick 应该在当前 poll 阶段停留多久,这个时间一般是根据最小的 setTimeout/setInterval 的时间阈值等多个因素(见下文)来确定。在到达阻塞时间后,会经历当前事件循环 tick 的后续阶段,并最终进入下一个事件循环 ticktimer 阶段,此时,过期的计时器的回调函数得以执行。
  2. 处理事件回调。

如前文概述,nodejspoll 阶段对应 libuv 中的 核心逻辑 如下:

timeout = 0;
/**
 * uv_backend_timeout 用于获取 poll 阶段的超时(阻塞)时间
 * http://docs.libuv.org/en/v1.x/loop.html#c.uv_backend_timeout
 */
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
  timeout = uv_backend_timeout(loop);

uv__io_poll(loop, timeout);

在调用 uv__io_poll 之前,首先初始化一个 timeout 变量,该变量在 loop 为常规模式下,将通过 uv_backend_timeout(loop)定义 来确定 poll 阶段的超时时间,该超时时间也就是 nodejs 文档 中提到的 poll 阶段应该阻塞的时间,那么确定该阻塞时间的具体依据是什么呢?

int uv_backend_timeout(const uv_loop_t* loop) {
  // https://github.com/libuv/libuv/blob/v1.35.0/src/uv-common.c#L521-L523
  // http://docs.libuv.org/en/v1.x/guide/eventloops.html#stopping-an-event-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);
}

uv_backend_timeout 函数体不难看出,该函数根据当前事件循环 tick 的部分属性来确定 poll 阶段的阻塞时间:

  1. 当事件循环 tickuv_stop()doc 函数标记为停止#时,返回 0,即不阻塞。
  2. 当事件循环 tick 不处于活动状态时且不存在活动的 request 时返回 0,即不阻塞。
  3. idle 句柄队列不为空时,返回 0,即不阻塞。
  4. pending callbacks 的回调队列不为空时,返回 0,即不阻塞。
  5. 当存在 closing 句柄,即存在 close 事件回调时,返回 0,即不阻塞。

为什么返回 0 表示不阻塞,而 -1 表示无限制阻塞?

因为从 uv__io_poll 函数体可见 poll 阶段实现轮询的关键点在于各个系统平台的轮询机制。上文中 0-1 分别对应 linux 系统底层轮询机制的轮询参数。

linuxepoll 轮询机制为例,在 uv__io_poll 函数体中调用了系统底层 epoll_wait 函数来实现 libuv 的轮询核心功能:

nfds = epoll_wait(loop->backend_fd,
                        events,
                        ARRAY_SIZE(events),
                        timeout);

The parameter timeout shall specify the maximum number of milliseconds that epollwait() shall wait for events. If the value of this parameter is 0, then epollwait() shall return immediately, even if no events are available, in which case the return code shall be 0. If the value of timeout is -1, then epoll_wait() shall block until either a requested event occurs or the call is interrupted.

epoll_wait 文档可见,当 timeout 传参为 0 时,将立即返回,当 timeout 传参为 -1 时,将无限制阻塞,直到某个事件触发或无限阻塞状态被主动打断。

回到 timeout 的主题上,在不满足以上不阻塞当前事件循环 tick 的前提下,由 uv__next_timeout 函数来计算最终的 poll 阶段阻塞时间:

int uv__next_timeout(const uv_loop_t* loop) {
  const struct heap_node* heap_node;
  const uv_timer_t* handle;
  uint64_t diff;

  // libuv 计时器二叉最小堆的根节点为所有计时器中距离当前时间节点最近的计时器
  heap_node = heap_min(timer_heap(loop));

  // 此处 true 条件为无限制的阻塞当前 poll 阶段
  if (heap_node == NULL)
    return -1; /* block indefinitely */

  handle = container_of(heap_node, uv_timer_t, heap_node);

  // 若最近时间节点的计时器小于等于当前事件循环 `tick` 开始的时间节点
  // 那么不阻塞,并进入下一阶段,直至进入下一 `tick` 的 `timer` 阶段执行回调函数
  if (handle->timeout <= loop->time)
    return 0;

  // 如 nodejs 文档中对 poll 阶段计算阻塞时间的描述
  // 以下语句用于计算当前 poll 阶段应该阻塞的时间
  diff = handle->timeout - loop->time;
  // INT_MAX 在 limits.h 头文件中声明
  if (diff > INT_MAX)
    diff = INT_MAX;

  return (int) diff;
}

从以上函数体并结合前文 对计时器的分析 不难看出,通过获取计时器最小堆的根节点得到距离现在最近的计时器执行节点。将该节点与当前事件循环 tick 的开始时间 loop->time 做对比:

  1. 若不存在任何计时器,那么当前事件循环 tick 中的 poll 阶段将 无限制阻塞。以实现一旦存在 I/O 回调函数加入到 poll queue 中即可立即得到执行。
  2. 若最近计时器时间节点小于等于开始时间,则表明在计时器二叉最小堆中 至少存在一个 过期的计时器,那么当前 poll 阶段的超时时间将被设置为 0,即表示 poll 阶段不发生阻塞。这是为了尽可能快的进入下一阶段,即尽可能快地结束当前事件循环 tick。在进入下一事件循环 tick 时,在 timer 阶段,上一 tick 中过期的计时器回调函数得以执行。
  3. 若最近计时器时间节点大于开始时间,则计算两个计时器之前的差值,且不大于 int 类型最大值。poll 将根据此差值来阻塞当前阶段,这么做是为了在轮询阶段,尽可能快的处理异步 I/O 事件。此时我们也可以理解为 事件循环 tick 始终有一种维持在 poll 阶段的倾向

由以上源码分析,不难得出 poll 阶段的本质:

  1. 为了尽可能快的处理异步 I/O 事件,那么事件循环 tick 总有一种维持 poll 状态的倾向;
  2. 当前 poll 阶段应该维持(阻塞)多长时间是由 后续 tick 各个阶段是否存在不为空的回调函数队列最近的计时器时间节点 决定。若所有队列为空且不存在任何计时器,那么事件循环将 无限制地维持在 poll 阶段

注:因为 poll 阶段的超时时间在进入 poll 阶段之前计算,故当前 poll 阶段中回调函数队列中的计时器并不影响当前 poll 阶段的超时时间。

poll 对 timer 的影响

Nodejs doc:

Note: Technically, the poll phase controls when timers are executed.

从技术上来说,poll 阶段控制了计时器的执行时机。为什么这么说?

首先,libuv 的事件循环是无法再入的,并且事件循环总是有一种维持在 poll 阶段的倾向,那么在没有满足 poll 阶段的结束条件时,就无法进入到下一个事件循环 ticktimer 阶段,就无法执行 timer queue 中到期计时器的回调函数。所以才会存在 “poll 阶段控制了计时器回调函数的执行时机” 的说法。

另外,无限制的轮询事件和调用回调函数,会导致完全不会清空 poll 的回调函数队列,进而永远都不会发生计时器的阈值检测导致拖垮整个事件循环迭代。libuv 在其内部设定了一个依赖于系统的最大执行数。结合前文对 nodejs 内置计时器 的描述,这也是计时器无法保证准确的执行回调函数,而是尽快的执行回调函数的原因之一。

check 阶段

该阶段的设计目的是可在 poll 阶段结束之时,立即调用指定代码片段(即函数)。如果 poll 阶段进入 idle 状态并且 setImmediate 函数存在回调函数时,那么 poll 阶段将打破无限制的等待状态,并进入 check 阶段执行 check 阶段的回调函数。

check 阶段的回调函数队列中所有的回调函数都是来自 poll 阶段的 setImmediate 函数。

setTimeout vs setImmediate

由前文 nodejs 内置计时器 章节可知,在现行的 nodejs 环境中,有且仅有两种计时器,一种是 setTimeout/setInterval,另一种是 setImmediate

setTimeout/setInterval 设计目的在于经历一段最小时间阈值后尽快调用指定的回调函数。而 setImmediate 是作为特殊的计时器而存在,其设计目的是给予用户能在 poll 阶段结束后(即 check 阶段)能够立即执行代码的机会,而不用在 timer 阶段执行。

实践

结合以上简短介绍,若同时在 user code 的模块词法环境中直接调用 setTimeoutsetImmediate 会出现什么样的结果?

为什么上文提到在 nodejsuser script 是模块词法环境而不是全局词法环境?

可简单通过 console.log(this === module.exports)(而不是 global) 为 true 值判断。

// index.js
setTimeout(
  /* setTimeoutCallback */ () => {
    console.log('from setTimeout')
  },
  0
)

setImmediate(
  /* setImmediateCallback */ () => {
    console.log('from setImmediate')
  }
)

以上代码通过 node index.js 命令调用后会出现 无法预测的随机 结果:

from setTimeout
from setImmediate

from setImmediate
from setTimeout

为什么会出现这样的现象?

nodejs 脚本初始编译运行时,nodejs 会首先以入口 JS 文件为执行入口,那么此时 运行中执行上下文 为当前入口 JS 文件对应的 Script 执行上下文。

nodejs-event-loop-cycle

前文所述setTimeout(callback, 0) 其实是被重置为 setTimeout(callback, 1)了。那么在首次 user script 代码执行后,即 Script 执行上下文退出执行上下文栈后,并 开始首次 事件循环 tick[nodejs 贡献者],在第一次进入 timer 阶段时,会抽取 timer 最小堆中的节点对比当前事件循环 tick 的开始时间是否已经过了阈值 1ms

  • 若在前文 uv__run_timer(loop) 中,系统时间调用和时间比较的过程总耗时没有超过 1ms 的话,在 timer 阶段会发现没有过期的计时器,setTimeoutCallbacks 同时也并不存在于 timer queue 中。那么此时,将继续执行至 poll 阶段,而在 poll 阶段 poll queue 队列为空时,检查 check queue 队列并不为空。那么继续进入事件循环 tick 的下一阶段,并清空 check queue 中由 setImmediate 注册的 setImmediateCallback 回调函数。在经历后续的事件循环 tick 并重新开始时,会发现先前的阈值为 1ms 的过期计时器,此时的 setTimeoutCallback 才得以加入 timer queue 并得以在当前 timer 阶段执行。

    控制台的输出如下:

    from setImmediate
    from setTimeout
  • 若在上文源码中,系统时间调用和时间比较的过程总耗时超过 1ms 的话,那么会将过期计时器的 setTimeoutCallback 加入到 timer queue 中,并进入 timer queue 的调用阶段。后续控制台输出如下:

    from setTimeout
    from setImmediate

那么从上文针对 libuvuv__run_timers 函数的分析可见,在 user script 的模块词法环境中直接同时调用 setTimeout(callback, 0)setImmediate(callback) 时无法预判回调函数的调用顺序的原因总结如下相关 issue

  1. 在初始的事件循环 tick 执行时,会 首先执行第一次时间检查
  2. timer 句柄中 timeout 存储的是当次事件循环 tick 的开始时间加上 时间阈值(示例代码中为 1ms)后的时间节点。
  3. 这一次初始 timer 的时间检查距当前事件循环 tick 的间隔可能小于 1ms 也可能大于 1ms 的阈值,这取决于时间的系统调用的耗时,而时间的系统调用又会受到操作系统的其他应用的影响。当间隔小于 1ms 时,将在 timer 阶段忽略示例代码中的 setTimeoutCallback 执行,并先执行 setImmediateCallback 函数;反之,首先执行 setTimeoutCallback 执行。

nodejs 官网另外 描述I/O cycle 中,示例代码的调用是可预测的,为什么?

const fs = require('fs')

fs.readFile(__dirname, () => {
  setTimeout(() => {
    console.log('from setTimeout')
  }, 1)

  setImmediate(() => {
    console.log('from setImmediate')
  })
})

上述示例代码将始终输出:

from setImmediate
from setTimeout

The main advantage to using setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present.

基于先前的分析,在经历初次事件循环 tick 后,后续所有的 setTimeout/setInterval 计时器阈值检查和调用都被先前事件循环 tickpoll 阶段所阻塞。而不论根据 nodejs 还是 libuv 的事件循环抽象结构图还是 uv_run 函数的源码,并且基于事件循环 无法再入 的前提,poll 阶段的下一阶段始终是 check 阶段,那么在 I/O cycle 中,所有的 timer 在当前事件循环 tick 中注册,并首先通过包含 setImmediate 回调函数的 check 阶段及其后续阶段,才会进入到下一事件循环 ticktimer 阶段。以至于在执行顺序上在 I/O cycle 中注册的 setTimeout/setInterval 回调函数始终在 setImmediate 的回调函数之后执行。以上同样说明了为什么在 nodejs 官网上 描述I/O cyclesetImmediate 的优先级高于 setTimeout

close callbacks

此阶段用于执行所有的 close 事件的回调函数。如突然通过 socket.destroy() 关闭 socket 连接时,close 事件将在此阶段触发。

与浏览器实现对比

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

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

References