为什么要多路转接?
一个服务器要同时处理多个客户端连接。最朴素的做法是:
-
阻塞 IO + 多进程/多线程:每个连接一个进程/线程,简单但资源开销大,连接数一多就撑不住。
-
非阻塞 IO + 轮询:不停问每个 fd "你好了吗",CPU 空转,效率低。
IO 多路转接的思路是:把一批 fd 交给内核,让内核帮我"盯着",哪个就绪了再告诉我,我再去处理。这样单线程就能管理大量连接,这就是 select / poll / epoll 的共同目标。
三者的演进关系可以概括为:
| 机制 | 核心数据结构 | 最大 fd 数 | 每次调用是否拷贝全部 fd | 就绪查找方式 |
|---|---|---|---|---|
| select | 三个位图 fd_set | 有上限(FD_SETSIZE,通常 1024) | 是 | 遍历位图 |
| poll | pollfd 数组 | 无上限(性能随数量下降) | 是 | 遍历数组 |
| epoll | 红黑树 + 就绪双链表 | 无上限 |
poll:比 select 更顺手的"数组版"
接口
cpp
#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 | 数据可写 | 是 |
一个最小示例:监控标准输入
cpp
#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
cpp
int epoll_create(int size);
创建一个 epoll 句柄。Linux 2.6.8 之后 size 参数被忽略(但必须传一个大于 0 的值)。用完之后必须 close() 关闭。
epoll_ctl
cpp
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 定义:
cpp
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
cpp
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 高性能的关键,在于内核里的这两个结构:
cpp
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 数量"有关。
cpp
struct epitem {
struct rb_node rbn; // 红黑树节点
struct list_head rdllink; // 双向链表节点
struct epoll_filefd ffd; // 事件句柄信息
struct eventpoll *ep; // 指向所属的 eventpoll 对象
struct epoll_event event; // 期待发生的事件类型
};
用一张图概括:
cpp
用户态 内核态
------ ------
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 或精细的锁策略来达到更好的均衡效果。