epoll函数详解
先理解 epoll 的核心结构体,epoll 的事件描述依赖 epoll_data_t(共用体)和 epoll_event(结构体)
1.union epoll_data_t
用户数据共用体
typedef union epoll_data {
void *ptr; // 自定义数据指针(比如存结构体、对象地址)
int fd; // 要监听的文件描述符(最常用)
uint32_t u32; // 32位无符号整数(少用)
uint64_t u64; // 64位无符号整数(少用)
} epoll_data_t;
- 本质是 "共用体":同一时间只能用其中一个字段(因为共用同一块内存);
fd:直接存要监听的文件描述符(比如 socket 的 fd),最常用;ptr:存自定义数据(比如绑定一个包含 fd、业务信息的结构体指针),适合复杂业务场景;
- 作用:将 "监听的 fd" 与 "用户业务数据" 绑定,epoll 就绪时能直接拿到关联数据。
2. struct epoll_event
事件描述结构体
struct epoll_event {
uint32_t events; // 要监听的事件(输入)/已就绪的事件(输出)
epoll_data_t data; // 与事件绑定的用户数据(通过epoll_data_t传递)
} __EPOLL_PACKED;
events字段:用 "事件宏" 的组合表示要监听的事件(注册时),或实际就绪的事件(epoll_wait 返回时),常用宏包括:EPOLLIN:读就绪(对应 socket 接收数据、对端关闭等);EPOLLOUT:写就绪(对应 socket 发送缓冲区空闲);EPOLLERR:fd 发生错误(内核自动检测,无需手动注册);EPOLLHUP:fd 被挂断(如 TCP 对端关闭,内核自动检测);EPOLLET:边缘触发模式(Edge Triggered,epoll 的高效模式);EPOLLONESHOT:仅监听一次事件(触发后需重新注册才能再次监听);
data字段:关联的用户数据(即上面的epoll_data_t),epoll 就绪时会原封不动返回该数据,方便业务层关联 fd 或自定义信息。
3. epoll_create:
#include <sys/epoll.h>
int epoll_create(int size);
- 作用:在内核中创建一个 epoll 实例(管理监听 fd 和事件的内核对象),返回该实例的句柄(文件描述符)。
- 参数
size:- 早期内核(<2.6.8):表示 "预计监听的 fd 数量",内核据此分配资源;
- 现代内核(≥2.6.8):该参数被忽略,传任意正整数即可(习惯传 1);
- 返回值:
- 成功:返回 epoll 实例的句柄(正整数,如 5、6);
- 失败:返回 - 1,
errno存错误原因(如ENOMEM:内核内存不足);
- 注意 :epoll 实例是一个文件描述符,用完必须调用
close(epfd)关闭,否则会造成文件描述符泄漏。
4.epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 作用:向 epoll 实例(
epfd)中注册 / 修改 / 删除某个 fd 的监听事件。 - 参数详解:
epfd:epoll_create返回的 epoll 实例句柄;op:表示要执行的动作,用 3 个宏表示:EPOLL_CTL_ADD:向 epoll 实例中注册新的 fd 及事件;EPOLL_CTL_MOD:修改已注册 fd 的监听事件(如从 EPOLLIN 改为 EPOLLOUT);EPOLL_CTL_DEL:从 epoll 实例中删除某个 fd 的监听(删除后不再监控该 fd);
fd:要监听的文件描述符;event:指向struct epoll_event的指针,描述 "要监听的事件 + 关联的用户数据";- 当
op=EPOLL_CTL_DEL时,event可以传NULL(因为删除不需要事件信息);
- 当
- 返回值:
- 成功:返回 0;
- 失败:返回 - 1,
errno存错误原因(如EBADF:fd 无效;EEXIST:op=ADD时 fd 已注册);
5.epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 作用 :阻塞等待 epoll 实例中监听的 fd 就绪,将已就绪的事件收集到
events数组中。 - 参数详解 :
epfd:epoll 实例句柄;events:用户态预先分配好的struct epoll_event数组,内核会将 "已就绪的事件" 填充到这个数组中;maxevents:events数组的长度(即最多能接收多少个就绪事件),不能超过epoll_create的size(虽然现代内核忽略 size,但习惯上保持一致);timeout:超时时间(单位:毫秒):timeout = -1:永久阻塞,直到有 fd 就绪;timeout = 0:不阻塞,立即返回(仅检查当前就绪状态);timeout > 0:阻塞timeout毫秒,超时后返回;
- 返回值 :
- 成功:返回已就绪的 fd 数量(正整数,≤maxevents);
- 超时:返回 0(
timeout时间内无 fd 就绪); - 失败:返回 - 1,
errno存错误原因(如EINTR:调用被信号中断);
epoll 的工作原理

