在构建单机承载数十万并发连接的网关或消息推送服务时,I/O 多路复用是绕不开的核心基础。早期 UNIX 系统提供的 select 和 poll 在连接数(File Descriptor 数量)上升到数千之后,CPU 利用率往往由于线性扫描而急剧飙升;而 Linux 2.6 引入的 epoll 则将时间复杂度从 $O(N)$ 降到了基于就绪事件数量的 $O(1)$。
本文结合 Linux 内核源码(fs/eventpoll.c),梳理 epoll 的核心数据结构与工程边界条件。
一、为什么 select 与 poll 在高并发下性能退化
在 select 模型中,每次调用都需要将整个 fd_set 位图从用户态拷贝到内核态,内核遍历所有被监控的描述符检查是否有就绪状态。这带来三个明显瓶颈:
- 句柄数量上限:默认
FD_SETSIZE仅为 1024。 - 重复内存拷贝:每次调用
select都要重新传入所有监听的 fd 集合。 - 全量线性扫描:当 10 万个连接中只有 5 个连接有数据到达时,内核依然要遍历 10 万个句柄。
| 多路复用机制 | 底层数据结构 | 时间复杂度 | 句柄数量限制 | 内存拷贝开销 |
|---|---|---|---|---|
select |
固定长度位图 | $O(N)$ | 默认 1024 | 每次调用全量拷贝 |
poll |
动态数组 pollfd |
$O(N)$ | 受限于系统最大文件数 | 每次调用全量拷贝 |
epoll |
红黑树 + 双向就绪链表 | $O(1)$ 仅处理活跃连接 | 受限于系统内存与 nofile |
注册时拷贝一次 |
二、epoll 的核心内核数据结构
调用 epoll_create1(0) 时,内核会在内存中分配一个 struct eventpoll 实例。该结构体中最关键的两个成员是红黑树根节点 rbr 和就绪链表头 rdllist:
struct eventpoll {
spinlock_t lock;
struct mutex mtx;
/* 等待队列:存放调用 epoll_wait 阻塞的进程 */
wait_queue_head_t wq;
/* 就绪描述符双向链表,存放已经有事件就绪的 epitem */
struct list_head rdllist;
/* 红黑树根节点,管理所有通过 epoll_ctl 注册的 fd */
struct rb_root_cached rbr;
/* 当正在向用户态拷贝就绪事件时,新到达的事件暂存于 ovflist */
struct epitem *ovflist;
};1. 红黑树(rbr)管理注册句柄
每当应用程序调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) 时,内核会封装一个 struct epitem 节点插入红黑树 rbr 中。红黑树保证了即使注册了 50 万个连接,插入、修改和删除操作都能在 $O(\log N)$ 时间内完成,且避免重复添加同一个句柄。
2. 回调唤醒机制(ep_poll_callback)
在 epoll_ctl 注册 fd 的同时,内核会将回调函数 ep_poll_callback 挂载到目标 socket 的等待队列中。当网卡接收到数据包、触发硬中断与软中断(NET_RX_SOFTIRQ)、TCP 协议栈完成数据组装后,内核直接调用该 socket 绑定的 ep_poll_callback:
- 将对应的
epitem节点挂载到eventpoll->rdllist双向就绪链表中; - 唤醒阻塞在
eventpoll->wq上的用户进程。
因此,epoll_wait 被唤醒后无需遍历整棵红黑树,只需检查 rdllist 是否为空,并将其中的就绪事件拷贝到用户态数组即可。
三、水平触发(LT)与边缘触发(ET)的实现差异
在 ep_send_events_proc 函数中,内核会将 rdllist 里的就绪节点移出并逐一拷贝给用户空间。如果某个句柄被设置为默认的 水平触发(Level Triggered, LT),且用户在本次读取中没有读完 socket 接收缓冲区的数据,内核会在检查完状态后再次将该 epitem 放回 rdllist,使得下一次 epoll_wait 立刻返回。
而在 边缘触发(Edge Triggered, ET) 模式下,内核移出 epitem 后不会自动重新入队,除非网卡再次收到新数据包触发 ep_poll_callback。
注意:在 ET 模式下,套接字必须设置为非阻塞(
O_NONBLOCK),并且每次收到EPOLLIN事件时必须循环调用read/recv直到返回EAGAIN或EWOULDBLOCK,否则残留数据将导致连接出现“假死”。
// ET 模式下标准的非阻塞读取循环
int read_all_nonblocking(int fd) {
char buf[4096];
for (;;) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
process_payload(buf, (size_t)n);
continue;
}
if (n == 0) {
// 对端正常关闭连接 (FIN)
close(fd);
return 0;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 内核接收缓冲区已读空,等待下一次可读事件
break;
}
if (errno == EINTR) {
continue;
}
// 其他读取异常
close(fd);
return -1;
}
return 1;
}四、生产环境中的惊群问题与 EPOLLEXCLUSIVE
在多进程模型(例如 Nginx Master-Worker 架构)下,多个 Worker 进程如果同时监听同一个监听套接字 listen_fd,当新连接完成三次握手时,早期内核可能会唤醒所有阻塞在 epoll_wait 上的 Worker 进程,但最终只有一个进程能成功 accept,其余进程拿到 EAGAIN 后再次休眠。
解决该问题通常有两种方案:
- 启用
SO_REUSEPORT(Linux 3.9+):让每个 Worker 进程各自绑定相同的 IP 和端口,由内核根据四元组哈希直接将新连接分发给特定 Worker 的独立监听队列。 - 使用
EPOLLEXCLUSIVE标志(Linux 4.5+):在epoll_ctl添加listen_fd时附带EPOLLEXCLUSIVE,内核唤醒等待队列时只唤醒排在队首的未阻塞进程,从而大幅减少无效的上下文切换。