Node.js如何运行
在本节中,您将了解 Node.js 内部是如何工作的,并了解反应堆模式,它是 Node.js 异步特性的核心。我们将介绍该模式背后的主要概念,如单线程架构和非阻塞 I/O,您还将了解这如何为整个 Node.js 平台奠定基础。
I/O 很慢
I/O(输入/输出的简称)无疑是计算机基本操作中最慢的操作。访问内存是纳秒级(10E-9 秒),而访问磁盘或网络上的数据则是毫秒级(10E-3 秒)。带宽也是如此。内存的传输速率始终保持在 GB/s 左右,而磁盘或网络的传输速率则从 MB/s 到 GB/s 不等。就 CPU 而言,I/O 通常并不昂贵,但会增加从向设备发送请求到操作完成之间的延迟。除此之外,我们还必须考虑人为因素。事实上,在很多情况下,应用程序的输入来自真人—例如鼠标点击—因此 I/O 的速度和频率不仅仅取决于技术方面,它可能比磁盘或网络慢很多个数量级。
阻塞 I/O
在传统的阻塞 I/O 编程中,与 I/O 请求相对应的函数调用会阻塞线程的执行,直到操作完成。在磁盘访问的情况下,阻塞时间可能只有几毫秒,而在用户操作(如按键)产生数据的情况下,阻塞时间可能长达几分钟甚至更长。下面的伪代码显示了一个针对套接字执行的典型阻塞线程:
// blocks the thread until the data is available
data = socket.read()
// data is available
print(data)
我们不难发现,使用阻塞 I/O 实现的网络服务器无法在同一线程中处理多个连接。这是因为套接字上的每次 I/O 操作都会阻塞对其他连接的处理。解决这个问题的传统方法是使用单独的线程(或进程)来处理每个并发连接。
这样,一个线程被 I/O 操作阻塞不会影响其他连接的可用性,因为它们是在单独的线程中处理的。
下面是这种情况的示例:
/image-2024-04-30-14-20-48-895.png)
图 1.1 着重强调了每个线程闲置和等待从相关连接接收新数据的时间。现在,如果我们再考虑到任何类型的 I/O 都可能阻塞请求—例如,在与数据库或文件系统交互时—我们很快就会意识到,线程需要阻塞多少次才能等待 I/O 操作的结果。不幸的是,线程占用的系统资源并不便宜—它会消耗内存并导致上下文切换—因此,为每个连接设置一个长期运行的线程,但在大部分时间内不使用它,就意味着浪费宝贵的内存和 CPU 周期。
非阻塞 I/O
除了阻塞式 I/O 外,大多数现代操作系统还支持另一种访问资源的机制,即非阻塞式 I/O。在这种运行模式下,系统调用总是立即返回,无需等待数据的读取或写入。如果在调用时没有结果,函数将直接返回一个预定义常量,表示此时没有数据可返回。
例如,在 Unix 操作系统中,fcntl() 函数用于操作现有的文件描述符(在 Unix 中表示用于访问本地文件或网络套接字的引用),将其操作模式更改为非阻塞模式(使用 O_NONBLOCK
标志)。一旦资源进入非阻塞模式,如果资源没有任何数据可供读取,任何读取操作都将失败,返回代码为 EAGAIN
。
处理这类非阻塞 I/O 的最基本模式是在一个循环中主动轮询资源,直到有实际数据返回。这就是所谓的 busy-waiting。下面的伪代码向你展示了如何使用非阻塞 I/O 和主动轮询循环从多个资源读取数据:
resources = [socketA, socketB, fileA]
while (!resources.isEmpty()) {
for (resource of resources) {
// try to read
data = resource.read()
if (data === NO_DATA_AVAILABLE) {
// there is no data to read at the moment
continue
}
if (data === RESOURCE_CLOSED) {
// the resource was closed, remove it from the list
resources.remove(i)
} else {
//some data was received, process it
consumeData(data)
}
}
}
正如你所看到的,通过这种简单的技术,可以在同一线程中处理不同的资源,但效率仍然不高。事实上,在前面的例子中,循环只会消耗宝贵的 CPU 来迭代大部分时间都不可用的资源。轮询算法通常会浪费大量的 CPU 时间。
事件复用
Busy-waiting 等待绝对不是处理非阻塞资源的理想技术,但幸运的是,大多数现代操作系统都提供了一种本地机制,可以高效处理并发的非阻塞资源。我们所说的就是同步事件解复用器(synchronous event demultiplexer,也称为事件通知接口)。
如果您对这个术语不熟悉,那么在电信领域,多路复用指的是将多个信号合并为一个信号,以便在容量有限的介质上轻松传输的方法。
解复用指的是相反的操作,即把信号重新拆分成原来的组成部分。这两个术语都用于其他领域(如视频处理),描述将不同事物合二为一的一般操作,反之亦然。
我们刚才提到的同步事件解复用器会监视多个资源,并在对其中一个资源执行的读或写操作完成后返回一个新事件(或一组事件)。这样做的好处是,同步事件解复用器当然是同步的,因此它会阻塞,直到有新事件需要处理。下面是使用通用同步事件解复用器读取两个不同资源的算法的伪代码:
watchedList.add(socketA, FOR_READ) // (1)
watchedList.add(fileB, FOR_READ)
while (events = demultiplexer.watch(watchedList)) { // (2)
// event loop
for (event of events) { // (3)
// This read will never block and will always return data
data = event.resource.read()
if (data === RESOURCE_CLOSED) {
// the resource was closed, remove it from the watched list
demultiplexer.unwatch(event.resource)
} else {
// some actual data was received, process it
consumeData(data)
}
}
}
让我们看看前面的伪代码会发生什么:
-
将资源添加到数据结构中,并将每个资源与特定操作(在我们的例子中是读取)关联起来。
-
使用要监视的资源组设置解复用器。对 demultiplexer.watch() 的调用是同步的,并且会阻塞,直到被监视的资源中的任何一个准备好被读取。此时,事件解复用器将从调用中返回,并可处理一组新的事件。
-
处理事件解复用器返回的每个事件。此时,与每个事件相关的资源都保证可以随时读取,并且在操作过程中不会阻塞。当所有事件都处理完毕后,流程将再次阻塞在事件解复用器上,直到再次有新事件可供处理。这就是所谓的事件循环。
有趣的是,通过这种模式,我们现在可以在单线程内处理多个 I/O 操作,而无需使用 busy-waiting 等待技术。现在我们应该更清楚为什么要讨论解复用了;只需使用一个线程,我们就能处理多个资源。图 1.2 将帮助您直观地了解网络服务器中的情况,该服务器使用同步事件解复用器和单线程来处理多个并发连接:
/image-2024-04-30-14-31-02-285.png)
由此可见,只使用一个线程并不会影响我们同时运行多个 I/O bound 任务的能力。这些任务分散在不同的时间段,而不是多个线程。如图 1.2 所示,这样做的明显好处是最大限度地减少了线程的总空闲时间。
但这并不是选择这种 I/O 模式的唯一原因。事实上,单线程对程序员处理并发问题的方式也有好处。整本书中,您将看到由于 JavaScript 单线程的特性,不存在进程内竞争条件和需要同步的多线程,因此我们可以使用更简单的并发策略。
反应堆模式
现在我们可以介绍反应器模式,它是前几节介绍的算法的一种特殊化。反应堆模式背后的主要思想是为每个 I/O 操作设置一个处理程序。在 Node.js 中,处理程序由回调函数(简称 cb)表示。一旦有事件发生并被事件循环处理,处理程序就会被调用。反应器模式的结构如图 1.3 所示:
/image-2024-04-30-14-33-56-115.png)
这是使用反应器模式的应用程序中发生的情况:
-
应用程序向事件解复用器提交请求,生成新的 I/O 操作。应用程序还会指定一个处理程序,在操作完成后调用。向事件解复用器提交新请求是一个非阻塞调用,它会立即将控制权返回给应用程序。
-
当一组 I/O 操作完成后,事件解复用器会将一组相应的事件推送到事件队列中。
-
此时,事件循环会遍历事件队列中的项。
-
对于每个事件,都会调用相关的处理程序。
-
处理程序是应用程序代码的一部分,它在执行完成后会将控制权交还给事件循环(5a)。在处理程序执行期间,它可以请求新的异步操作 (5b),从而将新项目添加到事件解复用器 (1)。
-
当事件队列中的所有项目都处理完毕后,事件循环会再次阻塞事件多路复用器,然后在有新事件发生时触发另一个循环。
异步行为现在已经很清楚了。应用程序在一个时间点(不阻塞)表达了访问资源的兴趣,并提供了一个处理程序,当操作完成后,该处理程序将在另一个时间点被调用。
当事件解复用器中不再有待处理的操作,事件队列中也不再有待处理的事件时,Node.js 应用程序就会退出。 |
我们现在可以定义 Node.js 核心的模式:
反应堆模式 通过阻塞来处理 I/O,直到从一组观察到的资源中获得新事件为止,然后通过将每个事件分派到关联的处理程序来做出反应。 |
Libuv,Node.js 的 I/O 引擎
每个操作系统都有自己的事件解复用器接口:Linux 上的 epoll、macOS 上的 kqueue 和 Windows 上的 I/O 完成端口 (IOCP) API。此外,即使在同一操作系统中,每个 I/O 操作也会因资源类型不同而表现迥异。例如,在 Unix 操作系统中,常规文件系统文件不支持非阻塞操作,因此为了模拟非阻塞行为,必须在事件循环之外使用单独的线程。
不同操作系统之间以及不同操作系统内部的所有这些不一致性要求为事件解复用器构建一个更高层次的抽象。这正是 Node.js 核心团队创建名为 libuv 的本地库的原因,其目的是让 Node.js 兼容所有主流操作系统,并规范不同类型资源的非阻塞行为。Libuv 代表了 Node.js 的底层 I/O 引擎,可能是 Node.js 所基于的最重要的组件。
除了抽象底层系统调用外,libuv 还实现了反应堆模式,从而为创建事件循环、管理事件队列、运行异步 I/O 操作和队列其他类型的任务提供了 API。
Nikhil Marathe 创建的免费在线书籍是了解有关 libuv 的更多信息的绝佳资源,可在 nodejsdp.link/uvbook 上获取。 |
Node.js 的秘诀
reactor 模式和 libuv 是 Node.js 的基本构建块,但我们还需要三个组件来构建完整的平台:
-
一组绑定,负责包装 libuv 和其他低级功能并将其公开给 JavaScript。
-
V8,最初由 Google 为 Chrome 浏览器开发的 JavaScript 引擎。 这就是 Node.js 如此快速高效的原因之一。 V8 因其革命性的设计、速度和高效的内存管理而备受赞誉。
-
实现高级 Node.js API 的核心 JavaScript 库。
这是创建 Node.js 的秘诀,下图代表了它的最终架构:
/image-2024-05-01-07-53-28-576.png)
我们的 Node.js 内部机制之旅到此结束。接下来,我们将了解在 Node.js 中使用 JavaScript 时需要考虑的一些重要方面。