1. struct eventpoll
当调用epoll_create时,内核会创建一个eventpoll结构体,它是 epoll 的核心管理对象,包含两个关键成员:
struct eventpoll {
struct rb_root rbr; // 红黑树的根节点:管理所有"被监控的fd+事件"
struct list_head rdlist; // 双向链表:存放"已就绪的fd+事件"
// 其他成员(锁、等待队列等):保证线程安全和阻塞逻辑
};
rbr(红黑树):存储所有通过epoll_ctl注册的 "fd + 事件",作用是高效管理监控对象(去重、插入 / 删除 / 查找的时间复杂度为O(logn))。rdlist(双向链表):存储 "已就绪的 fd + 事件",作用是快速返回就绪结果(epoll_wait直接读取链表,无需遍历所有监控对象)。
2. struct epitem
每个被 epoll 监控的 fd,都会对应一个epitem结构体(你提供的示意图中红黑树 / 双向链表的节点),它是红黑树和双向链表的 "共用节点":
struct epitem {
struct rb_node rbn; // 红黑树节点:挂到`eventpoll->rbr`上,管理监控关系
struct list_head rdllink; // 双向链表节点:事件就绪时,挂到`eventpoll->rdlist`上
struct epoll_filefd ffd; // 存储要监控的fd(比如socket的fd)
struct eventpoll *ep; // 指向所属的`eventpoll`实例(关联到epoll句柄)
struct epoll_event event; // 存储用户注册的事件类型(如EPOLLIN)和用户数据
};
rbn:让epitem成为红黑树的节点,实现对监控 fd 的高效管理;rdllink:让epitem成为双向链表的节点,事件就绪时快速加入就绪队列;event:关联用户注册的事件(如 "读就绪")和自定义数据(对应epoll_data_t)。
3.epoll 工作的完整流程
epoll 的工作分为创建 epoll 实例→注册监控事件→事件就绪触发→等待并获取就绪事件四个核心阶段:
阶段 1:epoll_create------ 创建 epoll 实例
进程调用epoll_create(int size)时:
- 内核创建一个
eventpoll结构体,初始化其成员:rbr:红黑树的根节点设为空(表示暂无监控对象);rdlist:双向链表的头节点初始化(表示暂无就绪事件);
- 内核为这个
eventpoll结构体分配一个文件描述符(即epoll_create的返回值epfd); - 后续
epoll_ctl/epoll_wait都通过epfd关联到这个eventpoll实例。
阶段 2:epoll_ctl------ 注册 / 修改 / 删除监控事件
以最常用的EPOLL_CTL_ADD(注册新 fd)为例:
- 进程传入参数:
epfd(关联eventpoll)、要监控的fd、struct epoll_event(事件类型 + 用户数据); - 内核创建
epitem结构体:- 填充
epitem->ffd:存入要监控的fd; - 填充
epitem->event:存入用户注册的事件(如 EPOLLIN)和数据; - 填充
epitem->ep:指向所属的eventpoll实例;
- 填充
- 红黑树去重检查:
- 内核通过
fd在eventpoll->rbr红黑树中查找:若已存在该 fd 的epitem,则返回错误(避免重复注册);
- 内核通过
- 插入红黑树:
- 将
epitem通过rbn节点插入eventpoll->rbr红黑树,完成监控对象的注册;
- 将
- 注册驱动回调:
- 内核向
fd对应的设备驱动(比如 socket 对应网卡驱动)注册一个回调函数ep_poll_callback,告诉驱动:"当这个 fd 有事件发生时,调用这个函数"。
- 内核向
阶段 3:事件就绪 ------ 驱动主动触发回调,加入就绪链表
当被监控的 fd 满足事件条件(比如 socket 收到数据)时:
- 设备驱动(如网卡驱动)检测到 fd 就绪(比如接收缓冲区有数据);
- 驱动调用之前注册的
ep_poll_callback回调函数; ep_poll_callback执行逻辑:- 通过
fd找到对应的epitem结构体; - 将
epitem通过rdllink节点,添加到eventpoll->rdlist双向链表中(此时该 fd 的事件被标记为 "就绪")。
- 通过
阶段 4:epoll_wait------ 等待并获取就绪事件
当进程调用epoll_wait(epfd, events, maxevents, timeout)时:
- 内核通过
epfd找到对应的eventpoll实例; - 检查
eventpoll->rdlist双向链表:- 若链表为空 :根据
timeout参数处理:timeout = -1:进程进入阻塞状态,挂到eventpoll的等待队列中,直到有事件加入rdlist;timeout = 0:直接返回 0(表示无就绪事件);timeout > 0:进程阻塞timeout毫秒,超时后返回 0;
- 若链表非空:
- 遍历
rdlist中的epitem,将每个epitem->event(事件类型 + 用户数据)复制到用户态传入的events数组中; - 复制完成后,返回就绪事件的数量(即
rdlist中epitem的个数);
- 遍历
- 若链表为空 :根据
- 注意:
epoll_wait不会清空rdlist,后续新的就绪事件会继续追加到链表中。
4.epoll 高效的本质
epoll 比 select/poll 高效的核心原因,是它从 "主动轮询" 变成了 "事件驱动",结合数据结构的优化:
- 红黑树管理监控对象 :替代了 select 的位图、poll 的数组,实现监控 fd 的高效增删改查 (时间复杂度
O(logn)),同时自动去重; - 双向链表管理就绪事件 :
epoll_wait无需遍历所有监控 fd,只需直接读取就绪链表,时间复杂度 **O(1)**; - 驱动回调的事件驱动 :内核无需主动轮询 fd 是否就绪,而是由设备驱动在事件发生时主动调用回调,将就绪事件加入链表 ------ 彻底避免了 select/poll 的 "全量遍历" 开销。
epoll 的 LT/ET 模式,核心差异是 "fd 就绪后,epoll 以什么样的规则向程序发送通知":
- LT:"状态驱动"------ 只要 fd 处于 "就绪状态",就持续通知;
- ET:"事件驱动"------ 仅当 fd 的 "就绪状态从无到有" 时,通知一次
水平触发LT
Level Triggered:epoll 默认模式
当 epoll 检测到 fd 的 "就绪状态存在" 时(比如 socket 接收缓冲区有数据),会持续触发通知(epoll_wait 反复返回该 fd),直到就绪状态消失(比如缓冲区数据被读完)。
关键特性
- 支持阻塞 / 非阻塞读写:哪怕用阻塞 read,没读完数据也不会卡死(因为 epoll_wait 会反复提醒,下次还能读);
- 容错性高,代码简单:新手友好,无需担心 "数据没读完导致丢失";
- 通知次数多:fd 就绪状态持续时,epoll_wait 会频繁返回,高并发下略有性能开销。
边缘触发ET
Edge Triggered:高性能模式(需手动设置EPOLLET)
仅当 fd 的 "就绪状态发生边缘变化(从无到有 )" 时,epoll 才触发一次通知;即使就绪状态持续(比如数据没读完),也不会再次通知,直到下一次状态变化。
关键特性
- 仅支持非阻塞读写(工程实践强制要求):这是 ET 模式最核心的易错点,下文单独拆解;
- 通知次数极少:仅在 "状态变化" 时通知,高并发下性能远高于 LT;
- 代码复杂度高:必须保证 "一次通知内处理完所有就绪数据",否则数据会丢失。
ET 为什么必须用非阻塞 fd
不是 epoll 接口要求,而是工程实践的硬性规则:
1.问题根源:阻塞读写会导致程序丢失
- 场景:socket 缓冲区有 1.5KB 数据,程序调用
read(fd, buf, 1024)只读了 1KB,假如之后再也没有数据到来,那么这0.5kb数据你就读不到了,数据丢失在某些场景(银行交易记录)中可是重错; - 即使你说自己每次都循环读取直到数据读完,但是阻塞模式下数据读完了可就直接卡在read函数了,那你整个程序就阻塞了。
2. 解决方案:非阻塞 + 循环读 / 写
- 步骤 1:将 fd 设置为非阻塞(通过
fcntl); - 步骤 2:收到 ET 通知后,循环调用 read/write ,直到返回
EAGAIN/EWOULDBLOCK(表示缓冲区已空,无数据可读写)。
示例代码
设置event时使用 ev.events = EPOLLIN | EPOLLET;即可开启ET模式
bool Add(const TcpSocket& sock, bool epoll_et = false) const {
int fd = sock.GetFd();
printf("[Epoll Add] fd = %d\n", fd);
epoll_event ev;
ev.data.fd = fd;
if (epoll_et) {
ev.events = EPOLLIN | EPOLLET;
} else {
ev.events = EPOLLIN;
}
int ret = epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &ev);
if (ret < 0) {
perror("epoll_ctl ADD");
return false;
}
return true;
}
补充1:select/poll 的工作模式
select 和 poll本质上都是 LT 模式------ 它们没有 "边缘触发" 的机制,只要 fd 处于就绪状态,每次调用 select/poll 都会返回该 fd,直到数据处理完;这也是 epoll 的灵活性所在:既兼容 select/poll 的 LT 模式,又提供高性能的 ET 模式。
补充2:为什么ET高效
1.ET没有做重复通知
2.ET模式强制要求了程序员每次要循环读取完数据,但是LT阻塞模式不需要做这种要求,所以LT下程序员可以每次不读取完,反正后面还会接着通知他,所以LT模式下程序员写出的代码读取就可能没有ET下写的读取代码读的快;读的快你的缓冲区就会有更大空间;有更大空间双方IO就可以发送更多数据,自然效率就高了。
可读可写的区别处理
epoll 监听的就绪状态,可读和可写的触发条件、常态相反,这是两者监听策略不同的根本原因:
| 状态 | 触发条件 | 就绪状态的 "常态" | 持续监听的后果 |
|---|---|---|---|
| 可读(EPOLLIN) | socket 接收缓冲区有数据 / 对端关闭 | 偶发态(大部分时间无数据) | 正常,epoll_wait 仅在有数据时返回 |
| 可写(EPOLLOUT) | socket 发送缓冲区有空闲空间(可写) | 常态(大部分时间有空闲) | 灾难性:epoll_wait 高频返回,CPU 100% |
为什么可写状态不能 "一直开启监听"?
原因:socket 发送缓冲区的 "可写状态" 是默认常态(除非你在高并发写数据,把缓冲区写满)。
简单说:持续监听EPOLLOUT,相当于让 epoll "无意义地高频返回",完全违背 epoll"事件驱动、低开销" 的设计初衷。
工程上正确的可写监听策略
实际开发中,EPOLLOUT的监听遵循 "按需注册、用完即撤" 的原则 ------ 只有需要写数据时才注册,写完立刻取消,具体分 3 步:
步骤 1:默认仅监听可读(EPOLLIN)
程序初始化时,给 socket 注册的事件只有EPOLLIN(+EPOLLET,如果用 ET 模式),专注处理 "有数据要读" 的场景:
// 初始化:仅监听可读
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // LT模式去掉EPOLLET
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
步骤 2:需要写数据时,临时注册可写(EPOLLOUT)
当程序要给客户端发送数据(比如响应 HTTP 请求),先尝试非阻塞写:
-
如果一次写完所有数据:无需注册
EPOLLOUT; -
如果没写完(返回
EAGAIN,表示缓冲区满了):立刻注册EPOLLOUT,等待缓冲区空闲后继续写;// 尝试写数据
ssize_t n = write(sockfd, buf, buf_len);
if (n > 0) {
buf += n;
buf_len -= n;
}// 没写完(缓冲区满),注册EPOLLOUT
if (buf_len > 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLOUT | EPOLLET; // 临时加EPOLLOUT
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
// 保存未写完的数据,后续处理
save_remaining_data(sockfd, buf, buf_len);
}
步骤 3:可写事件触发后,写完数据立刻取消 EPOLLOUT
当epoll_wait返回EPOLLOUT时,循环非阻塞写剩余数据,写完后立刻修改事件,去掉EPOLLOUT:
// epoll_wait返回后,处理可写事件
if (events[i].events & EPOLLOUT) {
// 循环非阻塞写剩余数据
while (buf_len > 0) {
ssize_t n = write(sockfd, buf, buf_len);
if (n > 0) {
buf += n;
buf_len -= n;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 还没写完,继续等下一次可写(但此时仍注册EPOLLOUT)
break;
} else {
// 出错,关闭连接
close(sockfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
return;
}
}
// 数据全部写完,取消EPOLLOUT监听
if (buf_len == 0) {
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 恢复仅监听可读
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
}
}