nodejs 回调函数 里面赋值给外面_NodeJS 事件循环(第四部分)- 处理 IO

5d0bf303a74827e192b512afb2f70bc7.png

原文链接

文章系列路线图

  • NodeJS 事件循环(第一部分)- 事件循环机制概述
  • NodeJS 事件循环(第二部分)- Timers,Immediates,nextTick
  • Nodejs 事件循环(第三部分)- Promise,nextTicks,immediate
  • NodeJS 事件循环(第四部分)- 处理 IO
  • NodeJS 事件循环(第五部分)- 最佳实践

欢迎来到 NodeJS 事件循环系列。在这篇文章中,我打算谈论下在 NodeJS 中如何处理 IO 的细节。并且希望深入到事件循环的实现和 I/O 工作怎么与其他异步操作同时发生的。如果你没看这个系列之前的文章,建议你看下。

异步 I/O ... 因为阻塞太主流了

当谈论 NodeJS 时,我们谈论了很多异步 I/O。像我们在第一篇中讨论的那样,I/O 从来不意味着是同步的。

5fb6493f8ccbcc93390a8cd893b6a7bf.png

在所有的系统实现中,他们都为异步 I/O 提供了事件通知接口(Linux 中的 epoll/macOS 中的 kquue/ solaris 中的 event ports / windows 中的 IOCP)。NodeJS 利用这些平台级别的事件通知系统提供非阻塞的,异步 I/O。

正如我们看到的,NodeJS 是实体的集合最终聚合成高性能的 NodeJS 框架。包含以下实用工具:

  • Chrome v8 引擎 —  高性能 JavaScript 执行
  • Libuv  —  提供异步 I/O 的事件循环
  • c-ares  —  提供 DNS 操作
  • 其他的插件如 (http-parser, crypto and zlib)

917b33bbb97002fc8c3e35ade97f72c7.png
NodeJS 的架构

在这篇文章中,我们将会讨论 Libuv 及如何为 Node 提供异步的 I/O。让我们看下事件循环图表。

42fd283dc3a9400abef4fab5f4e5df2a.png
图表2:事件循环概括

让我们复习下,到目前为止关于事件循环我们学了哪些:

  • 事件循环从处理所有过期的计时器开始。(实际上在处理之前还会检查下 nextTick 和 microtask)
  • 然后它将处理所有的即将发生的(本来大多数 I/O 回调会在本轮的 I/O 轮询阶段处理掉,但有上一轮延迟到这一轮的 I/O 回调。另外还有一些 I/O 错误回调) I/O 操作,然后选择性(是否等待,取决于 Immediate 队列是否为空或 timers 队列是否有过期的 timer 等)地等待 I/O 事件。
  • 然后会移到 immediate 队列处理 setImmediate 回调
  • 最后会处理所有的 I/O 关闭回调
  • 在每个阶段之间,libuv 需要和 Node 架构的高层沟通(JavaScript),将会处理 process.nextTick 和 microtask 回调。

现在让我们试着理解下事件循环中如何处理 I/O。

I/O 是什么?
通常来说,任何涉及到额外的设备(除了 CPU)都被称为 I/O。最普遍抽象 I/O 是文件操作和 TCP/UDP 网络操作

Libuv 和 NodeJS I/O

JavaScript 本身没有处理 I/O 的设施。在 NodeJS 开发期间,libuv 最初就是为 Node 提供异步的 I/O,尽管当前,Libuv 作为一个独立库可以被单独使用。Libuv 在 NodeJS 架构中的角色是抽象内部 I/O 的复杂性和为Node 的上层(JavaScript)提供一个通用的接口,以便 Node 可以异步的处理 I/O 而不用担心目前运行所在的平台类型。

当心!
如果你不理解事件循环,我建议你读之前的文章。这里我会省略某些细节,因为我会更关注 I/O 本身。
我可能使用一些 libuv 的代码片段,我只会使用 Unix 平台的代码片段,只让事情简单点。window 平台的可能有点儿差异,应该差异不大。
我假设你有 C 语言的一些基础。没有经验要求,有个基本的了解就足够了。

正如我们在 NodeJS 架构图表中看到的,libuv 驻留在层架构中较低的层。JavaScript 的高层和 libuv 事件循环阶段的关系。

78f34c33ee425a8646429f7b86439a7c.png
图表3:事件循环和 JavaScript

正如我们在图表2 中看到的,事件循环中有 4 个阶段。但是,当谈论到 libuv,有 7 个阶段。它们是:

  1. Timers  —  被 setTimeout 和 setInterval 调度的过期 timer 和 interval 回调 在这个阶段触发
  2. 即将发生的 I/O 回调 —  任何 完成的/错误的I/O 操作将在这里执行。(完成是指上一轮完成的,延迟到这一轮了)
  3. Idle 处理器 —  libuv 一些内部操作
  4. Prepare 处理器 —  在 I/O 轮询之前的一些准备工作
  5. I/O 轮询 —  处理 I/O 回调及选择性地等待 I/O 操作
  6. Check 处理器 —  执行一些 I/O 轮询后的一些时候工作, 通常, 执行被 setImmediate 调度的回调。
  7. Close 处理器  —  处理所有的关闭 I/O 回调 ( 关闭的 socket 连接等)

现在,如果你记得这个系列的第一篇文章,你可能会猜想...

  1. 什么是检查处理器?在事件循环图表中没有的
  2. I/O 轮询是什么? 为什么执行完完成的 I/O 后要阻塞 I/O ?Node 不应该是非阻塞的吗?

