为什么要多路转接?
一个服务器要同时处理多个客户端连接。最朴素的做法是:
阻塞 IO + 多进程/多线程:每个连接一个进程/线程,简单但资源开销大,连接数一多就撑不住。
非阻塞 IO + 轮询:不停问每个 fd "你好了吗",CPU 空转,效率低。
IO 多路转接的思路是:把一批 fd 交给内核,让内核帮我"盯着",哪个就绪了再告诉我,我再去处理。这样单线程就能管理大量连接,这就是 select / poll / epoll 的共同目标。
三者的演进关系可以概括为:
| 机制 | 核心数据结构 | 最大 fd 数 | 每次调用是否拷贝全部 fd | 就绪查找方式 |
|---|---|---|---|---|
| select | 三个位图 fd_set | 有上限(FD_SETSIZE,通常 1024) | 是 | 遍历位图 |
| poll | pollfd 数组 | 无上限(性能随数量下降) | 是 | 遍历数组 |
| epoll | 红黑树 + 就绪双链表 | 无上限 |
poll:比 select 更顺手的"数组版"
接口
#include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd { int fd; /* 要监听的文件描述符 */ short events; /* 请求监听的事件(输入) */ short revents; /* 实际发生的事件(输出) */ };参数说明:
fds:pollfd 结构数组,每个元素描述一个 fd 及关心的事件。
nfds:数组长度。
timeout:超时时间,单位毫秒。
-1表示永久阻塞,0表示立即返回。
返回值:
< 0:出错;= 0:等待超时;> 0:有 fd 就绪,返回值是就绪的 fd 个数。
常用事件
| 事件 | 含义 | 可作输入 | 可作输出 |
|---|---|---|---|
| POLLIN | 普通数据/优先数据可读 | 是 | 是 |
| POLLRDNORM | 普通数据可读 | 是 | 是 |
| POLLRDBAND | 优先级带数据可读(Linux 不支持) | 是 | 是 |
| POLLPRI | 高优先级数据可读(如 TCP 带外数据) | 是 | 是 |
| POLLOUT | 数据可写 | 是 |
一个最小示例:监控标准输入
#include <poll.h> #include <unistd.h> #include <stdio.h> int main() { struct pollfd poll_fd; poll_fd.fd = 0; // 标准输入 poll_fd.events = POLLIN; for (;;) { int ret = poll(&poll_fd, 1, 1000); if (ret < 0) { perror("poll"); continue; } if (ret == 0) { printf("poll timeout\n"); continue; } if (poll_fd.revents == POLLIN) { char buf[1024] = {0}; read(0, buf, sizeof(buf) - 1); printf("stdin:%s", buf); } } }poll 的优缺点
优点:
接口更统一:select 用三个位图分别表示读、写、异常,poll 用一个 pollfd 数组统一描述,且
events(输入)和revents(输出)分离,不需要像 select 那样每次调用前重新设置 fd_set。没有最大数量限制:pollfd 数组可以动态扩容,不像 select 受 FD_SETSIZE 限制。
缺点:
返回后仍需轮询:poll 只告诉你"有几个 fd 就绪",具体是哪些,还得遍历整个数组检查
revents。每次调用都要全量拷贝:每次 poll 都要把整个 pollfd 数组从用户态拷贝到内核态,fd 越多拷贝开销越大。
效率随 fd 数量线性下降:大量连接中只有少量活跃时,绝大部分遍历都是无效的。
一句话总结 poll:比 select 好用,但没解决"全量拷贝 + 全量遍历"的根本问题。
epoll:为大批量句柄而生
poll 是什么
man 手册的原话是:epoll 是"为处理大批量句柄而作了改进的 poll"。它在 Linux 2.5.44 内核中被引入,被公认为 Linux 2.6 下性能最好的多路 IO 就绪通知方法。
epoll 的使用是三部曲:
epoll_create:创建一个 epoll 句柄;epoll_ctl:把要监控的 fd 注册进去(增/删/改);epoll_wait:等待就绪事件。
三个系统调用
epoll_create
int epoll_create(int size);创建一个 epoll 句柄。Linux 2.6.8 之后size参数被忽略(但必须传一个大于 0 的值)。用完之后必须close()关闭。
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);epoll 的事件注册函数。
与 select/poll "每次监听时告诉内核关心什么事件"不同,epoll 是先注册、后等待。
第一个参数:epoll 句柄(epoll_create 的返回值);
第二个参数:动作,三个宏之一:
EPOLL_CTL_ADD:注册新的 fd;EPOLL_CTL_MOD:修改已注册 fd 的监听事件;EPOLL_CTL_DEL:删除一个 fd;
第三个参数:要监听的目标 fd;
第四个参数:告诉内核监听什么事件。
struct epoll_event定义:
typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ } EPOLL_PACKED;events常用宏:
| 宏 | 含义 |
|---|---|
| EPOLLIN | 对应 fd 可读(包括对端 socket 正常关闭) |
| EPOLLOUT | 对应 fd 可写 |
| EPOLLPRI | 有紧急数据可读(带外数据) |
| EPOLLERR | fd 发生错误 |
| EPOLLHUP | fd 被挂断 |
| EPOLLET | 设置为边缘触发(ET)模式 |
| EPOLLONESHOT | 只监听一次,之后需重新加入红黑树才能继续监听 |
epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);收集已就绪的事件。
events:用户态分配好的 epoll_event 数组,内核把就绪事件复制到这里。不能是空指针,内核只负责复制数据,不负责在用户态分配内存。
maxevents:告诉内核数组有多大,必须大于 0。
timeout:毫秒。
0立即返回,-1永久阻塞。
返回值:成功时返回就绪的 fd 数目;0表示超时;< 0表示失败。
epoll 工作原理(重点)
理解 epoll 高性能的关键,在于内核里的这两个结构:
struct eventpoll { /* 红黑树的根节点,存储所有添加到 epoll 中的需要监控的事件 */ struct rb_root rbr; /* 双链表,存放将通过 epoll_wait 返回给用户的满足条件的事件 */ struct list_head rdlist; };每个 epoll 对象都有一个独立的eventpoll结构体。工作流程如下:
注册:调用
epoll_ctl(EPOLL_CTL_ADD)时,内核会为每个事件建立一个epitem结构体,并把它挂到红黑树上。红黑树的作用是高效识别重复添加,插入效率 O(log n)。回调注册:每个添加到 epoll 的事件都会与设备(网卡)驱动建立回调关系。当事件真正发生时,驱动调用内核中的
ep_poll_callback,它把对应的 epitem 加到rdlist就绪双链表中。等待:
epoll_wait检查时,只需看rdlist是否为空。非空就把就绪事件复制到用户态数组并返回数量。这个操作的时间复杂度是O(1)——不管注册了多少 fd,都只和"就绪的 fd 数量"有关。
struct epitem { struct rb_node rbn; // 红黑树节点 struct list_head rdllink; // 双向链表节点 struct epoll_filefd ffd; // 事件句柄信息 struct eventpoll *ep; // 指向所属的 eventpoll 对象 struct epoll_event event; // 期待发生的事件类型 };用一张图概括:
用户态 内核态 ------ ------ epoll_create ────────► eventpoll { rbr(红黑树), rdlist(就绪链表) } epoll_ctl ADD ────────► epitem 插入红黑树,注册回调 ep_poll_callback │ 事件发生(网卡驱动) │ ep_poll_callback 把 epitem 挂到 rdlist │ epoll_wait ◄──────── 检查 rdlist,非空则拷贝就绪事件返回(O(1))epoll 的优点(对照 select/poll 的缺点)
接口使用方便:虽然拆成三个函数,但不需要每次循环都重新设置关注的文件描述符,输入输出参数分离,用起来反而更清晰高效。
数据拷贝轻量:只在
EPOLL_CTL_ADD时把 fd 结构拷贝到内核一次,不像 select/poll 每次循环都要全量拷贝。事件回调机制:不靠遍历,而是靠回调把就绪 fd 加入就绪队列,
epoll_wait直接访问就绪队列就知道哪些 fd 就绪,时间复杂度 O(1)。fd 数量再多,效率也不受影响。没有数量限制:fd 数目无上限。
三者的对比总结
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | 三个位图 fd_set | pollfd 数组 | 红黑树 + 就绪链表 |
| 最大 fd 数 | FD_SETSIZE(通常 1024) | 无上限 | 无上限 |
| fd 拷贝 | 每次调用全量拷贝 | 每次调用全量拷贝 | 仅注册时拷贝一次 |
| 就绪查找 | 遍历位图 O(n) | 遍历数组 O(n) | 直接取就绪链表 O(1) |
| 触发模式 | LT | LT | LT / ET |
| 适用场景 | 少量连接 | 少量连接 | 大量连接、少量活跃 |
epoll 的两种工作模式:LT 与 ET
一个很生动的比喻:你妈喊你吃饭。
水平触发(LT):你妈喊你一次你没动,她会继续喊第二次、第三次……
边缘触发(ET):你妈喊你一次你没动,她就不管你了。
场景设定
假设:
已把一个 TCP socket 添加到 epoll;
对端写入了 2KB 数据;
epoll_wait返回,说明可读;你调用
read只读了 1KB;继续调用
epoll_wait……
水平触发 LT(Level Triggered)
epoll 的默认模式。
检测到 socket 就绪时,可以不立刻处理,或者只处理一部分。
上面的例子中,缓冲区还剩 1KB,第二次
epoll_wait仍然会立刻返回并通知读就绪。直到缓冲区数据全部处理完,
epoll_wait才不再立刻返回。支持阻塞读写和非阻塞读写。
边缘触发 ET(Edge Triggered)
在添加 socket 时带上EPOLLET标志,进入 ET 模式。
检测到就绪时必须立刻处理。
上面的例子中,只读了 1KB,剩下 1KB 在缓冲区,但第二次
epoll_wait不会再返回了。也就是说,ET 模式下 fd 就绪后只有一次处理机会。
ET 性能比 LT 更高(
epoll_wait返回次数少很多),Nginx 默认采用 ET。只支持非阻塞读写。
LT 与 ET 的取舍
LT 是默认行为,代码简单,不容易丢数据。
ET 减少触发次数,但逼迫程序员在一次就绪响应中把数据全部处理完,代码复杂度更高。
如果在 LT 下也能做到每次就绪都立刻处理、不让就绪被重复提示,其实性能和 ET 是一样的。ET 的优势主要体现在"少返回几次"上。
为什么 ET 必须搭配非阻塞 fd?
这不是接口的硬性要求,而是工程实践上的要求。一个非常典型的场景:
服务器收到一个 10KB 的请求,要向客户端返回应答。如果客户端收不到应答,不会发送第二个 10KB 请求。
假设服务端用阻塞式 read,且一次只读 1KB:
第一次
epoll_wait返回可读,read 读了 1KB,缓冲区还剩 9KB;由于是 ET 模式,内核不认为 fd 仍然"新就绪",
epoll_wait不再返回;剩下的 9KB 一直躺在缓冲区里;
但服务器逻辑是"读完 10KB 才给客户端响应";
客户端没收到响应,就不会发下一个请求;
没有新数据到来,
epoll_wait永远不返回,死锁。
于是形成死循环式的互相等待:
客户端:你要把 10KB 都读完给我响应,我才发下一个数据啊!
服务器:客户端你快再发一个数据,我才能把剩下 9KB 读出来啊!
关键矛盾:ET 只给一次处理机会,但你用阻塞 read 又不敢一次读干净(因为阻塞 read 在没数据时会睡死),于是只能读一部分,剩下的数据再也等不到通知。
解决方案:用非阻塞 + 循环读的方式,保证一次把缓冲区里的数据读干净(读到EAGAIN/EWOULDBLOCK为止),这样就不依赖"下一次事件通知"。
而 LT 模式没有这个问题:只要缓冲区还有数据没读完,epoll_wait就会继续返回读就绪,程序总有机会把剩余数据读走。
结论:ET + 非阻塞是标配组合。ET 负责"少通知",非阻塞负责"一次读干净"。
epoll 的使用场景
epoll 的高性能是有特定场景的,选错场景可能适得其反。
适合:多连接,且多连接中只有一部分比较活跃。典型如处理上万客户端的互联网 APP 入口服务器。
不适合:系统内部服务器之间通信、只有少数几个连接的场景。这种场景用 epoll 并不合适,select/poll 甚至阻塞 IO 可能更简单。
一句话:epoll 赢在"连接多、活跃少",不是所有场景的无脑最优解。
epoll 中的惊群问题
epoll 中的惊群问题,简单说就是:多个进程或线程同时等在同一个 epoll 实例上,一个事件到来时,内核把“所有人”都叫醒了,但最终只有一个能真正干活,其他人白醒一场。
为什么会发生?
当多个进程通过fork继承了同一个监听 socket,并各自把该 socket 加入自己的 epoll 实例时,这个监听 socket 的等待队列上就挂了多个 epoll 实例。
默认情况下,这个等待队列是非排他的。一旦新连接到达,内核会唤醒等待队列上的所有epoll 实例,进而唤醒所有阻塞在epoll_wait上的进程。结果就是:所有进程都被调度起来,抢着去accept,但只有一个成功,其余进程拿不到连接,只能重新回去睡。CPU 时间就这么浪费在了无效的唤醒和上下文切换上。
怎么解决?
主要有三种思路,从内核到应用层都有方案:
1. 内核方案:EPOLLEXCLUSIVE(Linux 4.5+)
在epoll_ctl添加监听 fd 时带上EPOLLEXCLUSIVE标志。这样当事件发生时,内核只唤醒等待队列上的第一个(或少数几个)有线程在等的 epoll 实例,而不是全部。
2. 内核方案:SO_REUSEPORT
让每个 worker 进程自己bind同一个端口,各自拥有独立的监听 socket和 epoll 实例。内核在收到新连接时,负责把它均衡地分给其中一个 socket。因为大家不再共享同一个等待队列,从根源上就没有“抢同一个事件”的问题了。这是目前比较推荐的方案。
3. 应用层方案:Nginx 的 accept_mutex
这是 Nginx 早期的经典做法。用一个进程间的互斥锁,保证同一时刻只有一个 worker把监听 fd 加入自己的 epoll 里去等 accept。拿到锁的进程处理完 accept 事件(或超时)后释放锁,下一个进程再拿锁去监听。相当于在应用层把“并发抢”改成了“轮流等”,避免了无效唤醒。
在LT(水平触发)模式下,即使用了EPOLLEXCLUSIVE,也可能存在负载不均衡的问题:一个进程可能反复被唤醒并独占处理事件,其他进程一直闲着。而在ET(边缘触发)模式下,虽然惊群本身被缓解,但多个进程循环accept时,也可能出现“A 进程把连接都 accept 走了,B 进程被唤醒后却无活可干”的类惊群现象。所以实际工程中,往往结合SO_REUSEPORT或精细的锁策略来达到更好的均衡效果。