IO 线程模型
前言
在《从内核角度看 IO 模型》中,我们学习了网络数据包的接收和发送过程,并通过介绍五种 IO 模型来了解内核如何读取网络数据并通知用户线程。
IO 模型 vs IO 线程模型
- I/O 模型(Input/Output Model)描述了操作系统如何管理数据在网络通信中的传输方式,即内核与应用程序在进行 I/O 操作时的交互方式,主要涉及数据的阻塞、同步、异步等方面。
- I/O 线程模型指的是应用程序在处理 I/O 请求时的线程管理方式,特别是如何分配和调度线程来完成网络请求的处理。不同的线程模型在多路 I/O 处理时具有不同的效率,决定了系统在高并发下的响应能力。
特性 | I/O 模型 | I/O 线程模型 |
---|---|---|
定义 | 指操作系统在传输数据时的工作方式 | 指应用程序在处理 I/O 时的线程管理方式 |
关注点 | 数据传输的同步/异步、阻塞/非阻塞 | 应用程序如何分配线程处理多个连接 |
类型 | 阻塞 I/O、非阻塞 I/O、I/O 多路复用、异步 I/O 等 | 单线程、多线程、I/O 多路复用、事件驱动模型等 |
侧重点 | 如何管理数据传输和等待 | 如何高效管理线程资源,避免资源瓶颈 |
应用场景 | 影响数据传输方式,适用于不同的性能要求 | 影响并发处理能力,适合高并发的服务器端设计 |
典型应用 | read 、select 、epoll | Reactor、Master-Worker、Node.js 的事件模型等 |
优缺点 | 影响系统 I/O 操作的延迟,直接决定应用 I/O 性能 | 合理选择线程模型可以有效提高并发处理能力 |
《从内核角度看 IO 模型》中,内容是以内核空间(IO模型)的视角剖析网络数据的收发模型。本文则从用户空间(IO线程模型)的角度,介绍如何对网络数据进行收发。
INFO
《从内核角度看 IO 模型》 中已详细讲解了阻塞 IO 和非阻塞 IO 的这两种经典线程模型。因此,本文将着重讲解 IO 多路复用 和 异步 IO 对应的线程模型。
相比于内核,用户空间的 IO 线程模型相对简单一些。这里的用户空间 IO 线程模型主要讨论多线程协作时,如何分配角色,即谁负责接收连接、谁负责 IO 读写、谁负责计算、以及谁负责数据的发送和接收。这些模型只是对用户 IO 线程的不同分工方式。
演进
如果你看完了 《阻塞与非阻塞IO 的线程模型》,就能明白阻塞 I/O 和非阻塞 I/O 的关键问题在于:
阻塞 I/O 通过主动等待内核缓冲区的数据准备完毕,非阻塞 I/O 通过主动查询内核缓冲区的状态来确定 I/O 操作是否准备好。
这两者都是应用程序主动去与内核打交道。
如果你继续阅读 《IO 多路复用》,你会发现:
I/O 多路复用采用事件驱动方式,通过内核生成的事件通知来高效管理多个 I/O 操作,避免了主动查询。在数据从网卡拷贝到 socket 缓冲区后触发相应事件,从而提高了效率和响应性。
所以当下开源软件能实现网络高性能的原因之一就是 I/O 多路复用。但是使用 I/O 多路复用接口编写网络程序的同学,肯定知道这是一种面向过程的编码方式,这样的开发效率并不高。
因此,大佬们基于面向对象的思想,对 I/O 多路复用进行了封装,让使用者不必考虑底层网络 API 的细节,而只需关注应用代码的编写。这种模式被称为 Reactor 模式。
Reactor 翻译过来的意思是“反应堆”。这里的“反应”指的是“对事件的反应”,即当事件到来时,Reactor 会作出相应的反应。
事实上,Reactor 模式也称为 Dispatcher 模式,这个名称更贴合该模式的含义。Reactor 通过 I/O 多路复用监听事件,收到事件后,会根据事件类型将其分配(Dispatch)给某个进程或线程。
Reactor 模式主要由 Reactor 和 处理资源池两个核心部分组成,二者负责的工作如下:
- Reactor 负责监听和分发事件,事件类型包括连接事件、读写事件;
- 处理资源池 负责处理事件,例如:
read
-> 业务逻辑 ->send
。
Reactor 模式灵活多变,可以应对不同的业务场景,灵活性体现在以下两个方面:
- Reactor 的数量:可以是一个或多个;
- 处理资源池:可以是单个进程/线程,也可以是多个进程/线程。
常见的三个方案都是经典且在实际项目中应用广泛:
- 单 Reactor 单进程/线程
- 单 Reactor 多线程/进程
- 多 Reactor 多进程/线程
具体使用进程还是线程,通常与所使用的编程语言和平台有关。例如:
- Java 语言一般使用线程,如 Netty;
- C 语言则可以使用进程或线程,例如 Nginx 使用进程,而 Memcached 使用线程。
接下来,将分别介绍这三个经典的 Reactor 方案。
Reactor
无论是 C++ 还是 Java 编写的网络框架,大多数都是基于 Reactor 模型 进行设计和开发。Reactor 模型基于事件驱动,特别适合处理海量的 I/O 事件。
Reactor 模型中的角色
- Reactor:负责监听和分配事件,将 I/O 事件分派给相应的 Handler。新的事件包括连接建立就绪、读就绪、写就绪等。
- Acceptor:处理客户端的新连接,并将请求分派到处理器链中。
- Handler:将自身与事件绑定,执行非阻塞的读/写任务,完成 channel 的读取,并在处理业务逻辑后将结果写出到 channel。可以使用资源池来管理。
Reactor 处理请求的流程
- 读取操作:
- 应用程序注册读就绪事件和相关联的事件处理器。
- 事件分离器等待事件的发生。
- 当发生读就绪事件时,事件分离器调用第一步注册的事件处理器。
- 写入操作:与读取操作类似,只不过第一步注册的是写就绪事件。
单 Reactor 单线程
在单 Reactor 单线程模型中,Reactor 线程负责多路分离套接字,接受新连接,并将请求分派给 Handler。比如 Redis 就采用了单 Reactor 单进程的模型。
消息处理流程
- Reactor 对象通过
select
监控连接事件,收到事件后通过dispatch
进行转发。 - 如果是连接建立的事件,由 Acceptor 接受连接,并创建 Handler 处理后续事件。
- 如果不是建立连接事件,Reactor 会分发调用 Handler 进行响应。
- Handler 会完成
read -> 业务处理 -> send
的完整业务流程。
虽然单 Reactor 单线程模型在代码上进行了组件的区分,但整体操作仍然是单线程,无法充分利用硬件资源,且 Handler 的业务处理部分没有异步化。
对于小容量应用场景,单 Reactor 单线程模型是可行的;但对于高负载、大并发的应用场景,则显得不够合适,原因如下:
- 即使 Reactor 线程的 CPU 负荷达到 100%,也无法满足海量消息的编码、解码、读取和发送。
- 当 Reactor 线程负载过重时,处理速度减慢,可能导致大量客户端连接超时,超时后往往会进行重发,进一步加重 Reactor 线程负载,最终导致消息积压和处理超时,成为性能瓶颈。
- 一旦 Reactor 线程意外中断或进入死循环,整个系统通信模块将不可用,无法接收和处理外部消息,导致节点故障。
为了解决这些问题,演进出了 单 Reactor 多线程模型。
单 Reactor 多线程
在这个模型中,事件处理器(Handler)部分采用多线程(线程池)。
消息处理流程
- Reactor 对象通过
select
监控客户端请求事件,收到事件后通过dispatch
进行分发。 - 如果是建立连接请求事件,由 Acceptor 通过
accept
处理连接请求,并创建 Handler 对象处理连接后的各种事件。 - 如果不是建立连接事件,Reactor 会分发调用对应的 Handler 进行响应。
- Handler 只负责响应事件,不进行具体业务处理。通过
Read
读取数据后,会分发给 Worker 线程池进行业务处理。 - Worker 线程池分配独立的线程完成实际的业务处理,并将响应结果发送给 Handler 进行处理。
- Handler 收到响应结果后通过
send
将结果返回给 Client。
相对于第一种模型,单 Reactor 多线程模型在处理业务逻辑时,将 I/O 的读写事件交由线程池处理,从而降低了 Reactor 的性能开销,使其更专注于事件分发,提升了整体应用的吞吐量。
存在的问题
- 多线程数据共享和访问比较复杂。如果子线程完成业务处理后需要将结果传递给主线程 Reactor 进行发送,就会涉及共享数据的互斥和保护机制。
- Reactor 只在主线程中运行,可能会存在性能问题。例如,在处理百万客户端连接时,可能会对性能产生影响,特别是涉及安全认证等耗时操作。
为了解决这些性能问题,产生了 多 Reactor 多线程模型。
多 Reactor 多线程
相较于第二种模型,多 Reactor 多线程模型 将 Reactor 分为两部分:
- mainReactor:负责监听 server socket,处理网络 I/O 连接的建立操作,将建立的 socketChannel 注册给 subReactor。
- subReactor:主要进行与建立的 socket 进行数据交互和事件业务处理操作。通常,subReactor 的数量可以与 CPU 数量相同。
许多高性能的开源软件如 Nginx、Swoole、Memcached 和 Netty 都采用这种实现。
消息处理流程
- 从主线程池中随机选择一个 Reactor 线程作为 Acceptor 线程,用于绑定监听端口,接收客户端连接。
- Acceptor 线程接收客户端连接请求后,创建新的 SocketChannel,将其注册到主线程池的其它 Reactor 线程上,由其负责接入认证、IP 黑白名单过滤、握手等操作。
- 完成上述操作后,业务层的链路正式建立,将 SocketChannel 从主线程池的 Reactor 线程的多路复用器上摘除,重新注册到 Sub 线程池的线程上,并创建一个 Handler 用于处理各种连接事件。
- 当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应。
- Handler 通过
Read
读取数据后,会分发给 Worker 线程池进行业务处理。 - Worker 线程池分配独立的线程完成实际的业务处理,并将响应结果发给 Handler 进行处理。
- Handler 收到响应结果后通过
send
将响应结果返回给 Client。
总结
Reactor 模型具有如下优点:
- 响应快:不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的;
- 编程相对简单:可以最大程度避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
- 可扩展性:可以方便地通过增加 Reactor 实例的数量来充分利用 CPU 资源;
- 可复用性:Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。
Proactor
前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式。
关于 同步IO和异步IO,请参阅《从内核角度看 IO 模型》
Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,应用进程需要主动调用
read
方法来完成数据的读取,也就是说,应用进程需要主动将 socket 接收缓存中的数据读到应用进程内存中。这个过程是同步的,读取完数据后,应用进程才能处理数据。Proactor 是异步网络模式,感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才能自动完成数据的读写工作。这里的读写工作全程由操作系统来处理,并不需要像 Reactor 那样还需应用进程主动发起
read/write
来读写数据。操作系统完成读写工作后,会通知应用进程直接处理数据。
因此,Reactor 可以理解为“来了事件,操作系统通知应用进程,让应用进程来处理”,而 Proactor 可以理解为“来了事件,操作系统来处理,处理完再通知应用进程”。这里的“事件”包括有新连接、有数据可读、有数据可写的这些 IO 事件,这里的“处理”包含从驱动读取到内核以及从内核读取到用户空间。
举个实际生活中的例子,Reactor 模式就像快递员在楼下给你打电话告诉你快递到了你家小区,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。
无论是 Reactor,还是 Proactor,都是一种基于“事件分发”的网络编程模式,区别在于 Reactor 模式是基于“待完成”的 I/O 事件,而 Proactor 模式则是基于“已完成”的 I/O 事件。
接下来,一起看看 Proactor 模式的示意图:

