云计算百科
云计算领域专业知识百科平台

【C++ 网络编程】一篇文章搞懂I/O多路复用(select、poll、epoll)

I/O多路复用

IO多路复用也称为IO多路转接,它是一种网络通信的手段(机制),通过这种方式可以同时监测多个文件描述符并且这个过程是阻塞的,一旦检测到有文件描述符就绪( 可以读数据或者可以写数据)程序的阻塞就会被解除,之后就可以基于这些(一个或多个)就绪的文件描述符进行通信了。通过这种方式在单线程/进程的场景下也可以在服务器端实现并发。常见的IO多路转接方式有:select、poll、epoll(select跨平台 后两种Linux平台独占)

下面先对多线程/多进程并发和I/O多路复用的并发处理流程进行对比(服务器端):

  • 多线程/多进程并发
    • 主线程/父进程:调用 accept()监测客户端连接请求
      • 如果没有新的客户端的连接请求,当前线程/进程会阻塞
      • 如果有新的客户端连接请求解除阻塞,建立连接
    • 子线程/子进程:和建立连接的客户端通信
      • 调用 read() / recv() 接收客户端发送的通信数据,如果没有通信数据,当前线程/进程会阻塞,数据到达之后阻塞自动解除
      • 调用 write() / send() 给客户端发送数据,如果写缓冲区已满,当前线程/进程会阻塞,否则将待发送数据写入写缓冲区中
  • I/O多路复用并发
    • 使用I/O多路转接函数委托内核检测服务器端所有的文件描述符(通信和监听两类),这个检测过程会导致进程/线程的阻塞,如果检测到已就绪的文件描述符阻塞解除,则将这些已就绪的文件描述符返回
    • 根据类型对传出的所有已就绪文件描述符进行判断,并做出不同的处理
      • 监听的文件描述符:和客户端建立连接
      • 此时调用accept()是不会导致程序阻塞的,因为监听的文件描述符是已就绪的(有新请求)
    • 通信的文件描述符:调用通信函数和已建立连接的客户端通信
      • 调用 read() / recv() 不会阻塞程序,因为通信的文件描述符是就绪的,读缓冲区内已有数据
      • 调用 write() / send() 不会阻塞程序,因为通信的文件描述符是就绪的,写缓冲区不满,可以往里面写数据
    • 对这些文件描述符继续进行下一轮的检测(循环往复。。。)

与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。

select

使用select这种I/O多路复用方式需要调用一个同名函数select,这个函数是跨平台的,Linux、Mac、Windows都是支持的。

通过调用这个函数可以委托内核帮助我们检测若干个文件描述符的状态,其实就是检测这些文件描述符对应的读写缓冲区的状态:

  • 读缓冲区:检测里边有没有数据,如果有数据该缓冲区对应的文件描述符就绪
  • 写缓冲区:检测写缓冲区是否可以写(有没有容量),如果有容量可以写,缓冲区对应的文件描述符就绪
  • 读写异常:检测读写缓冲区是否有异常,如果有该缓冲区对应的文件描述符就绪

委托检测的文件描述符被遍历检测完毕之后,已就绪的这些满足条件的文件描述符会通过select()的参数分3个集合传出,得到这几个集合之后就可以分情况依次处理了。

select函数原型:

#include <sys/select.h>
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
// 阻塞总时长为 sec + microsec, 两者都需要初始化,否则可能读取到随机值
};
int select(
int nfds, // 监听的最大文件描述符 + 1
fd_set *readfds, // 监听“读事件”的文件描述符集合
fd_set *writefds, // 监听“写事件”的文件描述符集合
fd_set *exceptfds, // 监听“异常事件”的文件描述符集合
struct timeval *timeout // 超时时间(NULL表示永久阻塞)
);

  • 函数参数:
    • nfds:委托内核检测的这三个集合中最大的文件描述符 + 1(最大1024)
      • 内核需要线性遍历这些集合中的文件描述符,这个值是循环结束的条件
      • 在Window中这个参数是无效的,指定为-1即可
    • readfds:文件描述符的集合, 内核只检测这个集合中文件描述符对应的读缓冲区
      • 传入传出参数,读集合一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收数据
    • writefds:文件描述符的集合, 内核只检测这个集合中文件描述符对应的写缓冲区
      • 传入传出参数,如果不需要使用这个参数可以指定为NULL
    • exceptfds:文件描述符的集合, 内核检测集合中文件描述符是否有异常状态
      • 传入传出参数,如果不需要使用这个参数可以指定为NULL
    • timeout:超时时长,用来强制解除select()函数的阻塞的
      • NULL:函数检测不到就绪的文件描述符会一直阻塞。
      • 等待固定时长(秒):函数检测不到就绪的文件描述符,在指定时长之后强制解除阻塞,函数返回0
      • 不等待:函数不会阻塞,直接将该参数对应的结构体初始化为0即可。
  • 函数返回值:
    • 大于0:成功,返回集合中已就绪的文件描述符的总个数
    • 等于-1:函数调用失败
    • 等于0:超时,没有检测到就绪的文件描述符
