zhangxiang958/Blog

[译]IO 处理——NodeJS 事件循环 Part 4

zhangxiang958 opened this issue · 1 comments

原文链接:https://jsblog.insiderattack.net/handling-io-nodejs-event-loop-part-4-418062f917d1
欢迎回到 NodeJS 事件循环系列文章。在本篇文章中,我将会讲述在 NodeJS 中是如何处理 I/O 的细节,还有深挖事件循环实现原理与 I/O 是如何与其他异步操作协同工作的。如果你错过本系列前面几篇文章,我强烈建议你先阅读前面几篇关于 NodeJS 事件循环其他章节的文章。

文章系列目录

  • 事件循环总览
  • 定时器,Immediates 和 process.nextTick
  • Promises, Next-Ticks and Immediates
  • I/O 处理(本篇文章)
  • 事件循环的最佳实践

Async I/O…. Coz blocking is too mainstream!

我们在谈论 NodeJS 的时候少不了会涉及它的异步 I/O。正如我们在第一篇文章中谈论的一样,I/O 操作通常是异步的(虽然 NodeJS 也会提供同步 API).

在所有的操作系统的实现中,它们都提供了异步 I/O 的事件通知接口(Linux 的 epoll,MacOS 的 kqueue,Solaris 的 event port,Windows 的 IOCP 等等)。

NodeJS 利用了底层系统级的事件通知机制来提供非阻塞的异步 I/O。

正如我们看到的,NodeJS 是一些系统实现和实用工具库集成的高性能编程框架,包括:

  • Chrome v8 引擎——提供高性能的 JavaScript 运行时。
  • Libuv——提供异步 I/O 的事件循环机制
  • c-ares——提供 DNS 相关操作。
  • 其他插件例如 http-parser,crypto 和 zlib 等

在本篇文章中,我们将会讨论 Libuv 和它是如何为 Node 提供异步 I/O 的。我们一起看一下以下这幅图。

我们一起回顾一下至今为止我们所知道的事件循环机制:

  • 事件循环是从执行所有到期的定时器回调函数开始的。
  • 然后开始处理队列中被挂起的 I/O 操作,并且会可选择性地等待这些挂起的 I/O 操作完成。
  • 处理完 I/O 操作后接着它会去处理 setImmediate 添加的回调函数队列。
  • 最后,它会处理 I/O 的 close 事件相关操作。
  • 在处理每个(4 个)主要事件循环类型阶段之间,libuv 会将刚刚处理的事件阶段相关的结果回传给 Node 上层调用。同时,这个期间 process.nextTick 队列与其他微任务队列中的回调也会被处理。

现在,我们来一起探究 NodeJS 是如何在事件循环中执行 I/O 操作的。

什么是 I/O ?

一般来说,涉及到除了 CPU 之外的外部设备的任务都叫 I/O 操作。最常见的 I/O 操作类型就是文件操作和 TCP/UDP 网络操作。

Libuv and NodeJS I/O

JavaScript 本身是没有提供异步 I/O 操作的能力的。libuv 原本是只为 Node 提供异步 I/O 能力的,但是现在已经成为了一个独立的库了。在 NodeJS 的架构中,libuv 是负责抽象底层复杂的 I/O 操作并提供一些通用的接口供 Node 上层调用,以提供跨平台的异步 I/O 接口。

注意!

如果你没有读过本系列的前面几篇文章,我建议你可以去读一下,这可以帮助你理解事件循环。本篇文章中我可能会注重讲解 I/O 而忽略事件循环的部分细节。

我可能会使用部分运行在 unix 平台上的 libuv 源码进行讲解,windows 平台下可能略有差异。

下面的例子可能会要求你知道关于 C 语言的基本语法和流程。

正如我们在前面看到的 NodeJS 架构图,libuv 处于 NodeJs 架构的底层。现在就让我们一起看一下 NodeJs 架构的上层与 libuv 事件循环之间的关系吧。

正如我们前面看到关于事件循环的图,事件循环中有 4 个主要阶段。但是对于 libuv 来说,其实是有 7 个主要的阶段,包括:

  1. 定时器——到期的定时器回调和 interval 回调。
  2. 被挂起的 I/O 回调——处理被挂起的 I/O 事件,包括完成的和失败的。
  3. Idle handlers ——执行一些 libuv 内部操作。
  4. Prepare handlers ——执行一些 I/O 操作的预准备工作。
  5. I/O Poll——可选择性地等待 I/O 操作完成。
  6. Check handlers——执行一些 I/O 操作的后续处理工作,通常来说,setImmediate 添加的回调也会在这个阶段执行。
  7. Close handlers——执行一些 close 事件相关的操作比如 socket 连接等等。

