057 高级 IO 之 epoll 详解与 CMake 入门

057 高级 IO 之 epoll 详解与 CMake 入门
小米里的大麦epoll 和 CMake 的使用
1. epoll 简介
epoll 是 Linux 系统提供的一种 IO 多路复用机制,用来替代传统的 select 和 poll。它的核心优势是:
- 高效:性能不会随着监听的文件描述符数量增加而下降。
- 内存友好:只返回就绪的事件,而不是遍历所有文件描述符。
- 支持边缘触发:可以更灵活地控制事件触发方式。
2. epoll 的三大函数
epoll_create、epoll_ctl 和 epoll_wait 是 epoll 机制的三个核心函数,可以类比为:
epoll_create():创建一个 epoll 实例(相当于创建一个事件监听器)。epoll_ctl():管理要监听的文件描述符(添加、修改、删除监听列表中的 fd)。epoll_wait():等待事件发生(阻塞等待,直到有事件发生或超时)。
这三兄弟的关系就像一个管理系统的三个操作:
epoll_create():创建一个管理办公室。epoll_ctl():向办公室登记/修改/删除要监控的员工(文件描述符)。epoll_wait():在办公室等待,当有员工出事(事件发生)时进行通知。这种设计使得 epoll 可以高效地管理大量文件描述符,特别适合高并发的服务器程序。
3. epoll_create/epoll_create1 —— 创建 epoll 实例
介绍:创建一个 epoll 实例,返回一个文件描述符,后续的所有 epoll 操作都通过这个 fd 进行。
函数原型:
1 |
|
参数:
size:告诉内核你 大概 要监听多少个文件描述符,它只是一个 提示值,在较新的内核中这个参数已经不重要了,但必须大于 0,内核会动态调整内部数据结构的大小,实际可以监听的 fd 数量 只受系统资源限制(如文件描述符限制、内存等),定义size = 10,但实际监听 1000 个 fd 也没问题。flags常用0或EPOLL_CLOEXEC(在 exec 时自动关闭 epfd),即:epoll_create1(0)、epoll_create1(EPOLL_CLOEXEC)。
返回值:
- 成功:返回一个 epoll 文件描述符
epfd(>= 0),后续用这个 fd 来操作 epoll - 失败:返回 -1,同时设置 errno。
使用示例:
1 | int epfd = epoll_create(1024); // 创建一个 epoll 实例,预计监听1024个fd |
4. epoll_ctl —— 控制监听列表(注册 / 修改 / 删除 关注的 fd)
介绍:向 epoll 实例中添加、修改或删除要监听的文件描述符。
函数原型:
1 |
|
epoll_event 结构体:
1 | struct epoll_event |
参数:
epfd:epoll_create返回的 epoll 文件描述符。op:操作类型,告诉 epoll 要做什么。EPOLL_CTL_ADD:添加 一个新的文件描述符到监听列表。EPOLL_CTL_MOD:修改 已存在文件描述符的监听事件。EPOLL_CTL_DEL:从监听列表中 删除 一个文件描述符。
fd:目标被监控的文件描述符/要操作的文件描述符(socket、管道、文件等)。event:指向 epoll_event 结构体的指针。events指定要关注/监听的事件,常用的 events 事件:EPOLLIN:文件描述符 可读。EPOLLOUT:文件描述符 可写。EPOLLERR:文件描述符 错误。EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。EPOLLRDHUP:对端文件描述符关闭(连接)。EPOLLET:边缘触发模式。EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到 epoll 模型中。
data用于回传用户自定义数据(通常存fd或结构体指针)。event对EPOLL_CTL_DEL可传NULL(在某些内核版本仍需有效结构,传地址更保险)。
注意:添加前务必确保 fd 是合法且已打开。若使用
EPOLLET(边沿触发),必须 把 fd 设为非阻塞(fcntl+O_NONBLOCK),EPOLL_CTL_MOD用于修改同一 fd 的事件或 user data。只有删除操作(EPOLL_CTL_DEL)可以传 nullptr,添加和修改操作必须传有效的 epoll_event 指针:
- EPOLL_CTL_ADD:需要告诉内核监听什么事件 → 必须传
struct epoll_event *。- EPOLL_CTL_MOD:需要告诉内核修改成什么事件 → 必须传
struct epoll_event *。- EPOLL_CTL_DEL:只是删除,不需要指定事件 → 可以传
nullptr。传 nullptr 的含义:删除操作不需要关心事件类型,只要告诉内核要删除哪个 fd 即可,内核只需要 fd 值就能找到红黑树(下文会提到)中对应的节点并删除,传 nullptr 可以避免传递不必要的参数,提高效率。
返回值:
- 成功:返回 0。
- 失败:返回 -1,同时设置 errno。
使用示例:
1 | struct epoll_event ev; |
5. epoll_wait —— 等待事件发生
介绍:阻塞等待,直到有文件描述符上的事件发生,然后返回所有就绪的事件。
函数原型:
1 |
|
参数(结构体同上):
epfd:epoll_create返回的 epoll 文件描述符。events:指向 epoll_event 数组的指针,用于接收(内核返回)就绪的事件信息。maxevents:数组大小(能接收的最大就绪事件数)。timeout:超时/最长等待时间(毫秒)。-1:永久阻塞,直到有事件发生。0:非阻塞,立即返回。> 0:最多等待 timeout 毫秒。
要点:
events[i].data是你在epoll_ctl时设置的数据(常用来快速拿到对应的 fd 或连接结构体)。epoll_wait返回后应遍历events数组并处理每个就绪项。maxevents不应小于你预计一次处理的并发就绪数,通常设置为 64、128 或更大。
返回值:
> 0:返回就绪事件的数量(events [0..ret-1])。0:超时(在指定时间内没有事件发生)。-1:出错,同时设置 errno。
使用示例:
1 |
|
6. epoll 的底层原理
epoll 之所以比 select、poll 快、高效不是靠单一技术,而是 数据结构 + 算法 + 机制 的完美结合:它用了 “红黑树 + 就绪队列 + 回调机制” 这三样核心设计,让“监听谁”“谁就绪了”“怎么取结果”都高效完成,避免了反复轮询和复制。
1. epoll 的三大核心组件
| 名称 | 数据结构 | 作用 |
|---|---|---|
| 红黑树 rbr | struct rb_root rbr; | 存放“我要关注哪些 fd、关心哪些事件”的集合(监控列表) |
| 就绪队列 rdlist | struct list_head rdlist; | 存放“已经就绪的 fd 事件”,等 epoll_wait 来取 |
| 回调机制 ep_poll_callback | 函数指针 | 当设备驱动检测到某个 fd 就绪时自动触发,把它加入就绪队列 |
- 红黑树(rbr):存储要监听的所有文件描述符,就像“购物清单”,记录了所有要关注的商品(文件描述符),文件描述符天然作为红黑树的 key,查找速度很快 O(log n),每次调用
epoll_ctl就是在这张清单上增删改项目。 - 就绪队列(rdlist):存储已经就绪的文件描述符,就像“已到货通知单”,记录了哪些商品已经到了,可以取货,每次调用
epoll_wait就是来取这张通知单。
这三个东西组合在一起,就构成了一个完整的 epoll 模型(对应内核结构 eventpoll)。
2. 一个核心机制:回调 —— epoll 高效的 秘密武器!
- select/poll 的问题: 程序问操作系统:”我关注的这些 fd,哪些有数据了?”,操作系统:”我一个一个帮你查一遍…”,每次都要遍历所有 fd,效率随 fd 数量增加而下降。
- epoll 的聪明做法:程序告诉操作系统:”我要关注这些 fd 的事件”,操作系统在内核里建立回调函数(ep_poll_callback),当网卡收到数据时,硬件直接通知内核:”fd 3 有数据了!”,内核自动调用回调函数,把 fd 3 从红黑树移到就绪队列,程序调用
epoll_wait时,直接取就绪队列就行。
比喻理解: 想象你在网上购物:
- select/poll:你每隔几分钟就去快递点问:”我的包裹到了吗?”,快递员要查所有包裹。
- epoll:快递员有你的电话,包裹一到就给你打电话,你再过去取。
3. 整体流程概览
三个函数 epoll_create、epoll_ctl、epoll_wait,分别对应底层三个阶段:
| 用户函数 | 内核动作 | 对应的数据结构 |
|---|---|---|
epoll_create | 创建一个 eventpoll 实例 | 初始化红黑树 rbr、就绪队列 rdlist |
epoll_ctl | 把 fd 添加/修改/删除到红黑树 | 操作红黑树节点(每个节点是 epitem) |
epoll_wait | 等待就绪事件 | 从就绪队列里取出已经准备好的事件 |
4. 内部关键对象:epitem
每一个通过 epoll_ctl 加入监听的 fd,内核都会创建一个对应的结构体 epitem:
1 | struct epitem |
可以理解为:红黑树节点:表示我关注了 fd 上的这些事件,就绪队列节点:表示 fd 上的事件真的发生了,ffd + event:是谁、关心什么。
5. 特殊处理
- 普通模式:事件就绪后,继续保留在红黑树中,下次还会通知。
- EPOLLONESHOT 模式:事件就绪后,会 自动从红黑树删除,想再次监听这个 fd,必须重新添加(调用
epoll_ctl(ADD)),常用于多线程模式,防止同一个 fd 被多个线程同时处理。 - EPOLLET(边沿触发): 只在状态变化时通知一次,必须配合非阻塞 IO,否则可能漏事件,减少系统调用次数,进一步提升性能。
6. 线程安全保障
- 就绪队列用互斥锁保护,多线程访问安全
- 等待队列处理多个线程同时访问的情况
7. 为什么比 select/poll 高效?
- O(1) 查找:红黑树保证添加/删除操作是 O(log n),比数组快。
- 按需通知:只有事件真正发生时才通知,不需要轮询。
- 批量处理:一次
epoll_wait可以返回多个就绪事件。 - 内存友好:只返回就绪的 fd,不是所有 fd。
7. 代码示例(epoll LT)
完整代码请前往 GitHub 查看。
1 |
|
8. epoll 的工作方式
1. 核心区别
| 模式 | 触发时机 | 是否重复通知 | 是否必须非阻塞 | 实现复杂度 | 通知频率 |
|---|---|---|---|---|---|
| LT(Level Triggered,epoll 的默认模式) | 只要内核缓冲区里有数据(高电平状态) | 会 一直 通知 | 不强制,可阻塞或非阻塞 | 简单:可分多次处理事件 | 高(只要事件存在就反复通知) |
| ET(Edge Triggered) | 只有“状态变化”时触发(从无到有/有到多) | 只通知一次 | 必须 非阻塞(否则可能永久阻塞) | 复杂:必须一次性处理完所有数据 | 低(仅状态变化时通知一次) |
类比理解: 可以把内核数据缓冲区理解为“水桶”:
- LT 模式: 只要桶里有水(数据没读完),内核就会一遍又一遍告诉你“有水!”,所以你可以慢慢舀,不急着一次读完。
- ET 模式: 只有桶第一次被装满、或者水又多了一点时,才会告诉你一次,之后不会再提醒。所以必须一次把水全舀光,否则漏掉的数据就永远没人告诉你了。
性能与设计取舍:
| 对比项 | LT | ET |
|---|---|---|
| 内核通知次数 | 多(每次都通知) | 少(状态变化才通知) |
| CPU 开销 | 稍高 | 更低 |
| 编程难度 | 简单 | 较高 |
| 安全性 | 容错性强(可多次处理) | 容错性低(必须彻底处理) |
| 实际应用 | select、poll 属于 LT | Nginx、Redis 使用 ET |
2. ET 模式的编程要点
必须设置非阻塞:
1
2int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);循环读写直到返回错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29// 读
while (true)
{
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n == -1)
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
break; // 读完
}
else
{
perror("recv error");
}
}
else if (n == 0)
{
// 对端关闭
close(fd);
break;
}
else
{
// 正常读取
process(buf, n);
}
}
// 写同理,循环 send,直到 EAGAIN注册事件时带上
EPOLLET:1
2
3
4epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
3. 疑难解答
1. LT 模式下,如果我把 fd 设为非阻塞,并在第一次通知时就循环读完所有数据,那和 ET 有什么区别?
逻辑行为上几乎一样,性能也接近。但 LT 模式仍有“条件判断 + 冗余通知”的系统开销,ET 是内核级别的“只在状态变化时触发”,性能更纯粹:
- LT 仍然会“准备通知”,但因为你已经清空了缓冲区,所以下次
epoll_wait不会再返回该 fd。 - 但 LT 仍保留“兜底”能力:万一你漏读了,下次还会提醒你。
- 所以:ET 的优势不是“必须更快”,而是“强制你写出高效代码”。
实际上,高性能服务器(如 Redis、Nginx)选择 ET,是为了避免“意外的重复通知”带来的开销,尤其是在连接数极高的场景。
2. 为什么 ET 必须用非阻塞 IO?
因为 ET 要求你 一次性读完所有数据。如果使用阻塞 IO,当你读到最后一次(缓冲区已空),recv 会 永远阻塞,因为没有新数据到来,epoll 也不会再通知你。非阻塞 IO 在无数据时立即返回 -1 并设置 errno = EAGAIN,让你知道“本次数据已读完”。
3. ET 模式真的更高效吗?
在特定条件下是的:
- 减少 epoll_wait 的唤醒次数 → 降低系统调用开销。
- 促使应用层批量处理数据 → 更好的 cache locality 和吞吐。
- TCP 窗口优化:当接收方快速消费数据(ET 强制你这么做),TCP 接收窗口更大,发送方可以一次发更多数据,减少小包和 ACK 开销。
注意:如果 ET 实现不当(如漏读、未设非阻塞),反而会导致连接“假死”,比 LT 更危险。
9. 代码示例(epoll ET)
完整代码请前往 GitHub 查看。
1 |
|
10. 快速上手 CMake
1. CMake 是干什么的
CMake 是一个跨平台的 自动化构建工具。它的核心作用是:根据 CMakeLists.txt 自动生成 Makefile,然后我们只需执行 make 就能编译整个项目。
简单说:写一份 CMakeLists.txt → 执行 cmake → 自动生成 Makefile → 执行 make → 生成可执行文件。
2. 安装 CMake
1 | sudo apt update |
检查版本:
1 | cmake --version |
3. 编写最简 CMakeLists.txt
在项目的根目录中创建一个名为 CMakeLists.txt 的文件写入内容:
1 | cmake_minimum_required(VERSION 3.10) # 指定最低CMake版本 |
这就是最基本的版本。注意:CMake 严格要求文件名必须是 CMakeLists.txt,大小写都必须完全匹配。 原因很简单:CMake 的解析器在目录下只会自动搜索这个 精确名字 的文件(CMakeLists.txt)。不是变量名,不是模糊匹配,也不会识别 CMakelists.txt、cmakelist.txt 等写法。比如:
- ✅ 正确:
CMakeLists.txt。 - ❌ 错误:
CMakelists.txt/cmakelists.txt。
project(EpollServer):只是一个 工程名字,主要用于 CMake 内部标识,与目录名没强绑定。通常我们会让它与最终生成的可执行文件同名,这样方便。
4. 编译和运行
我们建议在项目根目录执行下面的代码,这样生成的临时文件都在 build 里,不会污染源代码目录:
1 | mkdir build |
运行 cmake 命令生成 Makefile
1 | cmake .. |
解释:.. 表示让 CMake 去上一级(也就是项目根目录)找 CMakeLists.txt。执行完这步后,build/ 目录里会生成:
1 | Makefile |
执行 make 编译:
1 | make |
CMake 会自动调用 g++ 编译 Main.cc 并生成可执行文件:
1 | EpollServer |
可选:运行程序
1 | ./EpollServer |












