1. epoll概念
epoll 是 Linux 2.5.44 引入的高性能多路 I/O 就绪事件通知机制,是 select/poll 的升级版,专门解决大规模文件描述符(fd)场景下的效率问题,被公认为 Linux 2.6+ 性能最优的多路 I/O 就绪通知方案
2. epoll 三大核心接口
epoll 提供了 3 个关键系统调用,对应「创建实例→管理事件→等待就绪」的完整流程:
2.1 epoll_create
cpp
int epoll_create(int size);
- 作用:在内核创建一个 epoll 模型(本质是struct eventpoll 结构),返回一个代表该实例的文件描述符epfd
- 说明:size早期用于指定监听 fd 数量上限,现在内核自动管理,仅需传大于 0 的值即可
- 用完之后,必须调用close()关闭
2.2 epoll_ctl
cpp
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
作用:
- 用户进程向内核「注册 / 修改 / 删除」要监听的 fd 及其事件,是用户与内核交互的核心接口
参数说明:
- epfd:epoll_create返回的 epoll 实例句柄。
- op:操作类型,用三个宏来表示
- fd:要监听的目标文件描述符(如listensocket)
- event:struct epoll_event结构体,描述监听事件 ,是单个事件**(添加 / 修改 / 删除 fd 时,必须传入一个全新配置好的 epoll_event 结构),**总结来说就是想要内核帮忙监听哪个fd,就往epoll_event.events里面放什么
op操作说明:
- EPOLL_CTL_ADD:注册新的fd到epfd中
- EPOLL_CTL_MOD:修改已注册 fd 的监听事件
- EPOLL_CTL_DEL:从epfd中删除一个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_data_t data; /* 用户自定义数据,内核不修改,仅原样返回 */
};
events常用宏定义(其中读写最常用):
- EPOLLIN:fd 可读(含对端正常关闭)
- EPOLLOUT:fd 可写
- EPOLLERR:fd 发生错误
- EPOLLHUP:fd 被挂断
- EPOLLET:边缘触发模式(Edge Triggered)
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个事件加入到EPOLL红黑树里
2.3 epoll_wait
cpp
int epoll_wait(int epfd, struct epoll_event *events, int maxevents,
int timeout);
作用:
- 收集在epoll监控的事件中已经发送的事件,通知用户进程处理
参数说明:
- epfd:epoll 实例句柄
- events:epoll将会把发生的事件赋值到用户提供的events数组中,内核会按从 0 开始的下标依次保存所有就绪事件
- maxevents数组最大长度,maxevents的值不能大于创建epoll_create()时的 size
- imeout:超时时间(毫秒,-1 表示阻塞等待,0 表示非阻塞)
返回值:
如果函数调永成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时,返回小于0 表示函数失败
对比一下ctl和wait的epoll_event结构体中events作用:
- ctl:用来添加想让内核监视的fd**(用户-->内核)**
- wait:用来存放所有就绪的事件**(内核-->用户)**
3. epoll 核心工作原理
epoll 的性能优势,源于内核中「红黑树 + 就绪队列 」的双数据结构设计,以及「底层回调机制」

