6.IO多路复用-epoll
epoll
一、核心背景
在高并发网络编程中(比如百万连接的服务器),传统的 select/poll 会遇到三个致命问题:
- 性能瓶颈:每次调用都要遍历所有监听的文件描述符(FD),FD 越多越慢(O (n));
- 数量限制:select 最多监听 1024 个 FD(FD_SETSIZE 限制);
- 数据拷贝:每次调用都要把用户态的 FD 集合拷贝到内核态,开销极大。
epoll 就是为解决这些问题而生的 —— 它是 Linux 内核专为高并发 I/O 场景设计的 “事件通知管家”,核心是「只关注有变化的 FD」,让服务器能高效处理数万甚至数百万的并发连接。
通俗类比
- select/poll:像一个老师,每天挨个检查全班 1000 个学生的作业(不管有没有写完),效率极低;
- epoll:像一个班长,只有学生写完作业(FD 就绪)才主动告诉老师,老师只处理这些 “写完作业的学生”,效率直接拉满。
二、epoll 核心底层原理
epoll 的高效,本质是内核用了三个关键设计,我们逐一拆解:
1. 核心数据结构:红黑树 + 就绪链表
内核为每个 epoll 实例维护两个核心结构:
| 结构 | 作用 |
|---|---|
| 红黑树 | 存储所有通过 epoll_ctl 注册的 FD(待检测集合),增删改查效率 O (logn) |
| 就绪链表 | 仅存储 “发生事件” 的 FD(比如有数据可读、可写),epoll_wait 只返回这个链表 |
内核工作流程:
- 你通过
epoll_ctl把要监听的 FD(比如监听套接字、客户端连接)添加到红黑树; - 内核会给每个 FD 注册一个回调函数:当 FD 就绪(比如有数据到达),就自动把这个 FD 从红黑树移到就绪链表;
- 你调用
epoll_wait时,内核只需要把就绪链表的 FD 拷贝到用户态,无需遍历所有 FD(时间复杂度 O (1))。
2. 内存映射(mmap):减少内核 / 用户态拷贝
epoll 用 mmap 把内核态的就绪链表和用户态的缓冲区映射到同一块内存,避免了传统 select/poll 中 “每次调用都拷贝整个 FD 集合” 的开销 —— 数据只需要拷贝一次,甚至无需拷贝。
3. 解耦设计:把 “注册 FD” 和 “等待事件” 分开
select/poll 把 “添加要检测的 FD” 和 “阻塞等待事件” 绑在一起,每次调用都要重复做这两件事;
epoll 把这两步拆成 epoll_ctl(注册 / 修改 / 删除 FD)和 epoll_wait(等待事件),大部分场景下 FD 是固定的,只需注册一次,后续只调用 epoll_wait 即可,大幅减少开销。
三、epoll 核心 API 深度解析
使用epoll需包含头文件:
1 |
epoll 只有三个核心函数,我们逐个拆解:
1. epoll_create /epoll_create1(创建 epoll 实例)
1 | // 旧版:size 参数在 2.6.8 后废弃,只需传 >0 的数即可 |
- 返回值:成功返回 epoll 实例的文件描述符(epoll_fd),失败返回 -1;
- 关键注意:epoll_fd 本身也是一个 FD,使用完必须
close(epoll_fd),否则会内存泄漏; - 通俗理解:创建一个 “事件管家”,后续所有 FD 的监听都由这个管家负责。
epoll_create1()函数
1. 参数:int flags(标志位)
这是 epoll_create1() 相比旧版 epoll_create() 的核心改进 —— 通过标志位控制 epoll 实例的行为,目前支持的标志只有一个:
| 标志值 | 含义 | 使用场景 |
|---|---|---|
0 |
默认值:创建普通的 epoll 实例,无特殊行为 | 绝大多数常规场景 |
EPOLL_CLOEXEC |
创建 epoll 实例时,为其设置 FD_CLOEXEC 标志(执行 exec 时自动关闭) |
多进程 / 程序替换(exec)场景 |
关键解释:EPOLL_CLOEXEC 的作用
- 背景:Linux 中,进程打开的 FD 会被子进程继承,若进程调用
exec(替换程序),默认情况下 FD 不会关闭; - 问题:如果不关闭 epoll 实例的 FD,可能导致资源泄露(比如子进程 / 新程序意外持有该 FD);
- 解决:设置
EPOLL_CLOEXEC后,当进程调用exec时,内核会自动关闭这个 epoll 实例的 FD,避免资源泄露。
2. 返回值
| 返回值 | 含义 |
|---|---|
-1 |
失败:会设置 errno 提示失败原因(如 ENOMEM 内存不足、EINVAL 标志无效) |
>=0 |
成功:返回一个有效的文件描述符(epfd),代表创建的 epoll 实例 |
常见失败原因及解决
errno = ENOMEM:系统内存不足,无法创建 epoll 实例 → 检查系统内存,或减少其他进程占用;errno = EINVAL:传入的flags无效(比如填了非0/EPOLL_CLOEXEC的值)→ 确认标志位合法;errno = EMFILE:进程打开的 FD 数达到上限 → 执行ulimit -n 65535扩大 FD 上限。
2. epoll_ctl(管理监听的 FD)
这是 epoll 最核心的函数,负责给 “管家” 添加 / 修改 / 删除要监听的 FD。
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
参数拆解
| 参数 | 含义 & 注意事项 |
|---|---|
| epfd | epoll_create 的返回值 |
| op | 操作类型: ✅ EPOLL_CTL_ADD:添加 FD 到红黑树 ✅ EPOLL_CTL_MOD:修改 FD 的监听事件 ✅ EPOLL_CTL_DEL:从红黑树删除 FD |
| fd | 要监听的文件描述符(比如监听套接字 lfd、客户端连接 cfd) |
| event | 核心结构体,指定 “监听什么事件”+“关联什么数据” |
核心返回值含义
| 返回值 | 含义 |
|---|---|
| 0 | 操作成功(添加 / 修改 / 删除监听事件完成) |
| -1 | 操作失败,具体错误原因通过 errno 获取 |
核心结构体:struct epoll_event
1 | // 联合体:多个变量共用一块内存,通常只用 fd 成员 |
常用 events 标志(必记)
| 标志 | 含义 |
|---|---|
| EPOLLIN | 读事件:FD 有数据可读(客户端发数据、新连接、客户端断开) |
| EPOLLOUT | 写事件:FD 可写(发送缓冲区有空间) |
| EPOLLET | 边缘触发(ET 模式):默认是水平触发(LT) |
| EPOLLERR | 异常事件:无需手动注册,内核自动触发 |
| EPOLLHUP | 挂起事件:比如客户端断开连接,无需手动注册 |
| EPOLLONESHOT | 仅触发一次事件,需重新调用 epoll_ctl 重置(多线程场景避竞争) |
示例:添加监听 FD
1 | struct epoll_event ev; |
3. epoll_wait(等待就绪事件)
1 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
参数拆解
| 参数 | 含义 & 注意事项 |
|---|---|
| epfd | epoll 实例的 fd(指定要查询的 “管家”) |
| events | 传出参数:用户态数组,内核会把 “就绪的 FD 信息” 填充到这个数组里 |
| maxevents | events 数组的最大长度(比如 1024),不能超过系统限制 |
| timeout | 阻塞时长(毫秒): ✅ -1:永久阻塞,直到有 FD 就绪 ✅ 0:非阻塞,立即返回 ✅ >0:阻塞指定毫秒后返回 |
返回值
> 0:就绪的 FD 数量(events 数组中前 N 个元素有效);
0:超时(没有就绪的 FD);
-1:失败(比如被信号中断)。
关键注意
- events 数组是 “传出” 的,内核会把就绪 FD 的
epoll_event结构体填进去; - 如果就绪的 FD 数量超过 maxevents,未拷贝的 FD 会留在内核就绪链表中,下次调用
epoll_wait会继续返回。
四、服务端使用epoll实现IO多路复用的示例代码
1 |
|
1. 创建监听套接字