现在,如果你记得第一篇文章的内容,你也许会有疑问:

  1. 什么是 Check handlers ?它并没有出现在事件循环的图中
  2. 什么是 I/O Polling(轮询)?为什么在执行完 I/O 成功的回调函数之后需要阻塞 I/O ?在 Node 中不是非阻塞的吗?

Check Handlers

当 NodeJs 初始化的时候,会将 setIKmmediate 添加的所有回调都在 libuv 中注册为 Check handlers,这样本质上就会使 setImmediate 添加的回调函数在 I/O 操作之后被执行。

I/O Polling

现在,你可能想要知道什么是 I/O 轮询。虽然我在图 1 中将 I/O 回调队列与 I/O 轮询放在了一起,但实际上,I/O 轮询是发生在 I/O 回调队列之后的。

重要的是,I/O 轮询是可选的,也就是说在某些情况下,I/O 轮询可能会不执行。我们一起来看一下 libuv 的源码:

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

while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    uv__run_timers(loop);
    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);

    uv__io_poll(loop, timeout);
    uv__run_check(loop);
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }

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

如果对 C 语法不熟悉不用担心,上面的代码简略看一下即可,它是 libuv 中 core.c 中的 uv_run 方法的代码片段,也是 NodeJs 事件循环的核心,下面我们通过图 3 来理解以上代码,一起来逐行理解:

  1. uv__loop_alive——检查是否有被引用的 handlers 调用或者被挂起的操作
  2. uv__update_time——用于标记到期的定时器,这会获取系统的当前时间并且更新本次循环的时间
  3. uv__run_timers——执行所有到期的定时器
  4. uv__run_pending——执行所有成功或失败的 I/O 事件
  5. uv__io_poll——I/O 轮询
  6. uv__run_check——检查所有校验函数(setImmediate 的回调也会在此时运行)
  7. uv__run_closing_handlers——执行所有 close 事件处理

在最开始,事件循环会去检查事件循环是否是存活的,这是通过 uv__loop_alive 函数来检查的,函数也非常简单:

static int uv__loop_alive(const uv_loop_t* loop) {
  return uv__has_active_handles(loop) ||
         uv__has_active_reqs(loop) ||
         loop->closing_handles != NULL;
}

uv__loop_active 函数返回一个布尔值,如果这个值为 true,那么:

  • 当前有活跃的句柄需要被处理
  • 当前有被挂起的系统请求
  • 当前有 close 事件需要被处理

如果 uv__loop_active 函数返回 true,那么事件循环就会一直保持执行。

执行完所有到期定时器的回调函数之后,uv__run_pending 函数就会被执行。这个函数将会遍历所有的 libuv 事件中的 pending_queue 中已完成的 I/O 操作,如果 pending_queue 队列为空,那么函数返回值为 0,否则 pending_queue 中的回调函数都会被执行,返回值为 1。

static int uv__run_pending(uv_loop_t* loop) {
  QUEUE* q;
  QUEUE pq;
  uv__io_t* w;

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

  QUEUE_MOVE(&loop->pending_queue, &pq);

  while (!QUEUE_EMPTY(&pq)) {
    q = QUEUE_HEAD(&pq);
    QUEUE_REMOVE(q);
    QUEUE_INIT(q);
    w = QUEUE_DATA(q, uv__io_t, pending_queue);
    w->cb(loop, w, POLLOUT);
  }

  return 1;
}

现在一起看一下 uv__io_poll 这个函数是如何实现 I/O 轮询的。

你可以看到 uv__io_poll 函数第二个参数接受一个 timeout 值,这个 timeout 值是由 uv_backend_timeout 函数计算得到的。uv__io_poll 使用这个 timeout 值来确定阻塞 I/O 操作的时长。如果 timeout 为 0,那么在本次事件循环中 I/O 轮询将会被跳过并进入执行 check handlers 阶段。那这个 timeout 是如何确定的呢?根据上面 uv_run 的代码,我们可以推断:

  • 如果事件循环以 UV_RUN_DEFAULT 模式运行,那么 timeout 值就由 uv_backend_timeout 方法来计算得到。
  • 如果事件循环以 UV_RUN_ONCE 模式来运行,并且 uv_run_pending 返回 0(例如 pending_queue 队列为空),timeout 值则由 uv_backend_time 方法计算得出。
  • 其他情况下,timeout 值都为 0。

你暂时不需要去了解事件循环的各种模式(例如:UV_RUNDEFAULT 或 UV_RUN_ONCE),如果想要了解,请看这里

