文章目录
- I/O 复用
- 概述
- I/O 模型
- 一个输入操作的两个阶段
- select 函数
- 概述
- 详细解析
- 函数内容详解
- `select`总结
- poll 函数
- 概述
- 详细解析
- 函数内容详解
- epoll 函数
- 概述
- 基础API
- 注意事项
- 总结一下select, poll, epoll的区别
- Reactor 和 Proactor
- 概述
- 概念
- 服务器连接多个客户端的业务场景
- 解决方案
- 思考:线程如何高效处理多个连接的业务?
- 进一步思考
- I/O 多路复用技术
- Reactor 模式
- Reactor 模式概述
- Reactor 模式的灵活性
- Reactor 模式的实现方案
- 总结
- 单 Reactor 单进程
- 组件职责
- 工作流程
- 优缺点
- 单 Reactor 多线程 / 多进程
- 工作流程(多线程为例)
- 优缺点(多线程)
- 优缺点(多进程,理论讨论)
- 总结
- 多 Reactor 多线程 / 多进程
- 多 Reactor 多线程
- 多 Reactor 多进程
- Proactor 模型
I/O 复用
概述
当TCP客户端同时处理两个输入:标准输入和TCP套接字时,会遇到以下问题:
- 问题描述:在客户端阻塞于标准输入上的
fgets
调用时,如果服务器进程突然终止,服务器TCP虽然正确地给客户端发送了一个FIN,但是客户端正阻塞于从标准输入读入的过程,因此无法立即看到这个EOF(文件结束符)。直到从标准输入读到数据后,客户端才能看到EOF,但此时可能已经错过了及时处理服务器关闭的时机。 - 解决方案需求:这样的进程就需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(如TCP连接关闭、标准输入有数据可读等),就通知进程。这种能力就被称为I/O复用(multiplexing)。
I/O 模型
存在5种主要的I/O模型:
- 阻塞式I/O
- 非阻塞式I/O
- I/O 复用
- 信号驱动式I/O
- 异步I/O
一个输入操作的两个阶段
一个输入操作通常包括两个不同的阶段:
- 等待数据准备好:在这个阶段,进程需要等待,直到所需的数据到达内核的缓冲区。
- 从内核向进程复制数据:一旦数据准备好,内核就会将数据从内核空间复制到用户空间,供进程使用。
这些阶段和模型的不同之处在于它们处理等待数据准备好这一阶段的方式。
select 函数
概述
该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定时间后才唤醒它。主旨思想在于不再由应用程序自己监视客户端连接,而是由内核替应用程序监视文件描述符的状态。
详细解析
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int maxfdpl, fd_set *readset, fd_set *writeset,
fd_set *exceptset, struct timeval *timeout);
maxfdpl: 监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
readset: 监控有读数据到达文件描述符集合,传入传出参数
writeset: 监控写数据到达文件描述符集合,传入传出参数
exceptset: 监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
timeout: 定时阻塞监控时间,3种情况
1.NULL,永远等下去
2.设置timeval,等待固定时间
3.设置timeval里时间均为0,检查描述字后立即返回,这称为轮询
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
void FD_ZERO(fd_set *set); //把文件描述符集合里所有位清0
void FD_SET(int fd, fd_set *set); //把文件描述符集合里fd位置1
void FD_CLR(int fd, fd_set *set); //把文件描述符集合里fd清0
int FD_ISSET(int fd, fd_set *set); //测试文件描述符集合里fd是否置1
函数内容详解
-
注意事项:
select
能监听的文件描述符个数受限于FD_SETSIZE
,一般为1024。单纯改变进程打开的文件描述符个数并不能改变select
监听的文件个数。
-
描述符就绪条件:
- 当某个套接字上发生错误时,该套接字将被
select
标记为既可读又可写。
- 当某个套接字上发生错误时,该套接字将被
-
接收低水位标记和发生低水位标记的目的:
- 允许应用进程控制
select
返回可读或可写条件之前有多少数据可读或有多大空间可用于写。例如,可以设置至少存在64字节的数据准备好读时,select
才唤醒应用进程,从而避免频繁打断应用进程处理其他工作。
- 允许应用进程控制
select
总结
-
优点:
select
遵循POSIX标准,跨平台移植性较好。select
监控的超时等待时间可以精细到微秒。
-
缺点:
select
所能监控的描述符数量有最大上限,取决于宏_FD_SETSIZE
,默认大小为1024。select
的监控原理是在内核中进行轮询遍历,这种遍历会随着描述符的增多而性能下降。select
返回时会移除所有未就绪的描述符,只给用户返回就绪的描述符集合,但没有直接告诉用户哪个描述符就绪,需要用户自己遍历才能获知。select
每次监控都需要重新将集合拷贝到内核中才能进行监控。select
每次返回都会移除所有未就绪的描述符,因此每次监控都要重新向集合中添加描述符。
poll 函数
概述
该函数提供的功能与select
类似,不过在处理流设备时,它能够提供额外的信息。select
函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定时间后才唤醒它。
详细解析
#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监控的事件 */
short revents; /* 监控事件中满足条件返回的事件 */
};
events标志以及测试revents标志的一些常量:
—————————————处理输入—————————————————————
POLLIN 普通或带外优先数据可读,即POLLRDNORM | POLLRDBAND
POLLRDNORM 数据可读
POLLRDBAND 优先级带数据可读
POLLPRI 高优先级可读数据
—————————————处理输出—————————————————————
POLLOUT 普通或带外数据可写
POLLWRNORM 数据可写
POLLWRBAND 优先级带数据可写
—————————————处理错误————————————————————
POLLERR 发生错误
POLLHUP 发生挂起
POLLNVAL 描述字不是一个打开的文件
nfds 监控数组中有多少文件描述符需要被监控
timeout 毫秒级等待
-1:阻塞等,#define INFTIM -1 Linux中没有定义此宏
0:立即返回,不阻塞进程
>0:等待指定毫秒数,如当前系统时间精度不够毫秒,向上取值
函数内容详解
- 注意事项:
- 如果不再监控某个文件描述符时,可以把
pollfd
结构体中的fd
成员设置为-1,这样poll
将不再监控此pollfd
,下次poll
返回时,会把对应的revents
成员设置为0。 pollfd
结构包含了要监视的事件(通过events
成员)和发生的事件(通过revents
成员),不再使用select
的"值-结果"传递方式。- 同时,
poll
函数对pollfd
数组的大小并没有最大数量限制(但监控的文件描述符数量过大后,性能也会有所下降)。 - 和
select
函数一样,poll
返回后,需要轮询pollfd
数组来获取就绪的描述符。
- 如果不再监控某个文件描述符时,可以把
epoll 函数
概述
epoll是Linux下多路复用I/O接口(select/poll)的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。因为它会复用文件描述符集合来传递结果,而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合。获取事件时,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合即可。目前,epoll是Linux大规模并发网络程序中的热门首选模型。epoll除了提供select/poll那种IO事件的电平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait
的调用,提高应用程序效率。
基础API
- 创建一个epoll句柄,参数size用来告诉内核监听的文件描述符的个数,与内存大小有关。
#include <sys/epoll.h>
int epoll_create(int size) size: 监听数目
- 控制某个epoll监控的文件描述符上的事件:注册、修改、删除。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
epfd: epoll_creat的句柄
op: 表示动作,用3个宏来表示:
EPOLL_CTL_ADD (注册新的fd到epfd),
EPOLL_CTL_MOD (修改已经注册的fd的监听事件),
EPOLL_CTL_DEL (从epfd删除一个fd);
event: 告诉内核需要监听的事件
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
EPOLLIN : 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLOUT: 表示对应的文件描述符可以写
EPOLLERR: 表示对应的文件描述符发生错误
EPOLLHUP: 表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
EPOLLONESHOT: 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
- 等待所监控文件描述符上有事件的产生,类似于
select()
调用。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
events: 用来存内核得到事件的集合,
maxevents: 告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,
timeout: 是超时时间
-1: 阻塞
0: 立即返回,非阻塞
>0: 指定毫秒
返回值: 成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1
注意事项
epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait
的调用,提高应用程序效率。
- 水平触发(LT): 默认工作模式,即当
epoll_wait
检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait
时,会再次通知此事件。 - 边缘触发(ET): 当
epoll_wait
检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait
时,不会再次通知此事件(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。
ET和LT原本应该是用于脉冲信号的,可能用它来解释更加形象。Level和Edge指的就是触发点,Level为只要处于水平,那么就一直触发,而Edge则为上升沿和下降沿的时候触发。比如: 0->1 就是Edge,1->1 就是Level。ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。
总结一下select, poll, epoll的区别
(此处应详细列出三者的主要区别,但原文未具体给出,以下为一般性总结)
-
select:
- 监视的文件描述符数量有限制(通常为1024)。
- 监视文件描述符时,需要将它们从用户空间拷贝到内核空间。
- 每次调用select时,都需要重新设置文件描述符集合。
- 只提供了水平触发。
-
poll:
- 监视的文件描述符数量没有硬性限制,但受限于系统资源。
- 与select类似,每次调用都需要重新设置文件描述符集合。
- 同样只提供了水平触发。
-
epoll:
- 没有监视文件描述符数量的限制(实际上受限于系统资源)。
- 只需在开始时设置一次文件描述符集合,之后可以复用。
- 提供了水平触发和边缘触发两种模式。
- 只在有事件发生时才通知,减少了CPU的使用率。
Reactor 和 Proactor
概述
概念
- PPC (Process per Connection): 每次有新的连接就新建一个进程去专门处理这个连接的请求。
- TPC (Thread per Connection): 每次有新的连接就新建一个线程去专门处理这个连接的请求,比PPC更轻量。
服务器连接多个客户端的业务场景
- 直接方式: 为每一条连接创建线程(或进程,但线程更常用因其轻量级)。
- 问题: 频繁创建和销毁线程带来性能开销和资源浪费,尤其在高并发场景下(如几万条连接)不现实。
解决方案
- 资源复用: 创建线程池,将连接分配给线程池中的线程处理,一个线程处理多个连接的业务。
思考:线程如何高效处理多个连接的业务?
- 阻塞问题: 当一个线程处理多个连接时,若某个连接在
read
操作时无数据可读,线程将阻塞,无法处理其他连接。 - 非阻塞轮询: 将socket改为非阻塞,线程不断轮询调用
read
,但此方法消耗CPU且效率随连接数增加而降低。
进一步思考
- 问题: 线程如何仅在连接上有数据时发起读请求?
- 解决方案: I/O 多路复用技术。
I/O 多路复用技术
- 定义: 用一个系统调用函数监听所有关心的连接,一个线程可监控多个连接。
- 实现: select/poll/epoll 是内核提供给用户态的多路复用系统调用。
- 工作原理:
- 注册连接: 将关心的连接传给内核。
- 内核检测: 内核检测这些连接上的事件。
- 阻塞与返回:
- 无事件: 线程阻塞在系统调用上,不消耗CPU轮询。
- 有事件: 内核返回产生事件的连接,线程处理这些连接的业务。
Reactor 模式
Reactor 模式概述
Reactor 模式(也称Dispatcher模式)主要由Reactor和处理资源池这两个核心部分组成,作用如下:
- Reactor:负责监听和分发事件,事件类型包含连接事件、读写事件。
- 处理资源池:负责处理事件,如
read -> 业务逻辑 -> send
。
Reactor 模式的灵活性
Reactor 模式是灵活多变的,可以应对不同的业务场景,其灵活性体现在:
- Reactor 的数量:可以只有一个,也可以有多个。
- 处理资源池:可以是单个进程/线程,也可以是多个进程/线程。
Reactor 模式的实现方案
将上述两个因素进行排列组合,理论上可以有以下四种方案选择:
-
单 Reactor 单进程 / 线程
- 优点:实现简单,不需要考虑进程间通信和多进程竞争。
- 缺点:无法充分利用多核CPU性能,业务处理耗时较长时会造成响应延迟。
- 应用场景:适用于业务处理非常快速的场景。
-
单 Reactor 多进程 / 线程
- 优点:能够充分利用多核CPU的能力。
- 缺点:引入多线程/进程,带来多线程/进程竞争资源的问题,需要处理并发问题。
- 实际应用:多线程方式较为常见,多进程方式实现复杂且少见。
-
多 Reactor 单进程 / 线程
- 说明:此方案相比单 Reactor 方案复杂且没有性能优势,实际中并未应用。
-
多 Reactor 多进程 / 线程
- 优点:主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理,交互简单。
- 实际应用:Netty 和 Memcache 等开源软件采用了此方案。
总结
Reactor 模式通过灵活配置Reactor的数量和处理资源池的类型,可以适应不同的业务场景需求。其中,多 Reactor 多线程方案因其高效性和易实现性,在实际项目中得到了广泛应用。
单 Reactor 单进程
在单 Reactor 单进程的模型中,所有组件(Reactor、Acceptor、Handler)都运行在同一个进程中。这种模型简单直观,但有其特定的优缺点。
组件职责
- Reactor:负责监听和分发事件,使用
select
、poll
或epoll
等系统调用来等待事件的发生。 - Acceptor:负责处理连接建立的事件,通过
accept
系统调用获取新的连接,并为每个连接创建一个Handler对象。 - Handler:负责处理非连接建立事件(如读写事件),通过
read
读取数据,执行业务逻辑,然后通过send
发送响应。
工作流程
- Reactor通过系统调用监听事件。
- 当事件发生时,Reactor通过
dispatch
方法分发事件。 - 如果是连接建立事件,则交给Acceptor处理,Acceptor通过
accept
获取连接并创建Handler。 - 非连接建立事件则直接交给对应的Handler处理。
- Handler执行
read -> 业务处理 -> send
流程。
优缺点
- 优点:
- 实现简单,无需处理进程间通信。
- 无需考虑多进程竞争资源的问题。
- 缺点:
- 无法充分利用多核CPU的性能。
- Handler处理业务时阻塞整个进程,可能导致响应延迟。
- 应用场景:适用于业务处理非常快速的场景,不适合计算密集型任务。
单 Reactor 多线程 / 多进程
单 Reactor 多线程/多进程模型在单 Reactor 单进程的基础上进行了扩展,以更好地利用系统资源。
工作流程(多线程为例)
- Reactor通过系统调用监听事件。
- 当事件发生时,Reactor通过
dispatch
方法分发事件。 - 连接建立事件交给Acceptor处理,Acceptor通过
accept
获取连接并创建Handler。 - 非连接建立事件则交给对应的Handler处理。但此时Handler只负责数据的接收和发送。
- Handler通过
read
读取到数据后,将数据传递给子线程中的Processor对象进行业务处理。 - Processor处理完业务后,将结果返回给主线程中的Handler。
- Handler通过
send
方法将响应结果发送给客户端。
优缺点(多线程)
- 优点:
- 能够充分利用多核CPU的能力,提高系统吞吐量。
- 缺点:
- 引入多线程竞争资源的问题,需要处理并发访问共享资源的情况,如使用互斥锁。
- 线程间通信和同步可能增加系统复杂度。
优缺点(多进程,理论讨论)
虽然实际中很少使用单 Reactor 多进程模型,但理论上其优缺点与多线程类似,但进程间通信(如通过管道、消息队列、共享内存等)通常比线程间通信更复杂且开销更大。
总结
单 Reactor 多线程模型在实际应用中更为常见,因为它能够较好地平衡系统资源的利用和实现的复杂度。然而,在面对极高并发的场景时,单 Reactor 模型可能会成为性能瓶颈。此时,可以考虑使用多 Reactor 模型,即多个Reactor分别监听和处理不同的事件,以进一步提高系统的并发处理能力。
多 Reactor 多线程 / 多进程
多 Reactor 多线程
在多 Reactor 多线程模型中,系统被划分为多个部分,每个部分都负责不同的任务,以提高系统的并发处理能力和响应速度。
- MainReactor:在主线程中运行,负责监听和接收新的连接事件。当有新连接到达时,MainReactor通过
accept
方法接收连接,并将新连接分配给某个子线程中的SubReactor。 - SubReactor:在子线程中运行,负责监听由MainReactor分配的连接上的读写事件。当有新的事件(如可读、可写)发生时,SubReactor调用对应的Handler来处理。
- Handler:用于处理具体的业务逻辑,包括读取数据、处理业务和发送响应。
优点:
- 主线程和子线程分工明确,提高了系统的并发处理能力。
- 主线程和子线程之间的交互简单,减少了锁的使用和上下文切换的开销。
- 子线程可以直接将处理结果发送给客户端,无需返回给主线程。
实现复杂度:
虽然多 Reactor 多线程模型在概念上比单 Reactor 多线程更复杂,但由于其清晰的分工和简单的交互,实际实现起来反而更加简单和高效。
多 Reactor 多进程
多 Reactor 多进程模型与多线程模型类似,但主要区别在于使用进程而非线程来隔离不同的任务。然而,正如之前提到的,由于进程间通信(IPC)的复杂性和开销,这种模型在实际应用中较少见。
Nginx 采用了类似但有所差异的多进程模型,其中主进程用于初始化和管理子进程,但并不直接处理连接。连接由子进程中的Reactor来accept和处理。
Proactor 模型
Proactor 模型是一种异步I/O模型,它允许操作系统内核来处理I/O操作,并在操作完成后通知用户进程。这与Reactor模型的同步I/O操作形成对比。
- Proactor Initiator:负责创建Proactor和Handler,并将它们注册到内核的Asynchronous Operation Processor中。
- Asynchronous Operation Processor:内核组件,负责处理注册请求,执行I/O操作,并在操作完成后通知Proactor。
- Proactor:根据Asynchronous Operation Processor的通知,调用相应的Handler来处理业务逻辑。
- Handler:处理具体的业务逻辑,也可以注册新的Handler。
优点:
- 异步I/O能够充分利用DMA(直接内存访问)特性,使得I/O操作与CPU计算可以重叠进行,从而提高系统性能。
实现难度:
- 真正的异步I/O在操作系统层面实现复杂,目前只有少数系统(如Windows的IOCP)提供了完善的支持。
- 在Linux下,虽然有AIO(异步I/O)机制,但其性能和成熟度都不如Windows的IOCP。因此,许多在Linux下的网络库(如Boost.Asio)采用Reactor模式模拟异步I/O。
总的来说,选择哪种模型取决于具体的应用场景、性能要求和操作系统的支持情况。在实际开发中,需要根据这些因素综合考虑,选择最适合的模型。