文件描述符集合fd_set及操作函数

fd_set是一个位图(Bitset)结构(本质是整数数组),每个位对应一个文件描述符的状态(0 = 不监听,1 = 监听)。系统提供 4 个操作函数:

// 将fd从set中清除(置0)
void FD_CLR(int fd, fd_set *set);
// 检查fd是否在set中(返回1表示存在,0表示不存在)
int FD_ISSET(int fd, fd_set *set);
// 将fd添加到set中(置1)
void FD_SET(int fd, fd_set *set);
// 将set中所有位清零
void FD_ZERO(fd_set *set);

在select()函数中第2、3、4个参数都是fd_set类型,它表示一个文件描述符的集合,类似于信号集 sigset_t,这个类型的数据有128个字节,也就是1024个标志位,和内核中文件描述符表中的文件描述符个数是一样的。

这并不是巧合,而是故意为之。这块内存中的每一个bit 和 文件描述符表中的每一个文件描述符是一一对应的关系,这样就可以使用最小的存储空间将要表达的意思描述出来了。

下图中的fd_set中存储了要委托内核检测读缓冲区的文件描述符集合。

  • 如果集合中的标志位为0代表不检测这个文件描述符状态
  • 如果集合中的标志位为1代表检测这个文件描述符状态

。

内核在遍历这个读集合的过程中,如果被检测的文件描述符对应的读缓冲区中没有数据,内核将修改这个文件描述符在读集合fd_set中对应的标志位,改为0,如果有数据那么这个标志位的值不变,还是1

在这里插入图片描述

使用select处理并发

如果在服务器基于select实现并发,其处理流程如下:

  • 创建监听的套接字 lfd = socket()
  • 将监听的套接字和本地的IP和端口绑定 bind()
  • 给监听的套接字设置监听 listen()
  • 创建一个文件描述符集合 fd_set,用于存储需要检测读事件的所有的文件描述符
    • 通过 FD_ZERO() 初始化
    • 通过 FD_SET() 将监听的文件描述符放入检测的读集合中
  • 循环调用select(),周期性的对所有的文件描述符进行检测
  • select() 解除阻塞返回,得到内核传出的满足条件的就绪的文件描述符集合
    • 通过FD_ISSET() 判断集合中的标志位是否为 1
      • 如果这个文件描述符是监听的文件描述符,调用 accept() 和客户端建立连接
        • 将得到的新的通信的文件描述符,通过FD_SET() 放入到检测集合中
      • 如果这个文件描述符是通信的文件描述符, 调用通信函数和客户端通信
        • 如果客户端和服务器断开了连接,使用FD_CLR()将这个文件描述符从检测集合中删除
        • 如果没有断开连接,正常通信即可
  • 重复第6步

在这里插入图片描述

poll

poll的机制与select类似,与select在本质上没有多大差别,使用方法也类似,下面的是对于二者的对比:

  • 内核对应文件描述符的检测也是以线性的方式进行轮询,根据描述符的状态进行处理
  • poll和select检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件描述符数量的增加而线性增大,从而效率也会越来越低。
  • select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制
  • select可以跨平台使用,poll只能在Linux平台使用

poll函数的函数原型如下:

#include <poll.h>
// 核心参数:
// fds:待监听的fd数组(struct pollfd类型)
// nfds:数组长度(需传入最大的fd+1,和select一致)
// timeout:超时时间(毫秒),-1=永久阻塞,0=非阻塞
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

核心pollfd结构体

struct pollfd {
int fd; // 要监听的文件描述符(如server_fd、client_fd)
short events; // 要监听的事件(输入:告诉内核我要等什么事件)
short revents; // 实际发生的事件(输出:内核告诉我们发生了什么)
};

在这里插入图片描述

常用事件宏(events/revents):

