- Welcome to the Node.js Platform
- Node.js Essential Patterns
- Asynchronous Control Flow Patterns with Callbacks
- Asynchronous Control Flow Patterns with ES2015 and Beyond
- Coding with Streams
- Design Patterns
- Writing Modules
- Advanced Asynchronous Recipes
- Scalability and Architectural Patterns
- Messaging and Integration Patterns
- Welcome to the Node.js Platform
- Node.js 的发展
- Node.js 的特点
- 介绍 Node.js 6 和 ES2015 的新语法
- reactor 模式
- Node.js Essential Patterns
- Asynchronous Control Flow Patterns with Callbacks
- Asynchronous Control Flow Patterns with ES2015 and Beyond
- Coding with Streams
- Design Patterns
- Writing Modules
- Advanced Asynchronous Recipes
- Scalability and Architectural Patterns
- Messaging and Integration Patterns
reactor 模式
reactor 模式
是 Node.js
异步编程的核心模块,其核心概念是: 单线程
、 非阻塞 I/O
,通过下列例子可以看到 reactor 模式
在 Node.js
平台的体现。
I/O 是缓慢的
在计算机的基本操作中,输入输出肯定是最慢的。访问内存的速度是纳秒级( 10e-9 s
),同时访问磁盘上的数据或访问网络上的数据则更慢,是毫秒级( 10e-3 s
)。内存的传输速度一般认为是 GB/s
来计算,然而磁盘或网络的访问速度则比较慢,一般是 MB/s
。虽然对于 CPU
而言, I/O
操作的资源消耗并不算大,但是在发送 I/O
请求和操作完成之间总会存在时间延迟。除此之外,我们还必须考虑人为因素,通常情况下,应用程序的输入是人为产生的,例如:按钮的点击、即时聊天工具的信息发送。因此,输入输出的速度并不因网络和磁盘访问速率慢造成的,还有多方面的因素。
阻塞 I/O
在一个 阻塞 I/O
模型的进程中, I/O
请求会阻塞之后代码块的运行。在 I/O
请求操作完成之前,线程会有一段不定长的时间浪费。(它可能是毫秒级的,但甚至有可能是分钟级的,如用户按着一个按键不放的情况)。以下例子就是一个 阻塞 I/O
模型。
// 直到请求完成,数据可用,线程都是阻塞的
data = socket.read();
// 请求完成,数据可用
print(data);
我们知道, 阻塞 I/O
的服务器模型并不能在一个线程中处理多个连接,每次 I/O
都会阻塞其它连接的处理。出于这个原因,对于每个需要处理的并发连接,传统的 web 服务器的处理方式是新开一个新的进程或线程(或者从线程池中重用一个进程)。这样,当一个线程因 I/O
操作被阻塞时,它并不会影响另一个线程的可用性,因为他们是在彼此独立的线程中处理的。
通过下面这张图:
通过上面的图片我们可以看到每个线程都有一段时间处于空闲等待状态,等待从关联连接接收新数据。如果所有种类的 I/O
操作都会阻塞后续请求。例如,连接数据库和访问文件系统,现在我们能很快知晓一个线程需要因等待 I/O
操作的结果等待许多时间。不幸的是,一个线程所持有的 CPU
资源并不廉价,它需要消耗内存、造成 CPU
上下文切换,因此,长期占有 CPU
而大部分时间并没有使用的线程,在资源利用率上考虑,并不是高效的选择。
非阻塞 I/O
除 阻塞 I/O
之外,大部分现代的操作系统支持另外一种访问资源的机制,即 非阻塞 I/O
。在这种机制下,后续代码块不会等到 I/O
请求数据的返回之后再执行。如果当前时刻所有数据都不可用,函数会先返回预先定义的常量值(如 undefined
),表明当前时刻暂无数据可用。
例如,在 Unix
操作系统中, fcntl()
函数操作一个已存在的文件描述符,改变其操作模式为 非阻塞 I/O
(通过 O_NONBLOCK
状态字)。一旦资源是非阻塞模式,如果读取文件操作没有可读取的数据,或者如果写文件操作被阻塞,读操作或写操作返回 -1
和 EAGAIN
错误。
非阻塞 I/O
最基本的模式是通过轮询获取数据,这也叫做 忙-等模型 。看下面这个例子,通过 非阻塞 I/O
和轮询机制获取 I/O
的结果。
resources = [socketA, socketB, pipeA];
while(!resources.isEmpty()) {
for (i = 0; i < resources.length; i++) {
resource = resources[i];
// 进行读操作
let data = resource.read();
if (data === NO_DATA_AVAILABLE) {
// 此时还没有数据
continue;
}
if (data === RESOURCE_CLOSED) {
// 资源被释放,从队列中移除该链接
resources.remove(i);
} else {
consumeData(data);
}
}
}
我们可以看到,通过这个简单的技术,已经可以在一个线程中处理不同的资源了,但依然不是高效的。事实上,在前面的例子中,用于迭代资源的循环只会消耗宝贵的 CPU
,而这些资源的浪费比起 阻塞 I/O
反而更不可接受,轮询算法通常浪费大量 CPU
时间。
事件多路复用
对于获取非阻塞的资源而言, 忙-等模型
不是一个理想的技术。但是幸运的是,大多数现代的操作系统提供了一个原生的机制来处理并发,非阻塞资源(同步事件多路复用器)是一个有效的方法。这种机制被称作事件循环机制,这种事件收集和 I/O 队列
源于 发布-订阅模式
。事件多路复用器收集资源的 I/O
事件并且把这些事件放入队列中,直到事件被处理时都是阻塞状态。看下面这个伪代码:
socketA, pipeB;
wachedList.add(socketA, FOR_READ);
wachedList.add(pipeB, FOR_READ);
while(events = demultiplexer.watch(wachedList)) {
// 事件循环
foreach(event in events) {
// 这里并不会阻塞,并且总会有返回值(不管是不是确切的值)
data = event.resource.read();
if (data === RESOURCE_CLOSED) {
// 资源已经被释放,从观察者队列移除
demultiplexer.unwatch(event.resource);
} else {
// 成功拿到资源,放入缓冲池
consumeData(data);
}
}
}
事件多路复用的三个步骤:
- 资源被添加到一个数据结构中,为每个资源关联一个特定的操作,在这个例子中是
read
。 - 事件通知器由一组被观察的资源组成,一旦事件即将触发,会调用同步的
watch
函数,并返回这个可被处理的事件。 - 最后,处理事件多路复用器返回的每个事件,此时,与系统资源相关联的事件将被读并且在整个操作中都是非阻塞的。直到所有事件都被处理完时,事件多路复用器会再次阻塞,然后重复这个步骤,以上就是
event loop
。
上图可以很好的帮助我们理解在一个单线程的应用程序中使用同步的时间多路复用器和非阻塞 I/O
实现并发。我们能够看到,只使用一个线程并不会影响我们处理多个 I/O
任务的性能。同时,我们看到任务是在单个线程中随着时间的推移而展开的,而不是分散在多个线程中。我们看到,在单线程中传播的任务相对于多线程中传播的任务反而节约了线程的总体空闲时间,并且更利于程序员编写代码。在这本书中,你可以看到我们可以用更简单的并发策略,因为不需要考虑多线程的互斥和同步问题。
在下一章中,我们有更多机会讨论 Node.js
的并发模型。
介绍 reactor 模式
现在来说 reactor 模式
,它通过一种特殊的算法设计的处理程序(在 Node.js
中是使用一个回调函数表示),一旦事件产生并在事件循环中被处理,那么相关 handler
将会被调用。
它的结构如图所示:
reactor 模式
的步骤为:
- 应用程序通过提交请求到时间多路复用器产生一个新的
I/O
操作。应用程序指定handler
,handler
在操作完成后被调用。提交请求到事件多路复用器是非阻塞的,其调用所以会立马返回,将执行权返回给应用程序。 - 当一组
I/O
操作完成,事件多路复用器会将这些新事件添加到事件循环队列中。 - 此时,事件循环会迭代事件循环队列中的每个事件。
- 对于每个事件,对应的
handler
被处理。 handler
,是应用程序代码的一部分,handler
执行结束后执行权会交回事件循环。但是,在handler
执行时可能请求新的异步操作,从而新的操作被添加到事件多路复用器。- 当事件循环队列的全部事件被处理完后,循环会在事件多路复用器再次阻塞直到有一个新的事件可处理触发下一次循环。
我们现在可以定义 Node.js
的核心模式:
模式(反应器) 阻塞处理 I/O
到在一组观察的资源有新的事件可处理,然后以分派每个事件对应 handler
的方式反应。
OS 的非阻塞 I/O 引擎
每个操作系统对于事件多路复用器有其自身的接口, Linux
是 epoll
, Mac OSX
是 kqueue
, Windows
的 IOCP API
。除外,即使在相同的操作系统中,每个 I/O
操作对于不同的资源表现不一样。例如,在 Unix
下,普通文件系统不支持非阻塞操作,所以,为了模拟非阻塞行为,需要使用在事件循环外用一个独立的线程。所有这些平台内和跨平台的不一致性需要在事件多路复用器的上层做抽象。这就是为什么 Node.js
为了兼容所有主流平台而 编写 C 语言库 libuv
,目的就是为了使得 Node.js
兼容所有主流平台和规范化不同类型资源的非阻塞行为。 libuv
今天作为 Node.js
的 I/O
引擎的底层。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论