![5d0bf303a74827e192b512afb2f70bc7.png](https://img-blog.csdnimg.cn/img_convert/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](https://img-blog.csdnimg.cn/img_convert/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](https://img-blog.csdnimg.cn/img_convert/917b33bbb97002fc8c3e35ade97f72c7.png)
在这篇文章中,我们将会讨论 Libuv 及如何为 Node 提供异步的 I/O。让我们看下事件循环图表。
![42fd283dc3a9400abef4fab5f4e5df2a.png](https://img-blog.csdnimg.cn/img_convert/42fd283dc3a9400abef4fab5f4e5df2a.png)
让我们复习下,到目前为止关于事件循环我们学了哪些:
- 事件循环从处理所有过期的计时器开始。(实际上在处理之前还会检查下 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](https://img-blog.csdnimg.cn/img_convert/78f34c33ee425a8646429f7b86439a7c.png)
正如我们在图表2 中看到的,事件循环中有 4 个阶段。但是,当谈论到 libuv,有 7 个阶段。它们是:
- Timers — 被 setTimeout 和 setInterval 调度的过期 timer 和 interval 回调 在这个阶段触发
- 即将发生的 I/O 回调 — 任何 完成的/错误的I/O 操作将在这里执行。(完成是指上一轮完成的,延迟到这一轮了)
- Idle 处理器 — libuv 一些内部操作
- Prepare 处理器 — 在 I/O 轮询之前的一些准备工作
- I/O 轮询 — 处理 I/O 回调及选择性地等待 I/O 操作
- Check 处理器 — 执行一些 I/O 轮询后的一些时候工作, 通常, 执行被 setImmediate 调度的回调。
- Close 处理器 — 处理所有的关闭 I/O 回调 ( 关闭的 socket 连接等)
现在,如果你记得这个系列的第一篇文章,你可能会猜想...
- 什么是检查处理器?在事件循环图表中没有的
- 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,上面的代码将更有意义。让我们试着一行一行地看下。
- uv_loop_alive - 检查下是否有引用的需要触发的处理器,或者任何活跃的即将发生的操作
- uv_update_time - 这会发送一个系统调用获取当前的时间,并且更新循环时间(为了辨识计时器是否过期)
- uv_run_timers - 运行所有过期的计时器
- uv_run_pending - 运行所有的 完成的/错误的 I/O 回调
- uv_io_poll - I/O 轮询
- uv_run_check - 运行所有的检查处理器(setImmediate 回调将会在这里运行)
- 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 模型对你更有意义。如果你有任何问题,我非常愿意回答。请在评论区评论。