【Linux开发】I/O 复用:epoll 模型
所有可能产生 EAGAIN 的套接字都必须设为非阻塞(监听套接字也最好设为非阻塞,但这里仅示范客户端)。注册事件时加上EPOLLET标志。在while循环中反复调用read,直到返回-1且,才能保证读完所有数据。处理完数据后不要忘记处理连接关闭epoll是 Linux 下高性能 I/O 复用的首选方案。三大核心函数(创建实例)、epoll_ctl(添加/删除监视)、epoll_wait(等待事件)
一、为什么需要 epoll?
1.1 select 的缺点
在之前的教程中,我们学习了 select 模型。它可以同时监视多个套接字,但存在几个致命问题:
| 问题 | 说明 | 影响 |
|---|---|---|
| 描述符上限 | 默认 1024,虽可修改但效率下降 | 无法支持大量连接 |
| 每次都要传递集合 | select 调用时,需要把 fd_set 从用户态拷贝到内核态 |
频繁拷贝,开销大 |
| O(n) 扫描 | 返回后,应用程序需要遍历所有 fd 找出就绪的 | 连接越多越慢 |
| 集合被修改 | select 会修改传入的集合,每次都需要重新设置 |
编程麻烦 |
简单来说:select 每次都要告诉内核“你要监视哪些 fd”,内核也要逐一检查所有 fd,效率低下。
1.2 epoll 的优势
epoll 是 Linux 特有的 I/O 复用机制(Windows 没有,对应的是 IOCP),它解决了 select 的痛点:
- 无描述符上限(受系统内存限制)。
- 一次注册,多次使用:只需把要监视的 fd 告诉内核一次,后续只通知变化的部分。
- 事件驱动:内核只返回真正发生事件的 fd,无需遍历所有 fd。
- 支持水平触发和边缘触发(后文详细讲解)。
二、epoll 核心数据结构
epoll 在内核中维护一个红黑树(存储所有被监视的 fd)和一个就绪链表(存储就绪的 fd)。当 I/O 事件发生时,内核把对应的 fd 放入就绪链表,应用程序通过 epoll_wait 获取就绪链表的内容。
三、epoll 三大函数
3.1 epoll_create —— 创建 epoll 实例
#include <sys/epoll.h>
int epoll_create(int size);
- 作用:创建一个 epoll 实例,返回一个文件描述符(epfd),后续所有 epoll 操作都使用它。
- 参数
size:从 Linux 2.6.8 开始被忽略,但必须 >0(历史遗留)。 - 返回值:成功返回 epfd(一个非负整数),失败返回 -1。
为什么需要 epfd?
epoll 实例本身也是一个文件描述符,可以被 close 关闭。它相当于一个“管理者”,记录所有监视的 fd。
3.2 epoll_ctl —— 控制 epoll 监视列表
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 作用:向 epoll 实例中添加、修改或删除要监视的文件描述符。
- 参数:
epfd:epoll_create返回的 epoll 文件描述符。op:操作类型,取以下值:EPOLL_CTL_ADD:添加 fd 到监视列表。EPOLL_CTL_MOD:修改 fd 上监视的事件。EPOLL_CTL_DEL:从监视列表中删除 fd。
fd:要操作的文件描述符(如套接字)。event:struct epoll_event结构体指针,指定监视的事件类型和用户数据。
struct epoll_event 结构体:
struct epoll_event {
uint32_t events; // 事件类型(EPOLLIN、EPOLLOUT 等)
epoll_data_t data; // 用户数据,通常放 fd
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
常用事件类型:
| 宏 | 含义 |
|---|---|
EPOLLIN |
有数据可读(包括连接请求、对方关闭) |
EPOLLOUT |
可写(输出缓冲区空) |
EPOLLRDHUP |
对方关闭连接或半关闭 |
EPOLLERR |
发生错误 |
EPOLLET |
边缘触发模式(Edge-Triggered) |
EPOLLONESHOT |
只触发一次,之后需要重新注册 |
示例:添加监听套接字,监视可读事件。
struct epoll_event event;
event.events = EPOLLIN; // 监视可读
event.data.fd = serv_sock; // 存储 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
3.3 epoll_wait —— 等待事件发生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 作用:等待事件发生,返回就绪的 fd 列表。
- 参数:
epfd:epoll 实例文件描述符。events:指向epoll_event数组的指针,用于接收就绪的事件列表。maxevents:最多返回多少个事件(通常为数组长度)。timeout:超时时间(毫秒)。-1表示无限等待,0表示立即返回。
- 返回值:成功返回就绪的 fd 数量(0 表示超时),失败返回 -1。
注意:epoll_wait 返回后,events 数组中只包含就绪的 fd,无需遍历所有 fd,效率极高。
四、水平触发(LT)与边缘触发(ET)
epoll 支持两种触发模式,这是它与 select 最大的不同之处。
4.1 水平触发(Level-Triggered,LT)
- 默认模式。
- 行为:只要某个 fd 上有未处理的数据(或满足事件条件),
epoll_wait就会一直通知你。 - 类比:门铃一直响,直到你出来处理。
示例:读缓冲区有 100 字节,你只读了 50 字节,那么下次 epoll_wait 还会立即返回,告诉你还有数据可读。
优点:编程简单,不容易漏掉数据。
缺点:可能会频繁唤醒,效率略低于边缘触发。
4.2 边缘触发(Edge-Triggered,ET)
- 需要指定
EPOLLET标志。 - 行为:只有当 fd 的状态发生变化时(如从无数据变为有数据),才通知一次。之后即使还有数据未读完,也不会再通知,直到下次状态变化。
- 类比:门铃只在你到达门口时响一次,不管你有没有把东西拿完。
示例:读缓冲区从 0 字节变为 100 字节时,epoll_wait 返回一次。如果你只读了 50 字节,下次 epoll_wait 不会再返回,直到有新数据到来。
要求:
- 必须循环读取直到返回
EAGAIN或EWOULDBLOCK(表示无更多数据)。 - 通常需要将套接字设为非阻塞模式,否则最后一次
read会阻塞。
优点:唤醒次数少,效率更高。
缺点:编程复杂,必须处理非阻塞 I/O 和循环读取。
4.3 两种模式对比
| 特性 | 水平触发(LT) | 边缘触发(ET) |
|---|---|---|
| 通知次数 | 持续通知直到处理完 | 只通知一次状态变化 |
| 编程难度 | 简单 | 较复杂 |
| 性能 | 较低(可能多次唤醒) | 较高 |
| 适用场景 | 通用 | 高并发、大量连接 |
| 是否需要非阻塞 | 不需要 | 必须 |
五、实战:epoll 回声服务器(水平触发版)
下面实现一个使用 epoll 的水平触发回声服务器,逻辑与 select 版相似,但效率更高。
5.1 服务器代码(逐行注释)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define BUF_SIZE 100 // 消息缓冲区大小
#define EPOLL_SIZE 50 // epoll 可监视的最大 fd 数量(实际无上限,只是数组大小)
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
// epoll 相关变量
struct epoll_event *ep_events; // 用于接收就绪事件的数组
struct epoll_event event; // 用于注册事件的结构体
int epfd, event_cnt; // epfd: epoll 实例句柄, event_cnt: 就绪事件数量
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
// ========== 1. 创建 TCP 套接字 ==========
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
// 绑定地址
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 监听
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
// ========== 2. 创建 epoll 实例 ==========
epfd = epoll_create(EPOLL_SIZE); // 参数已忽略,但需 >0
// 分配内存用于存放就绪事件(最多 EPOLL_SIZE 个)
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
// ========== 3. 将监听套接字加入 epoll 监视 ==========
event.events = EPOLLIN; // 监视可读事件(连接请求、数据到达)
event.data.fd = serv_sock; // 存储 fd,后面可以根据这个判断是哪个套接字
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
// ========== 4. 事件循环 ==========
while (1) {
// 等待事件发生,-1 表示无限等待
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error");
break;
}
// 遍历所有就绪的事件
for (i = 0; i < event_cnt; i++) {
// 情况1:监听套接字就绪 → 有新连接
if (ep_events[i].data.fd == serv_sock) {
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
// 将新客户端套接字加入 epoll 监视
event.events = EPOLLIN; // 监视可读(数据到达)
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connected client: %d\n", clnt_sock);
}
// 情况2:客户端套接字就绪 → 数据到达或连接关闭
else {
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if (str_len == 0) { // 客户端正常关闭连接
// 从 epoll 监视列表中删除该 fd
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d\n", ep_events[i].data.fd);
} else { // 收到数据,原样返回(回声)
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
close(serv_sock);
close(epfd);
free(ep_events);
return 0;
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
5.2 客户端代码
客户端代码与 select 版本完全相同,这里不再重复。
六、边缘触发(ET)模式详解与代码
6.1 为什么边缘触发更高效?
边缘触发模式下,内核只在状态变化时通知一次,这大大减少了内核唤醒应用程序的次数。为了确保不遗漏数据,应用程序必须循环读取直到缓冲区为空(返回 EAGAIN)。
6.2 必须使用非阻塞 I/O
在边缘触发模式下,如果最后一次 read 没有数据可读,它会阻塞(因为套接字默认是阻塞的)。因此需要将套接字设为非阻塞模式,这样 read 返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK,表示“暂时无数据”。
6.3 设置非阻塞的函数
void setnonblockingmode(int fd)
{
int flag = fcntl(fd, F_GETFL, 0); // 获取当前文件状态标志
fcntl(fd, F_SETFL, flag | O_NONBLOCK); // 添加非阻塞标志
}
6.4 边缘触发版回声服务器(逐行注释)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#define BUF_SIZE 4 // 故意设置小缓冲区,演示边缘触发
#define EPOLL_SIZE 50
void setnonblockingmode(int fd);
void error_handling(char *buf);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len, i;
char buf[BUF_SIZE];
struct epoll_event *ep_events;
struct epoll_event event;
int epfd, event_cnt;
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
// 创建 TCP 套接字并绑定、监听(同水平触发)
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
// 创建 epoll 实例
epfd = epoll_create(EPOLL_SIZE);
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
// ========== 关键点1:监听套接字也要设为非阻塞 ==========
setnonblockingmode(serv_sock);
// 监听套接字使用水平触发(也可以边缘触发,但一般无需)
event.events = EPOLLIN; // 水平触发(默认)
event.data.fd = serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error");
break;
}
puts("return epoll_wait"); // 每次返回都打印,观察触发次数
for (i = 0; i < event_cnt; i++) {
if (ep_events[i].data.fd == serv_sock) {
// 新连接
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
// ========== 关键点2:客户端套接字设为非阻塞 ==========
setnonblockingmode(clnt_sock);
// ========== 关键点3:使用边缘触发(EPOLLET) ==========
event.events = EPOLLIN | EPOLLET;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connected client: %d\n", clnt_sock);
} else {
// 数据到达或连接关闭
// ========== 关键点4:循环读取直到 EAGAIN ==========
while (1) {
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if (str_len == 0) { // 对方关闭连接
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d\n", ep_events[i].data.fd);
break; // 退出 while 循环
} else if (str_len < 0) {
// 出错,检查 errno
if (errno == EAGAIN) {
// 没有更多数据可读,正常退出循环
break;
}
// 其他错误,退出循环并关闭
break;
} else {
// 收到数据,回声
write(ep_events[i].data.fd, buf, str_len);
}
}
}
}
}
close(serv_sock);
close(epfd);
free(ep_events);
return 0;
}
void setnonblockingmode(int fd)
{
int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
6.5 边缘触发代码的关键点总结
- 所有可能产生 EAGAIN 的套接字都必须设为非阻塞(监听套接字也最好设为非阻塞,但这里仅示范客户端)。
- 注册事件时加上
EPOLLET标志。 - 在
while循环中反复调用read,直到返回-1且errno == EAGAIN,才能保证读完所有数据。 - 处理完数据后不要忘记处理连接关闭(
str_len == 0)。
6.6 运行对比
- 水平触发:客户端发送 5 次消息,服务器端
epoll_wait会返回 5 次(每次返回后立即处理,可能因为缓冲区小而返回多次,但总的唤醒次数与数据包数相近)。 - 边缘触发:客户端发送 5 次消息,如果缓冲区较小(如 4 字节),服务器可能一次
epoll_wait返回后,在while循环中读完所有数据,后续不会再次触发。因此epoll_wait的返回次数可能少于 5 次,效率更高。
七、epoll 与 select 的性能对比
| 对比项 | select | epoll |
|---|---|---|
| 监视 fd 数量 | 通常 1024 | 无限制(受内存限制) |
| 每次调用复制集合 | 是 | 否(只在注册时复制) |
| 就绪通知方式 | 修改集合,需遍历 | 直接返回就绪列表 |
| 时间复杂度 | O(n) | O(1)(仅就绪 fd) |
| 触发模式 | 仅水平触发 | 水平触发 + 边缘触发 |
| 适用场景 | 少量连接 | 大量连接(数千、数万) |
八、总结
- epoll 是 Linux 下高性能 I/O 复用的首选方案。
- 三大核心函数:
epoll_create(创建实例)、epoll_ctl(添加/删除监视)、epoll_wait(等待事件)。 - 水平触发(LT):简单,适合大多数场景。
- 边缘触发(ET):效率更高,但必须配合非阻塞 I/O 和循环读取,编程复杂。
- 实战中,对于一般的网络服务器,水平触发 + 非阻塞 I/O 已经足够优秀;对于超高并发(如游戏服务器、网关),可以使用边缘触发。
更多推荐



所有评论(0)