我们来一起看一下 uv_backend_timeout 方法是如何计算出 timeout 值的:

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);
}
  • 如果 loop 的 stop_flag 被设置了值,timeout 返回 0
  • 如果当前没有活跃的句柄或挂起的任务,那么 timeout 返回 0
  • 如果当前存在被挂起的 idle 句柄,那么系统不会等待 I/O 完成,timeout 为 0
  • 如果当前 pending_queue 队列中已有已完成的 I/O 操作,那么系统不会等待 I/O 完成,timeout 为 0
  • 如果有 close handlers 需要被执行,那么系统不会等待 I/O 完成,timeout 为 0

如果以上的情况都没有发生,那么 uv__next_timeout 方法就会被触发,它会决定 libuv 等待 I/O 操作的时长。

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

  heap_node = heap_min((const struct heap*) &loop->timer_heap);
  if (heap_node == NULL)
    return -1; /* block indefinitely */

  handle = container_of(heap_node, uv_timer_t, heap_node);
  if (handle->timeout <= loop->time)
    return 0;

  diff = handle->timeout - loop->time;
  if (diff > INT_MAX)
    diff = INT_MAX;

  return diff;
}

uv__next_timeout 会返回最近一个定时器的值,如果当前没有定时器,那么它将返回 -1 表示无穷大。

你现在你已经知道了“为什么在执行完 I/O 成功的回调函数之后需要阻塞 I/O ?在 Node 中不是非阻塞的吗?”

如果系统中有待执行的任务,那么事件循环将不会被阻塞。如果没有待执行的任务,那么事件循环只会等待到下一个定时器到期之后继续运行(重新激活)。

我希望你仍然能紧跟文章的步伐,我知道这里会有很多细节,但是为了明白个中原理,我们必须明白代码的逻辑。

现在我们知道了事件循环会为了 I/O 操作完成而等待多长时间。这个 timeout 值会被传到 uv__io_poll 函数中,这个函数会观察所有发送过来的 I/O 操作请求,直到最近一个定时器设置的到期时间到期或者系统最大超时时间到期为止。在到期之后,事件循环会被重新激活并进入 "check handlers" 阶段。

I/O 操作的底层细节在每个系统平台都不一样,我不会深入这些 I/O 操作的细节中因为它非常复杂甚至可以编写另外一个系列的文章。

Some words about Threadpoll

目前为止,我们在文章中还没有谈论到线程池。正如我们在第一篇文章中看到的,线程池通常是用来处理文件 I/O 和DNS 操作中涉及文件 I/O 操作的 getaddrinfogetnameinfo 函数的。因为线程池的线程数量是有限的(默认为 4),所以如果没有多余的空闲线程使用的话那么多个文件 I/O 请求依然会被阻塞的。然而,线程池的大小可以通过环境变量 UV_THREADPOOL_SIZE 来增大数量,就文章的编写节点来说最大可以到 128,这样可以提高应用系统的性能。

不过,一个固定数量的线程池大小依然会成为 NodeJS 的性能瓶颈,因为不止文件 I/O,DNS 的 getaddrinfo,getnameinfo 函数会使用线程池,某些 CPU 密集型操作比如 Crypto 的 ramdomBytes,randomFill,pbkdf2 等等的这些为了不影响应用的性能都会使用 libuv 中的线程池,但这也会导致 I/O 操作的利用资源不足。

在 libuv 之前的草案中,有人建议将线程池的大小可以根据负载提供伸缩性,但是这个提案并没有被采纳,有兴趣的同学可以观察以下视频:

https://www.youtube.com/watch?v=sGTRmPiXD4Y

Wrap Up

在本篇文章中,我讲述了在 NodeJS 的 libuv 是如何执行 I/O 操作的细节,我相信现在你已经能够了解非阻塞,事件驱动模型了,如果你还有任何问题,请在评论区回复我。谢谢。

References:

这里有个小错误:

image

另外有几个疑问:

1.看完后,对 为什么在执行完 I/O 成功的回调函数之后需要阻塞 I/O ?

我的理解是: poll设置一个等待,是为了当快要到达但是还没有到达的定时器到达超时时间,然后直接从poll阶段跳回timer阶段执行定时器的回调,避免出现这种情况:处于poll阶段(此时叫做A)时,定时器还需要1s到达超时,如果此时poll不阻塞,从poll -> check -> timer只花了0.5s,此时定时器依然没有达到超时,这时候继续 timer -> ... -> poll -> timer 这里假设花了2s, 那么相当于timer从A阶段明明只有一秒就要到超时了,但是实际执行花了 0.5 + 2 = 2.5秒。
请问这个理解正确吗?

image

如果没有定时器的话,这里的poll设置的时间是无穷大,那么 A.岂不是会一直卡在这里轮询不会进入下一个阶段?B.如果在轮询时检测到了有定时器超时时间到了,是从 poll -> check -> close -> timer 还是直接从 poll -> timer?

谢谢。