让我们回答上面的问题。

检查处理器

当 NodeJS 初始化后,它会在 Libuv 中设置所有的 setImmediate 回调注册为检查处理器。这本质上意味着任何一个你使用 setImmediate 的回调最终会在 libuv 的检查处理器队列,保证在 I/O 轮询之后处理。

I/O 轮询

你现在可能想知道 I/O 轮询是什么。尽管我在图表 1 中将 I/O 回调队列和 I/O 轮询放在了一个阶段,消费完 completed/errored 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 - 检查下是否有引用的需要触发的处理器,或者任何活跃的即将发生的操作
  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_handles - 运行所有的关闭回调

首先,事件循环会检查事件循环是否活着,通过触发 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_alive 简单地返回了一个 boolean 值。这个值是 true, 如果:

  • 有需要触发的活跃的处理器(handles)
  • 有活跃的未决的请求(活跃的操作)
  • 有任何一个关闭处理器需要触发

事件循环将会继续旋转只要 uv__loop_alive 函数返回 true。

运行完所有的过期的计时器,uv_run_pending 将会被触发。这个函数会仔细检查存储在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;
}

现在,让我们看下 I/O 轮询,通过触发 libuv 中的 uv__io_poll 函数。

你应该看到 uv_io_poll 函数接受第二个 timeout 参数,该参数是由 uv_backend_timeout 返回的。uv__io_poll 使用 timeout 决定应该阻塞多长的 I/O。如果 timeout 是 0,I/O 轮询将会跳过并且事件循环会移到检查处理器阶段(setImmediate phase)。timeout 值得由来是一个有趣的部分。基于上面 uv_run 的代码,我们可以推断出:

  • 如果事件循环以 UV_RUN_DEFAULT 模式运行,timeout 是由 uv_backend_timeout 函数算出来的。
  • 如果事件循环运行在 UV_RUN_ONCE模式,并且如果 uv_run_pending 返回 0(pending_queue 是空的),timeout 由uv_backend_timeout 方法算出来的。
  • 否则,timeout 是 0。
不要担心 事件循环模式的差异性比如 UV_RUN_DEFAULT 和 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);
}
  • 如果循环的 stop_flag 被设置了,决定着循环要准备退出了,timeout 是 0。
  • 如果没有活跃的处理器或者活跃的未决的操作,是没有意义的等待。因此 timeout 是 0。
  • 如果这里有未决的空闲的处理器需要处理,不应该等待,因此 timeout 是 0。
  • 如果在 pengding_queue 中有完成的 I/O 处理器,不应该等待,因此 timeout 是 0。
  • 如果有任何未决的关闭的处理器要处理,不应该等待,因此 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 做了什么,返回返回最近的 timer 的过期时间,如果没有 timers,它将返回 -1,表明 无限等待。

现在,你应该对这个问题有答案了。“为什么执行完任何一个完成的 I/O 回调要阻塞 I/O?Node 不应该是非阻塞的吗”....

如果有任何未决的任务需要处理,事件循环就不会阻塞。如果这里没有未决的任务需要执行,它将会阻塞直到下一个 timer 到期,会重新激活循环。

我希望你仍然在跟着我的思路!!!我知道这里牵扯了太多的细节,但是为了彻底高明白,必须清楚发生了什么。

现在我们知道循环应该等待 I/O 多长时间。timerout 值被传递给了 uv__io_poll 函数。这个函数会等待任何一个到来的 I/O 操作,直到 timeout 过期或者到达系统指定的最大安全的 timeout。timeout 之后,事件循环将会再一次变得 活跃并且会移到 “检查处理器”阶段。

I/O 循环在不同的操作系统上面发生的不一样。在 Linux 中,是被 epoll_wait 内核系统调用,在 macOS 上使用的是 kqueue。在 windows 上面,使用的是 IOCP 的 getQueueCompletionStatus。我不会深入到 I/O 轮询是如何工作的,因为非常复杂,需要另一个系列才能说清楚(应该是不会写的)。

关于线程池

目前为止,在这篇文章中还没有谈论线程池。像我们在这个系列的第一篇文章看到的,线程池被用来执行部分 I/O 操作,在 DNS 操作中,调用 getaddrinfo 和 getnameinfo 仅仅是因为在不同操作系统平台文件 I/O 的复杂性。因为线程池限制的大小(默认 4),多个文件系统操作的请求可能阻塞直到一个线程变得可用。然而,线程池的大小使用环境变量 UV_THREADPOOL_SIZE 可以增加到 128(在本文撰写的时候),以增加应用的性能。

不过固定大小的线程池已经被确定为 NodeJS 应用程序的一个瓶颈,因为文件 I/O,getaddrinfo,getnameinfo 并不是唯一在线程池上运行的。特定的 CPU 密集型的加密操作,如 randomBytes,randomFill 和 pbkdf2 也是在线程池上运行的,防止负面影响程序的性能,但也应让 I/O 操作稀缺的线程资源变得可用。

从上一个 libuv 的改进建议,建议让线程池基于负载变得可扩展,但是这个提议被撤销了,可能在未来会换成一个插件化的 API。

总结

在这篇文章中,我描述了 NodeJS 中 I/O 操作的细节,深入到 libuv 源码。我相信非阻塞的,事件驱动的 NodeJS 模型对你更有意义。如果你有任何问题,我非常愿意回答。请在评论区评论。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值