三、epoll
3.1 认识
epoll是"为处理大批量句柄而作了改进的poll"。epoll提供了三个系统调用,各司其职:
| 系统调用 | 作用 |
|---|---|
| epoll_create | 创建一个epoll句柄 |
| epoll_ctl | 注册/修改/删除要监听的事件 |
| epoll_wait | 等待就绪事件 |
3.2 系统调用
(1)epoll_create
创建一个epoll句柄
cpp
int epoll_create(int size);
自Linux 2.6.8之后,size参数被忽略。用完后必须调用close()关闭。
(2)epoll_ctl
事件注册函数
cpp
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
不同于select在监听时才告诉内核要监听什么,epoll在这里先注册要监听的事件类型。
-
epfd:epoll_create的返回值
-
op:动作类型,用三个宏表示
-
EPOLL_CTL_ADD:注册新fd到epfd
-
EPOLL_CTL_MOD:修改已注册fd的监听事件
-
EPOLL_CTL_DEL:从epfd中删除fd
-
fd:需要监听的文件描述符
-
event:告诉内核需要监听什么事件
(3)epoll_wait
收集已发生的就绪事件
cpp
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
-
events:分配好的epoll_event结构体数组,内核将事件复制到这里
-
maxevents:events数组大小,不能大于epoll_create时的size
-
timeout:超时时间,0立即返回,-1永久阻塞
-
返回值:已就绪的描述符数目,0表示超时,< 0表示失败
3.3 epoll_event结构
epoll_event结构体:

宏的集合:
| 宏 | 含义 |
|---|---|
| EPOLLIN | 对应文件描述符可读 |
| EPOLLOUT | 对应文件描述符可写 |
| EPOLLPRI | 有紧急数据可读 |
| EPOLLERR | 文件描述符发生错误 |
| EPOLLHUP | 文件描述符被挂断 |
| EPOLLET | 设为边缘触发模式 |
| EPOLLONESHOT | 只监听一次,监听完后需再次加入 |
3.4 工作原理
当进程调用epoll_create时,Linux内核会创建一个eventpoll结构体:
cpp
struct eventpoll {
/* 红黑树根节点,存储所有添加到epoll中需要监控的事件 */
struct rb_root rbr;
/* 双向链表,存放将要通过epoll_wait返回给用户的就绪事件 */
struct list_head rdlist;
};
每一个添加到epoll中的事件,都会建立一个epitem结构体:
cpp
struct epitem {
struct rb_node rbn; // 红黑树节点
struct list_head rdllink; // 双向链表节点
struct epoll_filefd ffd; // 事件句柄信息
struct eventpoll *ep; // 所属的eventpoll对象
struct epoll_event event; // 期待的事件类型
};
整体结构:

工作流程:
-
注册阶段:通过epoll_ctl将fd加入红黑树,同时与设备驱动建立回调关系
-
事件触发:当fd上有事件发生时,驱动调用回调方法ep_poll_callback,将对应epitem加入rdlist双向链表
-
获取就绪:epoll_wait只需检查rdlist是否为空,非空则将事件复制到用户态并返回,时间复杂度O(1)
注意:网上有些资料称epoll使用了内存映射机制来避免拷贝,这种说法不准确。
3.5 优点
与select/poll的缺点一一对应,epoll的优势体现在:
-
接口使用方便:虽拆分为三个函数,但无需每次循环重新设置关注的fd,输入输出参数分离
-
数据拷贝轻量:仅在EPOLL_CTL_ADD时将描述符结构拷贝到内核,操作不频繁;而select/poll每次循环都要拷贝
-
事件回调机制:不使用遍历,而是通过回调函数将就绪描述符加入就绪队列,epoll_wait直接访问就绪队列,时间复杂度O(1)
-
无数量限制:文件描述符数目无上限
3.6 LT与ET
epoll支持水平触发和边缘触发两种模式。用一个通俗的例子来说明:
你正在打游戏进入决赛圈,你妈饭做好了喊你吃饭:
水平触发:喊你一次你没动,她会继续喊第二次、第三次......直到你去吃
边缘触发:喊你一次你没动,她就不管你了
(1)水平触发(LT): epoll默认模式。
-
检测到socket事件就绪时,可以不立刻处理,或只处理一部分
-
只要缓冲区中还有数据未处理,下次epoll_wait仍会立刻返回并通知就绪
-
支持阻塞读写和非阻塞读写
(2)边缘触发(ET): 需在注册时设置EPOLLET标志。
-
事件就绪后必须立刻处理,只有一次处理机会
-
即使缓冲区还有数据未读完,下次epoll_wait也不会再返回
-
性能比LT更高,Nginx默认采用ET模式
-
只支持非阻塞读写
select和poll实际上也工作在LT模式下,而epoll是唯一支持ET模式的方案。
3.7 问题
**ET模式为何必须非阻塞?**理解这个问题需要看一个具体场景。
假设服务器接收一个10k的请求,读完后向客户端返回应答,客户端收不到应答就不会发送下一个请求。
正常的请求-应答时序:

