IO多路复用:select/poll/epoll
- select
- select函数
- 代码演示
- select的特点
- select的缺点
- poll
- poll的优点
- poll的缺点
- 代码演示
- epoll
- epoll相关调用
- epoll的两种触发方式
- 水平触发(LT)
- 边缘触发(ET)
- epoll的优点
- epoll中的惊群问题
- 1. 惊群问题的典型场景
- 2. 惊群问题的危害
- 3. 为什么 epoll 会触发惊群?
- 4. 解决方案
select
在 Linux 中,select 是一种早期的 I/O 多路复用技术,允许程序通过单个线程同时监视多个文件描述符(如套接字、管道等),并在其中任何一个文件描述符就绪(可读、可写或发生异常)时通知应用程序。
select函数
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
nfds:需要监视的最大文件描述符+1
readfds、writefds、exceptfds:输入输出型参数,其中readfds只关心读事件(传入时表明有多少文件需要被关心读事件,传出时表明有多少文件的读事件已就绪,wirtefds和exceptfds同理),wirtefds只关心写事件,exceptfds只关心异常事件
timeout:
- NULL:表示select中没有timeout,即select会一直被阻塞
- 0:仅检测描述符集合的状态,然后返回,并不阻塞
- 特定的时间:仅阻塞指定的时间,如果没有事件发生则返回
struct timeval
{long tv_sec; /* seconds */long tv_usec; /* microseconds */
};
fd_set
#define __FD_SETSIZE 1024 // 默认支持的最大文件描述符数量typedef struct {unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;
这个结构中包含了一个数组,该数组充当了位图的作用,select就是通过位图来对文件描述符进行监视的
操作fd_set的接口
void FD_CLR(int fd, fd_set *set);//清除set中相关fd的位
int FD_ISSET(int fd, fd_set *set);//测试set中fd位是否为真
void FD_SET(int fd, fd_set *set);//设置set中的fd
void FD_ZERO(fd_set *set);//清空set
函数返回值
- 成功则返回文件状态以改变的文件个数
- 返回0表示已经超时且没有监听到事件就绪
- 出错返回-1,错误原因存于errno中,所有参数的值都失效
代码演示
#include <sys/select.h>
#include <stdio.h>
#include <unistd.h>int main()
{fd_set readset;int fd=0;//从stdin中获取数据FD_SET(fd,&readset);select(fd+1,&readset,NULL,NULL,NULL);if(FD_ISSET(fd,&readset)==1){char buffer[1024];int n = read(0,buffer,1024);buffer[n]='\0';printf("%s\n",buffer);}return 0;
}
select的特点
- 可监控的文件描述符个数取决于sizeof(fd_set)的值,即select可管理的文件描述符的个数是有限制的
- 将fd加入到select中时,还要使用一个数组保存放到select中的fd
- 一是用于在select返回之后,数组作为源数据和fd_set进行FD_ISSET判断
- 二是select返回后会将以前加入的但无事发生的fd清空,则每次开始select之前都需要从数组中取得fd逐一加入,扫描数组的同时取得fd的最大值maxfd,用于select的第一个参数
select的缺点
- 每次调用select,都需要手动设置fd集合,使用上不方便
- 每次调用select,都需要将fd集合从用户态拷贝到内核态,当fd很多时,开销也会变得很大
- 每次调用select时都需要在内核遍历传递进来的所有fd
- select支持的文件描述符数量太少
poll
int ppoll(struct pollfd *fds, nfds_t nfds,const struct timespec *tmo_p, const sigset_t *sigmask);
struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */
};
fds:是一个poll函数监听的结构列表,每个元素中包含三部分内容:文件描述符、监听的事件集合和返回的事件集合
nfds:表示fds数组的长度
timeout:表示poll函数的超时时间(单位:毫秒),设置-1表示永久阻塞
events和revents:取值最常用的是POLLIN(读事件)和POLLOUT(写事件)
返回值:小于0,表示出错;大于0,表示poll函数等待超时;大于0表示poll由于监听的文件描述符就绪而返回
poll的优点
- 不同于select使用三个位图来标识三个fdset的方式,poll使用一个pollfd的指针来实现
- pollfd结构包含了要监视的event和发生的event,不再使用select参数-值传递的方式,使用更加方便
- poll没有最大数量的限制
poll的缺点
- 当poll监听的文件描述符增多之后,和select一样,poll返回之后,需要轮询pollfd来获取就绪的描述符
- 每次调用poll都需要将大量的pollfd结构从用户态拷贝到内核态当中
代码演示
#include <poll.h>
#include <stdio.h>
#include <unistd.h>int main()
{struct pollfd fd;fd.fd=0;fd.events=POLLIN;poll(&fd,1,-1);if(fd.revents=POLLIN){char buffer[1024];int n=read(0,buffer,sizeof(buffer));buffer[n]='\0';printf("%s\n",buffer);}return 0;
}
epoll
在 Linux 中,epoll 是一种高效的 I/O 多路复用机制,专为处理大规模并发连接而设计。它克服了传统 select 和 poll 的性能瓶颈
epoll相关调用
epoll_create
int epoll_create(int size);
- 创建成功后返回 epoll 文件描述符(epfd)
- 自linux2.6.8以后,size参数是被忽略的
- 用完之后,必须调用close关闭
epoll_ctl:管理监控的 fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
作用:向 epoll 实例添加、修改或删除监控的 fd。
参数:
op:操作类型(EPOLL_CTL_ADD(注册一个fd到epfd中)、EPOLL_CTL_MOD(修改已经注册的fd的监听事件)、EPOLL_CTL_DEL(从epfd中删除一个fd))。
event:指定监控的事件类型(如可读、可写)及用户数据。
struct epoll_event结构
typedef union epoll_data {void *ptr; // 用户自定义数据指针int fd; // 文件描述符uint32_t u32; // 32位整数uint64_t u64; // 64位整数
} epoll_data_t;struct epoll_event {uint32_t events; // 监控的事件类型(位掩码)epoll_data_t data; // 用户数据(联合体)
};
(1) events
字段
作用:指定要监控的事件类型,通过 位掩码(bitmask) 组合多个事件。
常用事件类型:
事件类型 | 描述 |
---|---|
EPOLLIN | 文件描述符可读(有数据到达或连接关闭)。 |
EPOLLOUT | 文件描述符可写(发送缓冲区未满)。 |
EPOLLERR | 发生错误(自动监控,无需显式设置)。 |
EPOLLHUP | 对端关闭连接或挂起(自动监控)。 |
EPOLLET | 边缘触发模式(Edge-Triggered),默认是水平触发(Level-Triggered)。 |
EPOLLONESHOT | 单次触发,事件处理后需重新注册(用于多线程安全场景)。 |
示例: |
// 监控可读事件,并启用边缘触发模式
event.events = EPOLLIN | EPOLLET;
(2) data
字段
- 作用:存储用户自定义数据,事件触发时可通过该字段快速关联上下文。
- 联合体成员:
成员 类型 用途 ptr
void*
指向用户自定义数据结构(如连接上下文)。 fd
int
直接存储文件描述符。 u32
uint32_t
存储32位整数(较少使用)。 u64
uint64_t
存储64位整数(较少使用)。
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 作用:阻塞等待就绪事件,返回就绪的 fd 数量。
- 参数:
events
:输出参数,存储就绪事件的数组。epoll会将发生的事件拷贝到events数组中(events数组不可以是空指针,内核只负责拷贝,不会主动分配内存)maxevents
:数组大小,防止溢出。timeout
:超时时间(ms),-1 表示阻塞,0 表示非阻塞。- 如果函数调用成功,返回对应IO上已准备好的文件描述符的数目,如果返回0则表示已经超时,返回小于0表示函数失败
epoll的两种触发方式
水平触发(LT)
- 行为:若 fd 的 I/O 事件未处理完,
epoll_wait
会持续通知。 - 优点:编程简单,容错性高。
- 缺点:可能重复触发,增加无效检查。
- 适用场景:常规应用开发(如 HTTP 服务器)。
边缘触发(ET)
- 行为:仅在 fd 的 I/O 状态变化时通知一次(如从不可读变为可读)。
- 优点:减少事件触发次数,提高性能。
- 缺点:需一次性处理完所有数据,否则可能丢失事件。
- 适用场景:高性能服务器(需结合非阻塞 I/O)。
注意 :在 Linux 的 epoll 边缘触发模式(ET 模式)下,必须将文件描述符(fd)设置为非阻塞模式,否则可能导致数据丢失、性能下降甚至死锁。以下是详细原因和解释:
(1) 避免阻塞导致事件丢失
- 场景:
假设recv
读取数据时,缓冲区中有 10KB 数据,但用户只读取了 5KB 后停止。- 非阻塞模式:
recv
读完后再次调用recv
会立即返回EAGAIN
或EWOULDBLOCK
,表示数据已读完。 - 阻塞模式:
recv
读完后当再次调用recv
会阻塞线程,直到新数据到达或连接关闭。
- 非阻塞模式:
- 风险: 在阻塞模式下,若线程卡在
recv
,即使其他 fd 有事件就绪,也无法及时处理(单线程场景),导致事件堆积或服务瘫痪。
(2) 确保一次性处理所有数据 - ET 模式的黄金法则:必须循环读取/写入数据,直到返回错误
EAGAIN
(表示当前无数据可读或缓冲区已满)。 - 非阻塞模式的必要性:只有非阻塞 fd 才会在数据读完时返回
EAGAIN
,而阻塞 fd 会一直等待,导致无法判断是否处理完数据。
(3) 防止死锁 - 场景: 若使用阻塞 fd,且
recv
卡在等待数据时,对端可能已关闭连接,但本端无法及时检测,导致线程永久阻塞。
epoll的优点
- 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符结构加入到就绪队列(通常情况下是一个双向链表)中,epoll_wait返回直接访问就绪队列就知道文件描述符就绪,这个操作的时间复杂度O(1),即使文件描述符数目很多,效率也不会收到影响
- 没有文件描述符的数量限制
- 底层采用红黑树存储监控的fd,插入、删除、查找的时间复杂度为O(logN)
epoll中的惊群问题
- 在 Linux 的
epoll
机制中,惊群问题(Thundering Herd Problem) 是指当多个进程或线程同时监听同一个文件描述符(如监听 socket)时,内核会将事件同时通知所有等待者,但最终只有一个进程/线程能成功处理该事件,导致大量无效的上下文切换和资源竞争,从而降低系统性能。
1. 惊群问题的典型场景
假设一个多进程服务器模型如下:
- 父进程:创建多个子进程(Worker 进程)。
- 子进程:共享同一个监听 socket,并各自调用
epoll_wait
等待新连接。 - 事件触发:当新连接到达时,所有子进程的
epoll_wait
被唤醒,但只有一个子进程能成功调用accept()
获取连接,其他进程被唤醒后因无事件而空转。
2. 惊群问题的危害
问题 | 描述 |
---|---|
CPU 资源浪费 | 大量进程/线程被无效唤醒,导致上下文切换开销。 |
锁竞争加剧 | 多个进程/线程同时争抢共享资源(如 accept() 队列),增加延迟。 |
吞吐量下降 | 系统忙于处理无效唤醒,真正处理请求的吞吐量降低。 |
3. 为什么 epoll 会触发惊群?
- 内核默认行为:
当监听 socket 有新连接到达时,内核会唤醒所有通过epoll_wait
等待该 socket 的进程/线程(即“水平触发”模式的默认行为)。 - 历史原因:
早期 Linux 内核未针对多进程监听同一 socket 的场景优化,导致惊群问题普遍存在。
4. 解决方案
(1) 使用 EPOLLEXCLUSIVE
标志(Linux 4.5+)
-
作用:确保同一时间只有一个进程被唤醒处理新连接。
-
用法:在注册事件时添加
EPOLLEXCLUSIVE
标志。struct epoll_event event; event.events = EPOLLIN | EPOLLEXCLUSIVE; // 启用独占唤醒 epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &event);
-
优点:内核级别解决惊群,无需修改应用逻辑。
-
限制:仅适用于 Linux 4.5 及以上内核。
(2) 使用 SO_REUSEPORT
选项(Linux 3.9+
-
作用:允许多个进程绑定到同一 IP 和端口,内核自动分配连接给不同进程。
-
用法:在每个子进程中独立创建监听 socket 并设置
SO_REUSEPORT
。int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt)); bind(listen_fd, ...); listen(listen_fd, ...);
-
优点:彻底避免惊群,连接负载均衡到不同进程。
-
限制:需每个进程独立监听,适用于无共享状态的 Worker 模型。
(3) 单进程监听 + 派发连接(传统方案)
- 作用:仅由一个进程负责监听新连接,通过进程间通信(如管道、共享内存)将连接派发给其他 Worker 进程。
- 示例:
- 父进程调用
epoll_wait
监听 socket。 - 新连接到达后,父进程
accept()
,将连接 fd 发送给某个子进程处理。
- 父进程调用
- 优点:兼容性高,适用于旧内核。
- 缺点:父进程可能成为性能瓶颈。