NodeJS 与任何其他编程平台的区别在于它处理 I/O 的方式。当有人介绍 NodeJS 时,我们一直听到这样的话:“基于 google 的 v8 javascript 引擎的非阻塞、事件驱动的平台”。所有这些是什么意思?“非阻塞”和“事件驱动”是什么意思?所有这些问题的答案都在 NodeJS 的核心事件循环中。在本系列文章中,我将描述事件循环是什么、它是如何工作的、它如何影响我们的应用程序、如何充分利用它等等。在第一篇文章中,我将描述 NodeJS 的工作原理、它如何访问 I/O 以及它如何在不同平台上工作等。
后期系列路线图
- 事件循环和大图(本文)
- 计时器、立即数和下一个滴答声
- Promises、Next-Ticks 和 Immediates
- 处理输入/输出
- 事件循环最佳实践
- Node v11 中计时器和微任务的新变化
- JavaScript 事件循环与 Node JS 事件循环
反应器模式
NodeJS 在一个事件驱动模型中工作,该模型涉及一个Event Demultiplexer(事件分离器)和一个Event Queue。所有 I/O 请求最终都会生成一个完成/失败事件或任何其他触发器,称为Event。这些事件根据以下算法进行处理。
- 事件分离器接收 I/O 请求并将这些请求委托给适当的硬件。
- 一旦处理了 I/O 请求(例如,来自文件的数据可供读取,来自套接字的数据可供读取等),事件分离器将在一个特定的操作中添加注册的回调处理程序待处理的队列。这些回调称为事件,添加事件的队列称为事件队列。
- 当事件可以在事件队列中处理时,它们会按照接收到的顺序依次执行,直到队列为空。
- 如果事件队列中没有事件或事件分离器没有未决请求,则程序将完成。否则,该过程将从第一步继续。
编排整个机制的程序称为事件循环。
事件循环是一个单线程半无限循环。这被称为半无限循环的原因是当没有更多工作要做时,它实际上会在某个时刻退出。从开发人员的角度来看,这是程序退出的地方。
不要让自己与事件循环和 NodeJS EventEmitter 混淆。EventEmitter 是一个与 Event Loop 完全不同的概念。
上图是 NodeJS 如何工作的高级概述,并显示了称为反应器模式的设计模式的主要组件。但这比这复杂得多。那么这有多复杂呢?
事件解复用器不是在所有 OS 平台中执行所有类型 I/O 的单个组件。
事件队列不是此处显示的单个队列,所有类型的事件都在其中排队和出队。并且 I/O 不是唯一排队的事件类型。
所以让我们深入挖掘。
事件解复用器(事件分离器)
事件解复用器不是现实世界中存在的组件,而是反应器模式中的一个抽象概念。在现实世界中,事件解复用器已经在不同的系统中以不同的名称实现,例如Linux上的epoll、BSD 系统(macOS)上的kqueue、Solaris 中的事件端口、Windows 中的IOCP(输入输出完成端口)等。 NodeJS 消耗了这些实现提供的低级非阻塞、异步硬件 I/O 功能。
文件 I/O 中的复杂性
但令人困惑的事实是,并非所有类型的 I/O 都可以使用这些实现来执行。即使在同一个操作系统平台上,支持不同类型的 I/O 也很复杂。通常,网络 I/O 可以使用这些 epoll、kqueue、事件端口和 IOCP 以非阻塞方式执行,但文件 I/O 要复杂得多。某些系统(例如 Linux)不支持文件系统访问的完全异步。并且在 macOS 系统中使用 kqueue 的文件系统事件通知/信号存在限制(您可以在此处阅读有关这些复杂情况的更多信息)。为了提供完全的异步性,解决所有这些文件系统的复杂性非常复杂/几乎不可能。
DNS 中的复杂性
与文件 I/O 类似,Node API 提供的某些 DNS 功能也具有一定的复杂性。由于 NodeJS 的 DNS 功能,例如dns.lookup访问系统配置文件,例如nsswitch.conf,resolv.conf和/etc/hosts,因此上述文件系统复杂性也适用于dns.lookup功能。
解决方案?
因此,引入了一个线程池来支持无法由硬件异步 I/O 实用程序(例如 epoll/kqueue/event 端口或 IOCP)直接寻址的 I/O 功能。现在我们知道并不是所有的 I/O 函数都发生在线程池中。NodeJS 已经尽力使用非阻塞和异步硬件 I/O 来完成大部分 I/O,但是对于阻塞或难以处理的 I/O 类型,它使用线程池。
🤔 然而,I/O 并不是在线程池上执行的唯一类型的任务。还有一些Node.js的crypto功能,例如crypto.pbkdf2,异步的版本crypto.randomBytes,crypto.randomFill以及异步版本的zlib功能,这是因为它们是非常CPU密集型的libuv线程池运行。在线程池上运行它们可以防止事件循环的阻塞。
齐聚一堂
正如我们所看到的,在现实世界中,在所有不同类型的操作系统平台上支持所有不同类型的 I/O(文件 I/O、网络 I/O、DNS 等)真的很困难。一些 I/O 可以使用本机硬件实现来执行,同时保持完全异步,并且有一些 I/O 类型应该在线程池中执行,这样才能保证异步性。
开发人员对 Node 的一个常见误解是 Node 在线程池中执行所有 I/O。
为了在支持跨平台 I/O 的同时管理整个过程,应该有一个抽象层来封装这些平台间和平台内的复杂性,并为 Node.js 的上层公开一个通用的 API。
那么是谁做的呢?请欢迎…。
官方 libuv 标志 ( https://github.com/libuv/libuv )
来自官方的 libuv 文档,
libuv 是一个跨平台的支持库,最初是为 NodeJS 编写的。它是围绕事件驱动的异步 I/O 模型设计的。
该库提供的不仅仅是对不同 I/O 轮询机制的简单抽象:“句柄”和“流”为套接字和其他实体提供了高级抽象;除其他外,还提供了跨平台文件 I/O 和线程功能。
现在让我们看看 libuv 是如何组成的。下图来自官方的 libuv 文档,描述了在公开通用 API 时如何处理不同类型的 I/O。
来源:http : //docs.libuv.org/en/v1.x/_images/architecture.png
现在我们知道事件解复用器,不是一个原子实体,而是一个由 Libuv 抽象出来并暴露给 NodeJS 上层的 I/O 处理 API 的集合。libuv 为 Node 提供的不仅仅是事件解复用器。Libuv 为 NodeJS 提供了整个事件循环功能,包括事件队列机制。
现在让我们看看事件队列。
事件队列
事件队列应该是一个数据结构,其中所有事件都被事件循环依次排队和处理,直到队列为空。但是这在 Node 中发生的方式与抽象反应器模式的描述方式完全不同。那么它有什么不同呢?
NodeJS 中有不止一个队列,不同类型的事件在它们自己的队列中排队。
在处理完一个阶段之后,在进入下一个阶段之前,事件循环将处理两个中间队列,直到中间队列中没有剩余的项目。
那么有多少队列呢?什么是中间队列?
原生 libuv 事件循环处理的队列主要有 4 种类型。
- 过期计时器和间隔队列— 由使用添加的过期计时器的回调setTimeout或使用添加的间隔函数组成setInterval。
- IO 事件队列- 已完成的 IO 事件
- 立即队列- 使用setImmediate函数添加的回调
- 关闭处理程序队列— 任何close事件处理程序。
请注意,尽管为了简单起见,我将所有这些都称为“队列”,但其中一些实际上是不同类型的数据结构(例如,计时器存储在最小堆中)
除了这 4 个主队列之外,还有 2 个有趣的队列,我之前提到过它们是“中间队列”,由 Node.js 处理。尽管这些队列不是 libuv 本身的一部分,而是 NodeJS 的一部分。他们是,
- Next Ticks Queue — 使用process.nextTick函数添加的回调
-
它是如何工作的?
如下图所示,Node 通过检查计时器队列中是否有任何过期计时器来启动事件循环,并在每个步骤中遍历每个队列,同时维护要处理的总项目数的参考计数器。处理完关闭处理程序队列后,如果任何队列中都没有要处理的项目并且没有挂起的操作,则循环将退出。事件循环中每个队列的处理可以看作是事件循环的一个阶段。
红色描绘的中间队列的有趣之处在于,一旦一个阶段完成,事件循环就会检查这两个中间队列是否有任何可用项目。如果中间队列中有任何可用的项目,事件循环将立即开始处理它们,直到两个直接队列都被清空。一旦它们为空,事件循环将继续到下一阶段。
例如,事件循环当前正在处理有 5 个处理程序的立即队列。同时,两个处理程序被添加到下一个tick队列。一旦事件循环完成了即时队列中的 5 个处理程序,事件循环将在移动到关闭处理程序队列之前检测到下一个tick队列中有两个项目要处理。然后它将执行下一个tick队列中的所有处理程序,然后将移动到处理关闭处理程序队列。下一个tick(事件)队列与其他微任务
下一个tick队列比其他微任务队列具有更高的优先级。尽管如此,当 libuv 在一个阶段结束时与 Node 的更高层进行通信时,它们都在事件循环的两个阶段之间进行处理。你会注意到我用深红色显示了下一个tick队列,这意味着下一个tick队列在开始处理微任务队列中的已解决承诺之前被清空。
下一个tick队列优先于已解决的承诺仅适用于 v8 提供的原生 JS 承诺。如果您使用诸如q或 之类的库bluebird,您将观察到完全不同的结果,因为它们早于原生 promise 并且具有不同的语义。
q并且bluebird他们自己处理已解决承诺的方式也有所不同,我将在稍后的博客文章中解释这一点。
这些所谓的“中间”队列的约定引入了一个新问题,IO 饥饿。使用process.nextTick函数广泛填充下一个tick队列将迫使事件循环无限期地继续处理下一个tick队列而不向前移动。这将导致 IO 饥饿,因为如果不清空下一个tick队列,事件循环将无法继续。
为了防止这种情况,曾经有一个可以使用process.maxTickDepth参数设置的下一个tick队列的最大限制,但由于某种原因,它从 NodeJS v0.12 开始被删除。
我将在后面的文章中通过示例深入描述这些队列中的每一个。
最后,现在您知道什么是事件循环、它是如何实现的以及 Node 如何处理异步 I/O。现在让我们看看 Libuv 在 NodeJS 架构中的位置。
节点 JS 架构
我希望你会发现这很有用,在以后的帖子中,我将描述, 定时器、立即数和 process.nextTick
- 已解决的承诺和 process.nextTick
- 处理输入/输出
- 处理事件循环的最佳实践