事件宏含义适用场景
POLLIN 有数据可读(对应 select 的读事件) 监听客户端连接、接收数据
POLLOUT 可写数据(对应 select 的写事件) 发送数据(如大文件传输)
POLLERR fd 发生错误 检测连接异常
POLLHUP fd 被挂断(如客户端断开) 检测连接关闭
POLLNVAL fd 无效(未打开 / 已关闭) 检测 fd 合法性

返回值:

  • 成功:返回有事件发生的 fd 数量;

  • 0:超时(timeout>0 时);

  • -1:失败(设置 errno,如 EINTR 表示被信号中断)。

poll 对比 select:核心优势

poll() 完美解决了 select() 的 3 个核心问题:

特性select()poll()
fd 存储方式 用 fd_set(位图),默认最大监听 1024 个 用 struct pollfd 数组,无 fd 数量限制(仅受系统资源限制)
事件与结果分离 输入输出共用 fd_set,每次需重新初始化 events(输入)和 revents(输出)分离,无需重复初始化
无效 fd 处理 需手动遍历排除无效 fd revents 会返回 POL·
性能(高并发) 每次需遍历 0~max_fd,低效 只需遍历有事件的 fd,高效
  • select() 每次循环都要重新拷贝 read_fds 到 temp_fds,因为 select() 会修改传入的 fd_set;
  • poll() 只需初始化一次 struct pollfd 数组(后续仅更新需要监听的 fd),因为 events 不会被内核修改,只有 revents 会更新。

epoll

epoll 全称 eventpoll,epoll 是 Linux 内核专为高并发 IO设计的多路复用机制,是 select/poll 的终极升级版 —— 解决了前两者 “遍历所有 fd” 的性能瓶颈,也是 Nginx/Redis 等高性能中间件的核心 IO 模型。

epoll核心原理

核心痛点:select/poll 的性能瓶颈

  • select/poll 是 **“轮询” 模式 **:每次调用都要把所有监听的 fd 从用户态拷贝到内核态,然后内核遍历所有 fd 检测事件,最后再把结果拷贝回用户态;
  • 即使只有 1 个 fd 有事件,也需要遍历全部 fd,时间复杂度 O (n),并发越高越慢(比如 10 万 fd 时,遍历成本极高)。

epoll 的核心改进:“事件驱动”+“内存映射”

epoll 彻底颠覆了轮询模式,核心设计:

特性实现方式效果
内核态事件表 在内核中创建一个epoll 实例(文件描述符),专门存储需要监听的 fd 和事件; 无需每次拷贝 fd 到内核态
事件回调机制 内核通过回调函数直接标记有事件的 fd,而非遍历所有 fd; 时间复杂度 O (1),仅处理有事件的 fd
内存映射(mmap) 用户态与内核态共享事件表内存,避免数据拷贝; 减少内存拷贝开销
支持边缘触发(ET) 仅在 fd 状态变化时触发事件(而非水平触发 LT 的 “有数据就触发”); 减少事件触发次数,提升效率

epoll 支持两种事件触发模式,这是其高性能的关键:

水平触发(LT,Level Trigger)
  • 行为:只要 fd 的缓冲区有数据 / 可写,就会持续触发事件(和 select/poll 行为一致);

  • 特点:编程简单,不易出错,适合新手;

  • 场景:中小并发、需要兼容 select/poll 逻辑的场景。

边缘触发(ET,Edge Trigger)
  • 行为:仅在 fd 的状态 “变化” 时触发一次事件(比如:缓冲区从空→有数据,仅触发一次);
  • 特点:触发次数极少,性能极高,但编程要求高(必须一次性读完缓冲区所有数据);
  • 场景:高并发(10 万 + 连接)、追求极致性能的场景(如 Nginx)。

注意:epoll 默认是 LT 模式,ET 模式需要显式指定。

epoll的核心API

epoll 的 API 非常简洁,只有 3 个核心函数,全部定义在 <sys/epoll.h> 头文件中。

  • epoll_create:创建 epoll 实例

#include <sys/epoll.h>
// size:已废弃(历史遗留参数),只需传入大于0的数即可
// 返回值:epoll实例的文件描述符(epfd),失败返回-1
int epoll_create(int size);
// 升级版(推荐):指定EPOLL_CLOEXEC,进程替换时自动关闭epfd
int epoll_create1(int flags);

作用:在内核中创建一个 epoll 事件表(红黑树实现),返回的 epfd 是操作这个事件表的句柄;

