跳到主要内容

Linux 的 epoll IO 模式

IO 模式演进:从 select 到 epoll

场景:你住在一栋有10000个房间的宿舍楼,经常有朋友来找人。

住校时,你的朋友来找你:

  • select 版宿管阿姨,带着你的朋友挨个房间找,直到找到你
  • epoll 版阿姨,会先记下每位同学的房间号, 你的朋友来时,只需告诉你的朋友你住在哪个房间,无需亲自带着你朋友满大楼逐个房间找人

如果来了10000个人,都要找自己住这栋楼的同学时,select 版和 epoll 版宿管大妈,谁效率高?同理,高并发服务器中,轮询I/O是最耗时操作之一,epoll性能更高也是很明显。

  • select 的调用复杂度 O(n)。如一个保姆照看一群孩子,如果把孩子是否需要尿尿比作网络I/O事件,select 就像保姆挨个询问每个孩子:你要尿尿吗?若孩子回答是,保姆则把孩子拎出来放到另外一个地方。当所有孩子询问完之后,保姆领着这些要尿尿的孩子去上厕所(处理网络I/O事件)
  • epoll 机制下,保姆无需挨个询问孩子是否要尿尿,而是每个孩子若自己需要尿尿,主动站到事先约定好的地方,而保姆职责就是查看事先约定好的地方是否有孩子。若有小孩,则领着孩子去上厕所(网络事件处理)。因此,epoll 的这种机制,能够高效的处理成千上万的并发连接,而且性能不会随着连接数增加而下降。

保姆照看孩子的例子

所以,select 模式下,会有个最大的限制,就是文件描述符的数量限制,默认 1024 个(Linux内核可以调大,但不建议),而 epoll 没有这个限制。

传统 IO 模式 vs Epoll

阻塞 IO (Blocking IO)

问题:一个线程只能处理一个连接,10000个并发需要10000个线程!

非阻塞 IO (Non-blocking IO)

问题:需要不断轮询,CPU消耗大。

IO 多路复用的演进

Epoll 的核心原理

Epoll 的三个核心系统调用

Epoll 内部数据结构

关键理解

  • 红黑树:存储所有监听的文件描述符,查找效率 O(log n)
  • 就绪列表:只包含有事件发生的文件描述符,epoll_wait 只需遍历这个列表

Epoll 工作流程详解

服务器启动和连接建立

高并发场景下的事件处理

LT vs ET 模式详解

在 Linux 中,epoll 提供了两种触发模式:水平触发(LT, Level Triggered) 和 边缘触发(ET, Edge Triggered)。这两种模式决定了应用程序在调用 epoll_wait() 时,内核如何通知文件描述符上的事件。

特性LT模式 (Level Trigger)ET模式 (Edge Trigger)
触发条件缓冲区有数据就通知状态发生变化时通知
是否可能重复通知是,可能多次通知否,只通知一次
编程复杂度简单较复杂,需要非阻塞 IO
数据读取方式可以分多次读必须读到 EAGAIN
性能表现较低,系统调用较多较高,系统调用减少
适用场景常规服务、对性能要求不极端的应用高并发、高性能服务

水平触发 (Level Triggered, LT)

“水平触发”意味着:只要条件满足(缓冲区有数据可读 / 可写空间存在),epoll_wait 就会持续返回事件。即使应用程序一次没有完全处理所有数据,下一次调用 epoll_wait() 时仍然会通知。行为类似于 pollselect,是 epoll 默认工作模式。

特性

  1. 事件不会丢失:应用程序慢一点也不会漏掉数据。
  2. 实现简单:程序只需按需读写,不必担心遗漏。
  3. 效率偏低:可能被内核重复多次通知相同事件。

典型场景

  • 大多数对性能要求不极端的场景,例如常规网络服务器。
  • 适合开发中快速实现,降低编程复杂度。
  • 特别适合新手使用,因为即使忘了读完数据也没问题,下次还会提醒。

解决的问题

  • 确保 不会遗漏事件,保证 IO 的完整性。
  • 替代 select/poll,性能更优(O(1) 级事件复杂度)。

LT 模式特点

  • 只要缓冲区有数据就会触发
  • 不会丢失事件,编程相对简单
  • 效率稍低(可能重复通知)

边缘触发 (Edge Triggered, ET)

“边缘触发”只会在 状态变化时 触发一次事件。例如:缓冲区从“空” → “有数据” 才会通知。如果应用程序没有一次性读完数据,后续 epoll_wait() 不会再次提醒。因此程序必须在接收到事件后,循环读取/写入,直到返回 EAGAIN,才能确保不会遗漏数据。

特性

  1. 通知次数少:只在变化时触发,不会反复提醒。
  2. 性能更高:减少系统调用,提高大规模连接的处理效率。
  3. 编程复杂:需要结合 非阻塞 IO,并确保应用逻辑在一次事件里完全处理数据。

典型场景

  • 高频网络 IO 服务器(如 nginx、redis 等)。
  • 大规模连接场景(C10K/C100K 问题)。
  • 场景特点:高并发、对性能和系统调用开销敏感。

解决的问题

  • 减少重复事件通知,提高高并发下的 可伸缩性
  • 在百万连接下减少内核到用户态的开销。

ET 模式特点

  • 只在状态变化时触发一次
  • 必须一次性读完所有数据
  • 效率更高,但编程复杂

实际代码示例

基础 Epoll 服务器

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

int main() {
// 1. 创建epoll实例
int epoll_fd = epoll_create1(0);

// 2. 创建监听socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

// 3. 绑定和监听
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;

bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 128);

