从 libuv 看 nodejs 事件循环

本文同步发布于 个人博客javascript

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

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


事件循环概述

根据 nodejs 官方文档,在一般状况下,nodejs 中的事件循环根据不一样的操做系统可能存在特殊的阶段,但整体是能够分为如下 6 个阶段:node

┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
复制代码
  1. timer 阶段,用于执行全部经过计时器函数(即 setTimeoutsetInterval)注册的回调函数。linux

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

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

  3. idle prepare 阶段,仅用于 nodejs 内部模块使用。web

  4. poll(轮询)阶段,此阶段有两个主要职责:1. 计算当前轮询须要阻塞后续阶段的时间;2. 处理事件回调函数。c#

    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 epoll_wait() shall wait for events. If the value of this parameter is 0, then epoll_wait() 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

相关文章
相关标签/搜索