示例:

int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == 1) {
perror("epoll_create1 failed");
exit(EXIT_FAILURE);
}

  • epoll_ctl:管理 epoll 事件表(增 / 删 / 改 fd)

这是 epoll 的核心 —— 向事件表中添加 / 删除 / 修改需要监听的 fd 和事件。

// epfd:epoll实例的fd(epoll_create的返回值)
// op:操作类型(EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL)
// fd:要监听的文件描述符(如server_fd/client_fd)
// event:要监听的事件(struct epoll_event结构体)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

核心结构体 struct epoll_event:

struct epoll_event {
uint32_t events; // 要监听的事件(如EPOLLIN)
epoll_data_t data; // 自定义数据(通常存fd,也可存指针)
};

// 联合体,按需使用
typedef union epoll_data {
void *ptr; // 自定义指针
int fd; // 存储fd(最常用)
uint32_t u32;
uint64_t u64;
} epoll_data_t;

常用事件宏:

事件宏含义适用场景
EPOLLIN 有数据可读(新连接 / 客户端发数据) 核心事件,必监听
EPOLLOUT 可写数据 大文件传输 / 批量发送
EPOLLERR fd 发生错误 自动监听,无需显式指定
EPOLLHUP fd 被挂断(客户端断开) 自动监听,无需显式指定
EPOLLET 边缘触发模式(默认 LT) 高并发场景
EPOLLONESHOT 仅触发一次事件,需重新注册 多线程处理同一 fd

常用操作(op):

  • EPOLL_CTL_ADD:向 epoll 事件表添加 fd 和监听事件;
  • EPOLL_CTL_MOD:修改已监听 fd 的事件;
  • EPOLL_CTL_DEL:从事件表中删除 fd(客户端断开时)。

示例:(添加服务器fd监听)

struct epoll_event ev;
ev.events = EPOLLIN; // 监听读事件(新连接)
ev.data.fd = server_fd; // 存储服务器fd
// 把server_fd添加到epoll事件表
if (epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev) == 1) {
perror("epoll_ctl add server_fd failed");
exit(EXIT_FAILURE);
}

  • epoll_wait:阻塞等待事件发生

// epfd:epoll实例的fd
// events:输出参数,存储有事件的fd数组(用户态分配)
// maxevents:events数组的最大长度(最多处理多少个事件)
// timeout:超时时间(毫秒),-1=永久阻塞,0=非阻塞
// 返回值:成功=有事件的fd数量;0=超时;-1=失败(errno=EINTR为信号中断)
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

核心特点:只返回有事件的 fd,无需遍历所有 fd,这是 epoll 高性能的关键;

示例:

struct epoll_event events[1024]; // 最多处理1024个事件
// 阻塞等待事件
int nfds = epoll_wait(epfd, events, 1024, 1);
if (nfds == 1) {
if (errno == EINTR) continue; // 信号中断,正常重试
perror("epoll_wait failed");
exit(EXIT_FAILURE);
}
// 仅遍历有事件的fd(nfds个)
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == server_fd) {
// 处理新连接
} else {
// 处理客户端通信
}
}

在这里插入图片描述

在这里插入图片描述

epoll vs select/poll

结合你的 TCP 服务器场景,用表格清晰对比三者的核心差异:

特性selectpollepoll
fd 数量限制 有(默认 1024) 无(受系统资源限制) 无(仅受系统最大文件描述符限制)
数据拷贝 每次调用拷贝所有 fd 每次调用拷贝所有 fd 仅拷贝一次(mmap 共享内存)
事件检测方式 遍历所有 fd(O (n)) 遍历所有 fd(O (n)) 回调标记有事件的 fd(O (1))
事件返回 返回所有 fd,需遍历筛选 返回所有 fd,需遍历筛选 仅返回有事件的 fd,直接处理
触发模式 仅 LT 仅 LT LT/ET(支持边缘触发)
内存开销 随 fd 数量线性增长 随 fd 数量线性增长 固定开销(内核事件表)
高并发性能 极差(1 万 fd 基本卡死) 差(1 万 fd 遍历成本高) 极佳(10 万 + fd 无压力)
跨平台支持 全平台(Linux/Windows) 大部分平台 仅 Linux 内核 2.6+
赞(0)
未经允许不得转载:网硕互联帮助中心 » 【C++ 网络编程】一篇文章搞懂I/O多路复用(select、poll、epoll)
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!