// 4. 添加监听socket到epoll
struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

// 5. 事件循环
struct epoll_event events[1024];
while (1) {
// 等待事件,最多返回1024个
int nfds = epoll_wait(epoll_fd, events, 1024, -1);

for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接到达
int client_fd = accept(listen_fd, NULL, NULL);

// 添加客户端socket到epoll
ev.events = EPOLLIN;
ev.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);

} else {
// 客户端数据到达
int client_fd = events[i].data.fd;
char buffer[1024];
int n = read(client_fd, buffer, sizeof(buffer));

if (n <= 0) {
// 连接关闭或出错
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
close(client_fd);
} else {
// 处理数据并响应
write(client_fd, "HTTP/1.1 200 OK\r\n\r\nHello World", 32);
}
}
}
}
}

ET 模式的正确处理

// ET模式必须循环读取直到EAGAIN
int handle_et_read(int fd) {
char buffer[1024];
int total_read = 0;

while (1) {
int n = read(fd, buffer, sizeof(buffer));
if (n > 0) {
total_read += n;
// 处理读取到的数据
process_data(buffer, n);
} else if (n == 0) {
// 连接关闭
return -1;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据读完了,正常退出
break;
} else {
// 真正的错误
return -1;
}
}
}
return total_read;
}

性能对比和应用场景

性能数据对比

实际测试场景

  • Web服务器:nginx 使用 epoll 可以处理数万并发连接
  • 聊天服务器:单机支持10万在线用户
  • 游戏服务器:实时处理大量玩家的操作指令

Nginx 还使用了 Epoll 的 ET 模式,结合非阻塞 IO,极大提升了高并发处理能力。

应用场景选择

常见问题和最佳实践

惊群问题

问题描述

  • 在多进程或多线程服务器中,多个工作进程(worker)同时监听同一个 listen_fd
  • 一旦有新连接到达,所有进程都会同时被唤醒
  • 但是,真正只有一个进程能 accept() 成功,其余进程调用 accept() 会失败 (EAGAINECONNABORTED)。
  • 这就造成了 大量无谓的 CPU 浪费——几十甚至上百个进程都被唤醒了,只为了一个连接。

影响

  • 系统大规模无效调度,严重浪费 CPU 时间片。
  • 高并发场景下,效率会显著降低。

解决方案

  • 从 Linux 4.5 起,epoll 新增了 EPOLLEXCLUSIVE 标志

整体问题工作原理:

解决方案

// 只唤醒一个等待在 epoll_wait 上的进程
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

这样就能避免“惊群”,只会唤醒一个监听的工作进程。 nginx、redis 等高性能框架都广泛使用这一机制。

文件描述符泄漏

问题描述

  • 当某个客户端连接结束后,程序调用了 close(fd) 关闭文件描述符,但却 忘记从 epoll 内核红黑树中移除
  • 由于 epoll 内部仍然维护这个 fd 的引用,导致内核态和用户态的状态不同步。

影响

  • epoll 内核空间会逐渐堆积无效的 fd,造成 内核资源泄漏
  • 在长时间运行的服务中,可能导致 epoll_wait 返回大量失效事件,甚至出现文件描述符表被耗尽的情况。

解决方案

  • 在关闭 fd 前,必须先调用 epoll_ctl(DEL) 显式移除事件。
// 错误示例:忘记从epoll中删除
int client_fd = accept(listen_fd, NULL, NULL);
// ... 处理连接
close(client_fd); // 只关闭文件描述符,未从epoll删除

// 正确做法:
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL); // 先删除
close(client_fd); // 再关闭

ET 模式的坑

问题描述

  • 在 ET(边缘触发)模式下,epoll_wait 只在“状态变化”时触发一次事件。
  • 如果应用程序 只读一次缓冲区的数据,而没有继续读到 EAGAIN,那么剩下的数据将永远不会再触发通知,导致数据饿死

影响

  • 服务端可能“卡死”在某些客户端数据没读完的情况,表现为 客户端卡顿或服务端假死
  • 很常见于写 ET 代码时忘记写 while (read() != EAGAIN) 这一循环。

解决方案

  1. 在 ET 模式下,fd 必须设置为 非阻塞模式 (O_NONBLOCK)。
  2. 每次触发后,必须 循环读/写,直到返回 -1 且 errno = EAGAIN
// 错误:ET模式下只读一次
int n = read(fd, buffer, sizeof(buffer));
if (n > 0) {
process_data(buffer, n);
}

// 正确:ET模式下必须读到EAGAIN
while (1) {
int n = read(fd, buffer, sizeof(buffer));
if (n > 0) {
process_data(buffer, n);
} else if (n < 0 && errno == EAGAIN) {
break; // 读完了
} else {
// 错误处理
break;
}
}

总结

Epoll 的核心优势

  1. O(1) 时间复杂度:只处理活跃连接,不遍历所有连接
  2. 事件驱动:内核主动通知,而不是应用主动询问
  3. 支持大量并发:没有文件描述符数量限制
  4. 内存效率高:避免频繁的用户态内核态数据拷贝

记忆口诀

  • Select:保姆挨个问孩子要不要尿尿(轮询所有)
  • Epoll:孩子主动告诉保姆要尿尿(事件通知)
  • LT:水龙头一直开着就一直提醒(水平触发)
  • ET:只在水龙头开/关的瞬间提醒一次(边缘触发)

Epoll 让 Linux 服务器能够轻松处理 C10K(万级并发)问题,是现代高性能服务器的基础!

References