介绍一下 Proactor 模式的工作流程:
- Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核。
- Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作。
- Asynchronous Operation Processor 完成 I/O 操作后,通知 Proactor。
- Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理。
- Handler 完成业务处理。
可惜的是,在 Linux 下的异步 I/O 是不完善的。aio
系列函数是由 POSIX 定义的异步操作接口,并不是真正的操作系统级别支持,而是在用户空间模拟出来的异步操作,并且仅支持基于本地文件的异步 I/O,网络编程中的 socket 是不支持的。这也使得基于 Linux 的高性能网络程序大多使用 Reactor 方案。
而在 Windows 中,实现了一套完整支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的真正意义上的异步 I/O。因此,在 Windows 中实现高性能网络程序可以使用效率更高的 Proactor 方案。
总结
常见的 Reactor 实现方案有三种:
- 单 Reactor 单进程/线程 这种方案不需要考虑进程间通信和数据同步的问题,因此实现起来相对简单。缺陷在于无法充分利用多核 CPU,且处理业务逻辑的时间不能太长,否则会导致响应延迟。因此,这种方案不适用于计算密集型的场景,而更适合于业务处理快速的场景,比如 Redis(6.0 之前)采用的就是单 Reactor 单进程的方案。
- 单 Reactor 多线程 通过多线程的方式,解决了第一种方案的缺陷,但仍然离高并发有一定距离。问题在于只有一个 Reactor 对象来承担所有事件的监听和响应,并且只在主线程中运行,因此在面对瞬间高并发的场景时,容易成为性能瓶颈。
- 多 Reactor 多进程/线程 通过多个 Reactor 来解决第二种方案的缺陷,主 Reactor 只负责监听事件,而将响应事件的工作交给从 Reactor。Netty 和 Memcache 都采用了“多 Reactor 多线程”的方案,而 Nginx 则采用了类似于“多 Reactor 多进程”的方案。
Reactor 可以理解为“来了事件,操作系统通知应用进程,让应用进程来处理”;而 Proactor 则可以理解为“来了事件,操作系统来处理,处理完再通知应用进程”。
因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 一样在感知到事件后,还需要调用 read
来从内核中获取数据。
不过,无论是 Reactor 还是 Proactor,它们都是一种基于“事件分发”的网络编程模式。区别在于 Reactor 模式是基于“待完成”的 I/O 事件,而 Proactor 模式则是基于“已完成”的 I/O 事件。