如果服务端使用阻塞式read,且一次只读了1k数据:

此时由于epoll处于ET模式,不会再认为该fd读就绪,epoll_wait不会再次返回,剩下的9k数据一直留在缓冲区中。
死锁场景:

-
服务器只读到1k,需读完10k才返回响应
-
客户端要收到响应才发下一个请求
-
客户端发下一个请求,epoll_wait才会返回,才能读剩余9k
三者形成循环等待,解决方案是使用非阻塞轮询方式读取,确保一次把缓冲区数据读完。
而LT模式没有这个问题------只要缓冲区数据没读完,epoll_wait就会持续返回读就绪。
3.8 代码示例
cpp
#pragma once
#include <vector>
#include <functional>
#include <sys/epoll.h>
#include "tcp_socket.hpp"
typedef std::function<void (const std::string&, std::string* resp)> Handler;
class Epoll {
public:
Epoll() {
epoll_fd_ = epoll_create(10);
}
~Epoll() {
close(epoll_fd_);
}
bool Add(const TcpSocket& sock) const {
int fd = sock.GetFd();
epoll_event ev;
ev.data.fd = fd;
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;
}
bool Del(const TcpSocket& sock) const {
int fd = sock.GetFd();
int ret = epoll_ctl(epoll_fd_, EPOLL_CTL_DEL, fd, NULL);
if (ret < 0) {
perror("epoll_ctl DEL");
return false;
}
return true;
}
bool Wait(std::vector<TcpSocket>* output) const {
output->clear();
epoll_event events[1000];
int nfds = epoll_wait(epoll_fd_, events,
sizeof(events) / sizeof(events[0]), -1);
if (nfds < 0) {
perror("epoll_wait");
return false;
}
// 注意:循环到nfds即可,不需要遍历全部
for (int i = 0; i < nfds; ++i) {
TcpSocket sock(events[i].data.fd);
output->push_back(sock);
}
return true;
}
private:
int epoll_fd_;
};
服务器主循环:
cpp
class TcpEpollServer {
public:
bool Start(Handler handler) {
TcpSocket listen_sock;
listen_sock.Socket();
listen_sock.Bind(ip_, port_);
listen_sock.Listen(5);
Epoll epoll;
epoll.Add(listen_sock);
for (;;) {
std::vector<TcpSocket> output;
if (!epoll.Wait(&output)) continue;
for (size_t i = 0; i < output.size(); ++i) {
if (output[i].GetFd() == listen_sock.GetFd()) {
TcpSocket new_sock;
listen_sock.Accept(&new_sock);
epoll.Add(new_sock);
} else {
std::string req, resp;
bool ret = output[i].Recv(&req);
if (!ret) {
epoll.Del(output[i]);
output[i].Close();
continue;
}
handler(req, &resp);
output[i].Send(resp);
}
}
}
return true;
}
private:
std::string ip_;
uint16_t port_;
};
四、三者对比
| 对比维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set位图 | pollfd结构体数组 | 红黑树+就绪链表 |
| 最大描述符数 | 通常1024 | 无硬性限制 | 无限制 |
| 每次调用是否需重置 | 是 | 否 | 否 |
| 用户态/内核态拷贝 | 每次调用都拷贝整个fd_set | 每次调用都拷贝整个pollfd数组 | 仅在epoll_ctl时拷贝,epoll_wait不拷贝 |
| 就绪检测方式 | 内核遍历所有fd | 内核遍历所有fd | 回调机制,O(1)获取就绪 |
| 工作模式 | 仅LT | 仅LT | LT + ET |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 适用场景 | 连接数少且都较活跃 | 连接数中等 | 大量连接、活跃度低 |
五、使用场景
epoll的高性能是有前提的,并非所有场景都适用。
适合使用epoll:
-
多连接,且其中只有一部分连接比较活跃
-
典型的互联网APP入口服务器,需处理上万个客户端连接
-
对并发性能要求较高的网络服务
不适合使用epoll:
-
系统内部服务器之间通信,只有少数几个连接
-
连接数少且全部持续活跃的场景,select/poll的遍历开销可以忽略,epoll的复杂结构反而带来额外开销
具体选择哪种I/O模型,应根据实际需求和场景特点来决定,而非盲目追求"最新最好"。
结语
从select到poll再到epoll,Linux I/O多路转接的演进脉络清晰可见:每一代方案都在解决上一代的核心痛点。
select用位图实现了最基本的多路复用,poll用结构体数组优化了接口,epoll则通过红黑树+回调机制从根本上解决了性能瓶颈。
理解这三者的底层原理和适用场景,不仅是网络编程的基本功,也是面试中的高频考点,希望本文能帮你建立起完整的知识框架。