3.1 内核三大核心结构体
1. struct eventpoll(epoll 总管理器)
创建实例epoll_create后内核自动分配eventpoll对象,初始化以下结构
cpp
struct eventpoll {
struct rb_root rbr; // 红黑树根节点:存放所有监听fd
struct list_head rdllist; // 就绪双向链表:存放已触发事件fd
wait_queue_head_t wq; // epoll_wait 阻塞等待队列
// ...
};
- 红黑树rbr:存储所有用户通过epoll_ctl 注册的 fd 和事件
- 就绪链表rdllist :存储所有已就绪的 fd(由内核自动添加),epoll_wait 只需检查该队列即可,检查时时间复杂度为 O(1) ,获取就绪事件时时间复杂度为O(N)
- wq:进程调用 epoll_wait 无事件时,在此休眠
2. struct epitem(epoll_entry 监听条目)
每一个被监听的 fd 对应一个epitem
cpp
struct epitem {
struct rb_node rbn; // 红黑树节点
struct list_head rdllink; // 就绪链表节点
struct epoll_filefd ffd; // 存储fd和file结构体
struct eventpoll *ep; // 归属哪个epoll实例
struct epoll_event event; // 用户监听事件 IN/OUT/ERR
struct list_head pwqlist; // 挂载等待队列项
};
- 红黑树节点epitem里ffd 字段负责指向 fd 和 struct file
3. struct eppoll_entry(等待队列回调载体)
绑定fd自带的内核等待队列,挂载回调函数:ep_poll_callback
4. epoll使用完整过程
步骤一:创建 epoll 实例 epoll_create
- 内核分配一个eventpoll对象(初始化epoll 实例本体:红黑树、就绪队列、锁等)
- 分配一个struct file,并把它的 private_data指向这个 eventpoll
- 从当前进程的 fd 表中找一个空闲fd项,把这个 struct file 挂上去,把这个fd返回给用户态**(这里解释为什么epoll_create要返回一个fd,而后续函数都需要这个fd,因为靠fd找到红黑树、就绪队列等)**
步骤二:添加监听 fd epoll_ctl (EPOLL_CTL_ADD)
- 用户传入 fd、监听事件
- 内核创建 epitem对象,填充 fd、事件,将epitem插入 eventpoll 红黑树中保存
- 拿到该 fd 内核自带的等待队列
- 创建eppoeppoll_entry,挂载回调ep_poll_callback(把 eppoll_entry 挂入 fd 等待队列作用:让内核知道:这个 fd 有事发生,就调用回调通知 epoll)
步骤三:内核事件触发(数据到来)
- socket / 文件 收到数据、可写、异常
- 内核唤醒该 fd等待队列
- 执行回调函数ep_poll_callback
- 核心动作:
- 把当前epitem从红黑树摘出
- 加入 eventpoll 的 rdllist 就绪链表
- 唤醒阻塞在 epoll_wait 的进程
步骤四:epoll_wait 获取就绪事件
- 检测rdllist 就绪链表是否为空
- 为空 → 进程休眠,等待事件唤醒
- 不为空 → 遍历就绪链表
- 把就绪事件拷贝到用户态数组
- 返回就绪数量,用户只处理有事件 fd
4. 实现epoll echo服务
EpollServer.hpp
cpp
#include <iostream>
#include <memory>
#include <sys/select.h>
#include <sys/epoll.h>
#include "Socket.hpp"
using namespace SocketModule;
using namespace LogModule;
class EpollServer
{
public:
const static int size = 64;
const static int defaultfd = -1;
EpollServer(int port)
: _listensock(std::make_unique<TcpSocket>()), _isrunning(false), _epfd(defaultfd)
{
// 1. 创建listensocket
_listensock->BuildTcpSocketMethod(port);
// 2. 创建epoll模型
_epfd = epoll_create(256);
if (_epfd < 0)
{
LOG(LogLevel::ERROR) << "epoll_create error";
exit(EPOLL_CREATE_ERR);
}
// 成功创建
LOG(LogLevel::DEBUG) << "epoll create success,epfd: " << _epfd;
// 3. 将listensocket设置到内核中
struct epoll_event ev;
ev.data.fd = _listensock->Fd(); // TODO : 这里未来是维护的是用户的数据,后面要用,常见的是fd
ev.events = EPOLLIN;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Fd(), &ev);
if (n < 0)
{
LOG(LogLevel::FATAL) << "add listensockfd failed";
exit(EPOLL_CTL_ERR);
}
}
void Start()
{
_isrunning = true;
int timeout = -1;
while (_isrunning)
{
// 从就绪队列里拿就绪事件
int n = epoll_wait(_epfd, _revs, size, timeout);
if (n < 0)
{
LOG(LogLevel::ERROR) << "epoll error";
continue;
}
else if (n == 0)
{
LOG(LogLevel::DEBUG) << "timeout...";
continue;
}
else
{
// 有就绪连接
Dispatcher(n);
continue;
}
}
_isrunning = false;
}
void Dispatcher(int nums)
{
LOG(LogLevel::DEBUG) << "event ready ...";
// // epoll也要循环处理就绪事件--有可能有多个fd就绪
for (int i = 0; i < nums; i++)
{
int sockfd = _revs[i].data.fd;
if (_revs[i].events & EPOLLIN)
{
// 读就绪
if (sockfd == _listensock->Fd())
{
// 说明是新来的连接
Accepter();
}
else
{
// 派发任务
Recver(sockfd);
}
}
// if(_revs[i].events & EPOLLOUT)
// {// 写事件就绪
// }
}
}
void Accepter()
{
InetAddr client;
int sockfd = _listensock->Accept(&client);
if (sockfd < 0)
{
LOG(LogLevel::ERROR) << "accept error";
}
else
{
// accept到新连接,把新fd交给epoll
LOG(LogLevel::INFO) << "get a new link, sockfd: "
<< sockfd << ", client is: " << client.StringAddr();
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (n < 0)
{
LOG(LogLevel::FATAL) << "add listensockfd failed";
exit(EPOLL_CTL_ERR);
}
else
{
LOG(LogLevel::INFO) << "epoll_ctl add sockfd success: " << sockfd;
}
}
}
void Recver(int sockfd)
{
char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << "client say@ " << buffer << std::endl;
}
else if (n == 0)
{
LOG(LogLevel::INFO) << "client quit...";
// 1. 从epoll中移除fd的关心 && 关闭fd
// 细节:epoll_ctl: 只能移除合法fd -- 先移除,在关闭!!
int m = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (m > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
}
// 2. 关闭
close(sockfd);
}
else
{
LOG(LogLevel::ERROR) << "recv error";
// 1. 从epoll中移除fd的关心 && 关闭fd
// 细节:epoll_ctl: 只能移除合法fd -- 先移除,在关闭!!
int j = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (j > 0)
{
LOG(LogLevel::INFO) << "epoll_ctl remove sockfd success: " << sockfd;
}
// 2. 关闭
close(sockfd);
}
}
void Stop()
{
_isrunning = false;
}
~EpollServer()
{
_listensock->Close();
if (_epfd > 0)
{
close(_epfd);
}
}
private:
std::unique_ptr<Socket> _listensock;
bool _isrunning;
int _epfd;
// 错错错!! 不能全程使用一个ev
// 添加 / 修改 / 删除 fd 时,必须传入一个全新配置好的 epoll_event 结构
// struct epoll_event ev; // ctl_ev
struct epoll_event _revs[size]; // epoll_revs_event
};
5. epoll的优点
| 对比维度 | select/poll | epoll |
|---|---|---|
| 接口易用 | 每次循环都要重置 / 拷贝关注的文件描述符,输入输出不分离 | 接口拆分成三个函数,不需要每次循环重置关注的文件描述符,也做到了输入输出参数分离 |
| 数据拷贝开销 | 每次调用都要把文件描述符列表拷贝到内核 | 仅在调用EPOLL_CTL_ADD时拷贝一次,后续操作无频繁拷贝 |
| 事件就绪效率 | 遍历所有文件描述符判断是否就绪,时间复杂度 O (n) | 内核通过回调机制将就绪事件加入就绪队列,epoll_wait 直接访问,时间复杂度 O (1) |
| 文件描述符数量 | 有数量上限(select 默认 1024) | 无数量上限,仅受系统资源限制 |
注意:
内存映射误区:很多博客说 epoll 用了 mmap 内存映射,这种说法不准确,用户态定义的struct epoll_event是在用户空间中分配好的内存,仍需内核拷贝数据到用户空间,并非零拷贝
对比select,poll,epoll之间优点和缺点,十分重要!!
6. epoll工作方式
Epoll 支持两种触发模式:水平触发 Level Triggered工作模式和边缘触发EdgeTriggered工作模式,决定了事件通知的行为逻辑,是面试高频考点
6.1 场景
socket 收到 2KB 数据 → epoll_wait 返回可读 → 你只读了 1KB,还剩 1KB
1.LT 水平触发模式(默认)
- 第二次调用epoll_wait
- 立刻返回,继续告诉你:这个 socket 还有数据可读
- 直到你把剩下 1KB 全部读完,epoll_wait 才不再通知
- **就像:**没读完也没关系,下次还会提醒你
2.ET 边缘触发模式(加了EPOLLET)
- 如果在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志,epoll进入ET工作模式
- 第二次调用epoll_wait
- 阻塞 / 不返回,再也不提醒你
- 哪怕缓冲区还剩 1KB 数据,也不会再触发事件
- 就像:只通知一次,你必须一次性把数据读完,否则剩下的数据会 "卡住"
select和poll也是工作在LT模式下,epoll既可以支持LT,也可以支持ET,这也是 epoll 高性能的核心原因之一
6.2 对比LT和ET
| 特性 | LT 水平触发(默认) | ET 边缘触发 |
|---|---|---|
| 触发时机 | 只要就绪就一直触发 | 仅状态变化时触发一次 |
| 处理要求 | 可以分次处理,不用一次读完 | 必须一次读完 / 写完所有数据 |
| IO 模式 | 阻塞 / 非阻塞 都支持 | 必须用非阻塞 IO |
| 性能 | 较低(epoll_wait 调用多) | 更高(系统调用少) |
| 常用场景 | 简单程序、初学者 | Nginx、Redis 等高并发服务器 |
7. 为什么 ET 必须用非阻塞 IO?
ET 模式要求 FD 设为非阻塞,不是内核接口强制规定,是工程实践必做规范
7.1 阻塞IO的缺陷
阻塞read 无法一次性读完所有数据:
- 可能被系统信号中断中断读取
- 内核缓冲区数据分批就绪导致单次读取必然读不全
7.2 死锁阻塞场景(死循环)
- 客户端规则:收不到服务端应答,绝不发新请求
- 服务端规则:读完完整 10K 请求,才返回应答
- 服务端用阻塞 read,单次仅读 1K,剩余 9K 滞留缓冲区。又触发了ET 特性:仅新数据抵达时触发一次可读事件,存量残留数据不再触发
- 结果:epoll_wait 永久阻塞,读不到剩余 9K → 凑不齐完整请求 → 服务端不回应答,调用不到read → 客户端不发新数据 → 永远无新事件触发,程序卡死死锁
那假设强行在事件里面写循环拿取缓冲区数据呢?
虽然可以读完所有残留数据,但此时缓冲区为空了,**没数据 → 立刻把进程等待挂起(休眠),这是OS的设计规则,**所以阻塞 read 会直接挂起卡死程序
非阻塞情况为什么不挂起? 没数据 → 直接返回 -1,不会挂起、卡住,直接告诉用户:EAGAIN(没数据了) → 退出循环 → 程序正常继续
7.3 总结
所以,为了解决上述问题 (阻塞 read 不一定能一下把完整的请求读完),于是就可以使用非阻塞轮询的方式来读缓冲区,保证一定能把完整的请求都读出来
如果是 LT 没这个问题,只要缓冲区中的数据没读完,就能够让 epoll_wait 返回文件描述符读就绪
7.4 面试高频问题补充
- 为什么 ET 模式必须用非阻塞 fd? 因为 ET 模式下,你需要循环
recv直到EAGAIN,如果是阻塞 fd,缓冲区没数据时recv会阻塞,导致整个事件循环卡死。 - **LT 模式可以做到和 ET 一样高效吗?**可以,但需要手动一次性处理完所有数据,否则 LT 会一直通知,效率反而更低。ET 是通过内核强制约束程序员这么做,所以天然更高效。
- **Epoll 为什么比 select 高效?**核心原因:① 无需每次拷贝 fd 列表;② 就绪事件通过回调直接通知,O (1) 获取就绪 fd;③ 无数量上限。
7.5 epoll 的使用场景
epoll 的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。
- 对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。例如,典型的一个需要处理上万个客户端的服务器,例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll
- 如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用 epoll 就并不合适。具体要根据需求和场景特点来决定使用哪种 IO 模型