1 | int lfd = socket(AF_INET, SOCK_STREAM, 0); |
- 调用
socket()创建一个 TCP 类型的监听套接字lfd,用于接收客户端的连接请求; - 失败则打印错误并退出程序。
2. 绑定套接字到指定端口和地址
1 | struct sockaddr_in server_addr; |
- 将监听套接字
lfd与端口 10000、本机所有网卡地址绑定,使客户端能通过IP:10000访问服务端; - 失败则关闭
lfd并退出程序。
3. 开启监听
1 | listen(lfd, 128); |
- 将
lfd从 “主动套接字” 转为 “被动套接字”,开始监听客户端的连接请求; - 第二个参数 128 是半连接队列的最大长度,失败则关闭
lfd并退出程序。
4. 创建 epoll 实例
1 | int epfd = epoll_create1(0); |
- 创建 epoll 实例,内核会为该实例分配内存(存储监听的 FD 红黑树、就绪 FD 链表);
- 返回的
epfd是 epoll 实例的文件描述符,失败则退出程序。
5. 将监听套接字加入 epoll 实例
1 | struct epoll_event ev; |
- 调用
epoll_ctl的EPOLL_CTL_ADD操作,将lfd添加到 epoll 实例的监听集合中; - 告知 epoll 要监听
lfd的读事件(EPOLLIN),失败则退出程序。
6. 进入无限循环,阻塞等待就绪事件
1 | while(1) { |
- 调用
epoll_wait阻塞程序,直到 epoll 实例中有 FD 就绪(超时时间 -1 表示永久阻塞); - 返回值
num是就绪 FD 的数量,evs数组存储所有就绪 FD 的事件信息。
7. 遍历所有就绪的 FD,逐个处理事件
1 | for(int i = 0; i < num; ++i) { |
分支 1:处理新连接事件(就绪 FD 是监听套接字 lfd)
1 | if(curfd == lfd) { |
- 当
curfd == lfd时,说明有新客户端发起连接请求; - 调用
accept()接收连接,返回用于与该客户端通信的套接字cfd; - 将
cfd添加到 epoll 实例,监听其读事件,使 epoll 能检测该客户端的数据发送行为。
分支 2:处理客户端通信事件(就绪 FD 是通信套接字 cfd)
1 | else { |
- 当
curfd != lfd时,说明是已连接的客户端有数据发送 / 连接断开; - 调用
read()读取客户端数据:- 读取成功(
len > 0):打印数据并通过write()回显给客户端; - 读取到 0 字节(
len == 0):客户端主动断开连接,从 epoll 中删除该 FD 并关闭; - 读取失败(
len < 0):打印错误并退出程序。
- 读取成功(
五、epoll 两种工作模式(LT vs ET,核心重点)
这是 epoll 最容易踩坑的地方,我们用 “快递” 的类比讲清楚:
1. 水平触发(LT,默认)
- 类比:你网购了一个快递,快递员送到小区门口,会一直给你打电话(直到你取走);
- 核心规则:只要 FD 处于 “就绪状态”(比如读缓冲区有数据),每次调用
epoll_wait都会返回这个 FD; - 编程要求:无需一次性读完数据,容错高(比如读了一半数据,下次还能收到通知);
- 适用场景:新手入门、大部分业务场景(比如普通 Web 服务器)。
2. 边缘触发(ET,高速模式)
类比:快递员只给你打一次电话(不管你取没取),只有新快递到了才会再打;
核心规则:仅当 FD 从 “非就绪” 变为 “就绪” 时触发一次通知(比如新数据到达读缓冲区);
编程要求(必须满足,否则丢数据):
- FD 必须设置为非阻塞(避免循环读写时阻塞);
- 读写操作必须循环执行,直到返回
EAGAIN/EWOULDBLOCK(表示数据已读完 / 写缓冲区满)。
适用场景:高并发场景(比如百万连接的网关、Redis/Nginx 底层),追求极致性能。
LT vs ET 核心对比表
| 维度 | 水平触发(LT) | 边缘触发(ET) |
|---|---|---|
| 触发时机 | 只要 FD 就绪,持续触发 | 仅 FD 状态变化时触发一次 |
| 阻塞要求 | 支持阻塞 / 非阻塞 FD | 必须用非阻塞 FD |
| 编程难度 | 简单,容错高 | 复杂,需循环读写 |
| 性能 | 一般(通知次数多) | 更高(通知次数少) |
| 数据丢失风险 | 低 | 高(未读完数据会丢) |
关键:ET 模式如何正确读写数据?
1 | // 1. 先把 FD 设置为非阻塞 |
fcntl()函数fcntl(file control)是 Unix/Linux 系统中用于控制文件描述符属性的核心系统调用,简单来说,它的作用是 “给已打开的文件描述符设置 / 获取各种参数”。
它的函数原型如下(C 语言):
1 |
|
- 参数:
fd:要操作的文件描述符(比如打开文件返回的0/1/2标准输入 / 输出 / 错误,或open()返回的自定义 fd)。cmd:操作指令(核心参数,决定要做什么)。...:可选参数,根据cmd的不同,传入不同类型的值(如int或struct flock)。
- 返回值: 成功返回非负整数(具体值看
cmd),失败返回-1并设置errno。
示例:用 fcntl 设置非阻塞模式
文件描述符(fd)有一组状态标志,其中 O_NONBLOCK 就是非阻塞标志。我们通过 fcntl 读取当前标志,添加 O_NONBLOCK 后再写回,就可以让 fd 进入非阻塞模式。
1 |
|
取消非阻塞:
1 | flags &= ~O_NONBLOCK; |
O_NONBLOCK原理简述(了解)
O_NONBLOCK 是一个宏定义(通常在 fcntl.h 中),它的本质是一个整数常量,且这个整数的二进制形式只有某一位是 1,其余位都是 0。
比如在大多数系统中:
1 |
|
假设当前 fd 的状态标志 flags 是:
1 | 二进制:1001 0100 1000 0000 (包含 O_NONBLOCK,第 11 位是 1) |
~ 是按位取反运算符,会把二进制的每一位都翻转(1→0,0→1)。
以 O_NONBLOCK = 04000(二进制 100 0000 0000 0000)为例:
1 | O_NONBLOCK 的二进制:0000 0000 1000 0000 0000 0000 (简化为 32 位) |
这个取反后的结果,就是清除 O_NONBLOCK 标志的掩码:
- 对应
O_NONBLOCK的位是 0,其余位都是 1。
六、epoll 常见坑点(避坑指南)
坑 1:ET 模式未设置非阻塞 → 进程阻塞
- 现象:服务端卡死,无法处理其他连接;
- 原因:ET 模式下循环读数据,缓冲区空时
recv阻塞; - 解决:必须用
fcntl把 FD 设置为非阻塞。
坑 2:ET 模式未循环读数据 → 数据丢失
- 现象:客户端发大量数据,服务端只收到一部分;
- 原因:ET 模式仅触发一次通知,未读完的数据不会再通知;
- 解决:循环读直到
errno == EAGAIN。
坑 3:监听 EPOLLOUT → CPU 100%
- 现象:服务端 CPU 占用率极高;
- 原因:写缓冲区大部分时间可写,
epoll_wait频繁返回,循环空转; - 解决:仅当需要写数据时,才添加 EPOLLOUT 监听,写完后立即删除。
坑 4:关闭 FD 未从 epoll 删除 → 无效事件
- 现象:
epoll_wait频繁返回已关闭的 FD,报错EBADF; - 原因:FD 关闭后,内核不会自动从 epoll 红黑树删除;
- 解决:关闭 FD 前,先调用
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)。
七、总结(核心要点回顾)
- epoll 核心优势:基于红黑树 + 就绪链表管理 FD,回调机制触发事件,mmap 减少拷贝,效率 O (1),无 FD 数量限制;
- 两种模式:LT 模式易编程、容错高(默认),ET 模式效率高但需非阻塞 + 循环读写;
- 核心步骤:创建监听 FD → 创建 epoll 实例 → 添加监听 FD → epoll_wait 检测事件 → 处理新连接 / 通信数据 → 循环检测;
- 避坑关键:ET 模式必须非阻塞 + 循环读写,关闭 FD 前要从 epoll 删除,避免监听不必要的 EPOLLOUT。