欢迎回到 NodeJS 事件循环系列。在这篇文章中,我将详细讨论 NodeJS 中如何处理 I/O。我希望深入研究事件循环的实现以及 I/O 如何与其他异步操作一起工作。如果您错过了本系列之前的任何文章,我强烈建议您阅读我在以下路线图部分中列出的内容。我在前 3 篇博文中描述了 NodeJS 事件循环中的许多其他概念。
后期系列路线图
- 事件循环和大图
- 计时器、立即数和下一个滴答声
- Promises、Next-Ticks 和 Immediates
- 处理 I/O(本文)
- 事件循环最佳实践
- Node v11 中计时器和微任务的新变化
-
异步 I/O…. 因为阻塞太主流了!
谈到 NodeJS,我们经常谈论异步 I/O。正如我们在本系列的第一篇文章中讨论的那样,I/O 从来都不是同步的。
在所有操作系统实现中,它们都为异步 I/O 提供事件通知接口(Linux 中的 epoll/macOS 中的 kqueue/solaris/IOCP 中的事件端口等)。NodeJS 利用这些平台级事件通知系统来提供非阻塞、异步 I/O。
正如我们所看到的,NodeJS 是一个实用程序的集合,这些实用程序最终会聚合到高性能的 NodeJS 运行时中。这些实用程序包括, Chrome v8 引擎——用于高性能 JavaScript 评估
- Libuv — 具有异步 I/O 的事件循环
- c-ares — 用于 DNS 操作
- 其他附加组件,例如(http-parser、crypto和zlib)
Node JS 高级架构
在本文中,我们将讨论 Libuv 以及它如何为 Node.js 提供异步 I/O。让我们再次看一下事件循环图。
图 2:事件循环简而言之
让我们回顾一下到目前为止我们学到的关于事件循环的知识:
- 事件循环从执行所有过期计时器的处理程序开始
- 然后它将处理任何挂起的 I/O 操作,并可选择等待任何挂起的 I/O 完成。
- 然后它将继续使用 setImmediate 回调
- 最后,它将处理任何 I/O 关闭处理程序。
- 在每个阶段之间,libuv 需要将阶段的结果传达给 Node 架构的更高层(即 JavaScript)。每次发生这种情况时,任何process.nextTick 回调和其他微任务回调将被执行。
现在,让我们尝试了解 NodeJS 如何在其事件循环中执行 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 上层提供通用接口,以便 Node 可以执行平台无关的异步 I/O,而不必担心它运行在什么平台上。
警告!
如果您对事件循环没有基本的了解,我建议您阅读本系列的前几篇文章。为简洁起见,我可能会在此处省略某些细节,因为我想在本文中更多地关注 I/O
我可能会使用 libuv 本身的一些代码片段,我只会使用 Unix 特定的片段和示例,只是为了使事情更简单。特定于 Windows 的代码可能会有所不同,但应该没有太大区别。
我假设您可以理解一小段 C 代码。不需要专业知识,但对流程有基本的了解就足够了。
正如我们在之前的 NodeJS 架构图中看到的,libuv 驻留在分层架构的较低层中。下面我们来看看NodeJS上层与libuv事件循环各阶段的关系。
图 3:事件循环和 JavaScript
正如我们之前在图 2(简而言之事件循环)中看到的,事件循环有 4 个可区分的阶段。但是,对于 libuv,有 7 个可区分的阶段。他们是,
- 计时器 - 由setTimeout和setInterval将调用的过期计时器和间隔回调。
- 待处理 I/O 回调 — 此处要执行的任何已完成/错误 I/O 操作的待处理回调。
- 空闲处理程序 — 执行一些 libuv 内部操作。
- 准备处理程序——在轮询 I/O 之前执行一些准备工作。
- I/O 轮询 — 可选择等待任何 I/O 完成。
- 检查处理程序——在轮询 I/O 后执行一些事后工作。通常,setImmediate这里会调用由 调度的回调。
- 关闭处理程序 - 执行任何关闭的 I/O 操作(关闭的套接字连接等)的关闭处理程序
现在,如果您还记得本系列的第一篇文章,您可能想知道……
- 什么是检查处理程序?它也没有出现在事件循环图中。
- 什么是 I/O 轮询?为什么我们在执行任何完成的 I/O 回调后阻塞 I/O?Node不应该是非阻塞的吗?
检查处理程序
当 NodeJS 初始化时,它将所有setImmediate回调设置为在 libuv 中注册为检查处理程序。这实质上意味着您设置使用的任何回调setImmediate最终都将进入 Libuv 检查句柄队列,该队列保证在其事件循环期间的 I/O 操作之后执行。
输入/输出轮询
现在,您可能想知道 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;
}
libuv:core.c
哎哟! 对于那些不熟悉 C 的人来说,这似乎有点令人费解。但是让我们尝试了解一下它,而不必太担心它。上面的代码是一部分uv_run方法,它驻留在core.clibuv源文件中。但最重要的是,这是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;
}
libuv:core.c
uv__loop_alive函数只返回一个布尔值。这个值是true如果:
- 有要调用的活动句柄,
- 有活动请求(活动操作)待处理
- 有任何要调用的关闭处理程序
只要uvloop_alive函数返回true,事件循环就会一直旋转。
运行完所有过期定时器的回调uvrun_pending函数后,函数将被调用。该函数将遍历存储pending_queue在 libuv 事件中的已完成 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;
}
libuv:core.c
现在让我们看一下通过调用uvio_polllibuv 中的函数来执行的 I/O 轮询。
您应该看到该uvio_poll函数接受timeout由uv_backend_timeout函数计算的第二个参数。uv__io_poll使用超时来确定它应该为 I/O 阻塞多长时间。如果超时值为零,则 I/O 轮询将被跳过,事件循环将移至检查处理程序 ( setImmediate) 阶段。决定价值的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);
}
libuv core.c
- 如果stop_flag设置了确定循环即将退出的循环,则超时为0。
- 如果没有活动句柄或活动操作挂起,则无需等待,因此超时为0.
- 如果有待执行的空闲句柄,则不应等待 I/O。因此,超时为0。
- 如果 中有已完成的 I/O 处理程序pending_queue,则不应等待 I/O。因此超时是0.
- 如果有任何关闭处理程序待执行,则不应等待 I/O。因此,超时为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;
}
什么uvnexttimeout是,它将返回最接近的计时器值的值。如果没有计时器,它将返回-1指示无穷大。
现在您应该对“为什么我们在执行任何已完成的 I/O 回调后阻塞 I/O?Node 不应该是非阻塞的吗?”……
如果有任何待执行的任务要执行,则事件循环不会被阻塞。如果没有待执行的任务,它只会被阻塞,直到下一个计时器关闭,这会重新激活循环。
希望大家继续关注我!!!我知道这对你来说可能太详细了。但是要清楚地理解这一点,有必要对下面发生的事情有一个清晰的认识。_
现在我们知道循环应该等待任何 I/O 完成多长时间。timeout然后将该值传递给uvio_poll函数。此函数将监视任何传入的 I/O 操作,直到该操作timeout过期或达到系统指定的最大安全超时。超时后,事件循环将再次激活并进入“检查处理程序”阶段。
I/O 轮询在不同的操作系统平台上发生的方式不同。在 Linux 中,这是由epoll_wait内核系统调用执行的,在 macOS 上使用kqueue. 在 Windows 中,它是GetQueuedCompletionStatus在 IOCP(输入输出完成端口)中使用的。我不会深入研究 I/O 轮询的工作原理,因为它真的很复杂,值得再写一系列文章(我认为我不会写)。
关于线程池的一些话
到目前为止,我们还没有在本文中讨论线程池。正如我们在本系列的第一篇文章中看到的,线程池主要用于执行所有文件 I/O 操作,getaddrinfo而getnameinfo在 DNS 操作期间调用仅仅是由于不同平台的文件 I/O 的复杂性(对于一个扎实的想法这些复杂性,请阅读这篇文章)。由于线程池的大小是有限的(默认大小为 4),对文件系统操作的多个请求仍然可以被阻塞,直到一个线程可以工作。但是,可以使用环境变量将线程池的大小增加到128(在撰写本文时)UV_THREADPOOL_SIZE,以提高应用程序的性能。
不过,这个固定大小的线程池已经确定成为应用的NodeJS一个瓶颈,因为文件I / O, getaddrinfo,getnameinfo是不是唯一的操作由线程池中进行。某些 CPU 密集型加密操作,例如randomBytes,randomFill和pbkdf2也在 libuv 线程池上运行,以防止对应用程序的性能产生任何不利影响,但是,这也使可用线程成为 I/O 操作的稀缺资源。
与之前的 libuv 增强提案一样,建议根据负载使线程池可扩展,但该提案最终已被撤回,以便用将来可能引入的可插拔线程 API 替换它。
本文的某些部分的灵感来自 Saúl Ibarra Corretgé 在 NodeConfEU 2016 上所做的演讲。如果您想了解更多关于 libuv 的信息,我强烈建议您观看。
包起来
在这篇文章中,我详细描述了如何在 NodeJS 中执行 I/O,深入研究 libuv 源代码本身。我相信 NodeJS 的非阻塞、事件驱动模型现在对你更有意义。如果您有任何问题,我真的很想回答。因此,请不要犹豫,回复这篇文章。如果你真的喜欢这篇文章,我会很高兴你能鼓掌鼓励我写更多。谢谢。
https://blog.csdn.net/yezhenxu1992/article/details/51731237
https://zhuanlan.zhihu.com/p/37756195