【C/C++】Linux epoll详解与实战
/ 服务器配置常量epoll 是 Linux 下高性能网络编程的基础,理解其工作原理和正确使用方式对于开发高并发服务器至关重要。│ 关键要点总结 ││ ││ 1. epoll 三个核心 API: ││ epoll_create1() - 创建实例 ││ epoll_ctl() - 管理文件描述符 ││ epoll_wait() - 等待事件 ││ ││ 2. 两种触发模式: ││ LT (Leve
Linux epoll 详解与实战
一、什么是 epoll
epoll 是 Linux 内核提供的高性能 I/O 事件通知机制(I/O event notification facility),专门用于监控大量文件描述符上的事件。它是 select 和 poll 的改进版本,解决了它们在处理大量并发连接时的性能瓶颈,是现代 Linux 高并发服务器的基石。
I/O 多路复用的演进
在理解 epoll 之前,我们需要了解 I/O 多路复用的背景。传统的阻塞 I/O 模型中,每个连接需要一个线程来处理,当并发连接数达到数万甚至数十万时,线程切换的开销会变得不可接受。I/O 多路复用允许单个线程同时监控多个文件描述符,当任何一个文件描述符就绪时,程序得到通知并进行处理。
┌─────────────────────────────────────────────────────────────────┐
│ I/O 多路复用演进历史 │
│ Evolution of I/O Multiplexing │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1983 1997 2002 │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │select│ ───▶ │ poll │ ──────▶ │epoll │ │
│ └──────┘ └──────┘ └──────┘ │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ • 1024 fd 限制 • 无 fd 限制 • 无 fd 限制 │
│ • O(n) 扫描 • O(n) 扫描 • O(1) 事件通知 │
│ • 每次拷贝全部 • 每次拷贝全部 • 只拷贝一次 │
│ │
└─────────────────────────────────────────────────────────────────┘
为什么需要 epoll
传统的 select 和 poll 存在以下问题:
第一个问题是每次调用都需要拷贝。每次调用 select 或 poll 时,都需要将所有被监控的文件描述符从用户空间拷贝到内核空间。当监控 10000 个连接时,每次调用都要拷贝这 10000 个文件描述符的信息,即使其中只有几个有事件发生。
第二个问题是内核需要线性扫描。内核收到调用后,需要遍历所有文件描述符来检查哪些就绪。这意味着时间复杂度为 O(n),当 n 很大时,即使大部分连接都是空闲的,每次调用的开销也很大。
第三个问题是 select 有最大文件描述符数量限制。select 使用固定大小的位图来表示文件描述符集合,通常限制为 1024,这个限制在编译时就已确定。
┌─────────────────────────────────────────────────────────────────┐
│ select/poll 的性能问题 │
│ Performance Issues with select/poll │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User Space Kernel Space │
│ 用户空间 内核空间 │
│ │
│ ┌─────────────────┐ │
│ │ fd_set (10000) │ │
│ │ [1,2,3,...,10000]────────────────────┐ │
│ └─────────────────┘ │ │
│ │ ▼ │
│ │ Every call ┌──────────┐ │
│ │ 每次调用 │ Copy │ │
│ │ │ 拷贝 │ │
│ │ └────┬─────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────┐ │
│ │ │ Scan │ │
│ │ │ O(n) │ │
│ │ │ 扫描 │ │
│ │ └────┬─────┘ │
│ │ │ │
│ │ ▼ │
│ │ Only 3 ready │
│ │ 只有 3 个就绪 │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────┐ │ │
│ │ Return & copy │◀───────────────────┘ │
│ │ back all 10000 │ │
│ │ 返回并拷贝全部 │ │
│ └─────────────────┘ │
│ │
│ Problem: 10000 fds copied, only 3 are ready! │
│ 问题:拷贝了 10000 个 fd,只有 3 个就绪! │
│ │
└─────────────────────────────────────────────────────────────────┘
epoll 如何解决这些问题
epoll 通过以下机制解决上述问题:
首先,epoll 在内核中维护一个事件表。通过 epoll_ctl 注册文件描述符时,信息被保存在内核中,后续的 epoll_wait 调用不需要再次传递这些信息。这是"一次注册,多次使用"的模式。
其次,epoll 使用回调机制而非轮询。当文件描述符就绪时,内核通过回调函数将其加入就绪队列,epoll_wait 只需要检查这个就绪队列,时间复杂度为 O(1)。
最后,epoll 没有文件描述符数量限制。epoll 使用红黑树存储被监控的文件描述符,理论上只受系统资源限制。
┌─────────────────────────────────────────────────────────────────┐
│ epoll 的解决方案 │
│ How epoll Solves the Problems │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User Space Kernel Space │
│ 用户空间 内核空间 │
│ │
│ ┌─────────────────────────┐ │
│ │ epoll instance │ │
│ │ epoll 实例 │ │
│ ┌──────────────┐ │ │ │
│ │epoll_ctl ADD │────────────────▶│ ┌─────────────────┐ │ │
│ │ (once) │ │ │ Red-Black │ │ │
│ │ (只需一次) │ │ │ Tree │ │ │
│ └──────────────┘ │ │ 红黑树 │ │ │
│ │ │ (all fds) │ │ │
│ │ └────────┬────────┘ │ │
│ │ │ │ │
│ │ │ callback │ │
│ │ │ 回调 │ │
│ │ ▼ │ │
│ │ ┌─────────────────┐ │ │
│ ┌──────────────┐ │ │ Ready List │ │ │
│ │ epoll_wait │◀────────────────│ │ 就绪队列 │ │ │
│ │ │ Only ready │ │ (only ready) │ │ │
│ │ │ 只返回就绪 │ └─────────────────┘ │ │
│ └──────────────┘ │ │ │
│ │ └─────────────────────────┘ │
│ ▼ │
│ Returns only 3 ready fds │
│ 只返回 3 个就绪的 fd │
│ │
│ Advantage: No matter how many fds, only ready ones returned! │
│ 优势:无论有多少 fd,只返回就绪的! │
│ │
└─────────────────────────────────────────────────────────────────┘
二、epoll 内部数据结构
理解 epoll 的内部数据结构有助于正确使用它并进行性能调优。
┌─────────────────────────────────────────────────────────────────┐
│ epoll 内部数据结构 │
│ epoll Internal Data Structures │
├─────────────────────────────────────────────────────────────────┤
│ │
│ struct eventpoll │
│ ┌──────────────────────────┐ │
│ │ │ │
│ │ ┌────────────────────┐ │ │
│ │ │ Red-Black Tree │ │ │
│ │ │ 红黑树 │ │ │
│ │ │ │ │ │
│ │ │ Stores all │ │ │
│ │ │ registered fds │ │ │
│ │ │ 存储所有注册的 │ │ │
│ │ │ 文件描述符 │ │ │
│ │ │ │ │ │
│ │ │ ┌───┐ │ │ │
│ │ │ │ 5 │ │ │ │
│ │ │ └─┬─┘ │ │ │
│ │ │ ╱ ╲ │ │ │
│ │ │ ┌─┴─┐ ┌─┴─┐ │ │ │
│ │ │ │ 3 │ │ 8 │ │ │ │
│ │ │ └───┘ └───┘ │ │ │
│ │ │ │ │ │
│ │ │ O(log n) lookup │ │ │
│ │ │ O(log n) 查找 │ │ │
│ │ └────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────┐ │ │
│ │ │ Ready List │ │ │
│ │ │ 就绪链表 │ │ │
│ │ │ │ │ │
│ │ │ ┌───┐ ┌───┐ │ │ │
│ │ │ │fd3│──▶│fd8│──▶∅ │ │ │
│ │ │ └───┘ └───┘ │ │ │
│ │ │ │ │ │
│ │ │ O(1) access │ │ │
│ │ │ O(1) 访问 │ │ │
│ │ └────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────┐ │ │
│ │ │ Wait Queue │ │ │
│ │ │ 等待队列 │ │ │
│ │ │ │ │ │
│ │ │ Blocked threads │ │ │
│ │ │ waiting for events│ │ │
│ │ │ 阻塞等待事件的 │ │ │
│ │ │ 线程队列 │ │ │
│ │ └────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
红黑树(Red-Black Tree)用于存储所有被监控的文件描述符,支持 O(log n) 的插入、删除和查找操作。当调用 epoll_ctl 添加、修改或删除文件描述符时,操作的就是这棵红黑树。
就绪链表(Ready List)存储所有当前有事件发生的文件描述符。当某个文件描述符上有事件发生时,内核通过回调函数将其加入就绪链表。epoll_wait 调用时,只需要检查这个链表并返回其中的元素。
等待队列(Wait Queue)存储所有因调用 epoll_wait 而阻塞的进程或线程。当有事件发生时,内核会唤醒等待队列中的进程。
三、epoll 核心 API 详解
epoll 只有三个核心系统调用,简洁而强大。
epoll_create1 - 创建 epoll 实例
#include <sys/epoll.h>
int epoll_create1(int flags);
此函数创建一个 epoll 实例并返回对应的文件描述符。这个文件描述符本身也是一个资源,使用完毕后需要调用 close 关闭。
flags 参数通常传入 0。也可以使用 EPOLL_CLOEXEC 标志,使文件描述符在调用 exec() 系列函数时自动关闭,这是一个安全的做法,可以防止子进程意外继承 epoll 文件描述符。
返回值为 epoll 文件描述符,失败时返回 -1 并设置 errno。
┌─────────────────────────────────────────────────────────────────┐
│ epoll_create1 流程 │
│ epoll_create1 Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User Code Kernel │
│ 用户代码 内核 │
│ │
│ int epfd = epoll_create1(0); │
│ │ │
│ │ │
│ ▼ │
│ ┌─────────┐ ┌─────────────────────┐ │
│ │ Call │ ──────────────────▶│ Allocate eventpoll │ │
│ │ 调用 │ │ struct in kernel │ │
│ └─────────┘ │ 在内核中分配 │ │
│ │ eventpoll 结构 │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Initialize: │ │
│ │ • Red-black tree │ │
│ │ • Ready list │ │
│ │ • Wait queue │ │
│ │ 初始化红黑树、 │ │
│ │ 就绪链表、等待队列 │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────┐ ┌─────────────────────┐ │
│ │ epfd=3 │◀───────────────────│ Return fd │ │
│ │ │ │ 返回文件描述符 │ │
│ └─────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
注意:还有一个旧的 epoll_create(int size) 函数,其中 size 参数在现代内核中已被忽略,但必须大于 0。推荐使用 epoll_create1。
epoll_ctl - 管理监控的文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
此函数用于添加、修改或删除被监控的文件描述符。
epfd 是 epoll_create1 返回的 epoll 文件描述符。
op 参数指定操作类型,有三个可选值:EPOLL_CTL_ADD 用于注册新的文件描述符到 epoll 实例,EPOLL_CTL_MOD 用于修改已注册的文件描述符的关注事件,EPOLL_CTL_DEL 用于从 epoll 实例中删除文件描述符。
fd 是要操作的目标文件描述符。
event 是指向 epoll_event 结构的指针,描述要监控的事件和关联的数据。
struct epoll_event {
uint32_t events; // Epoll events (EPOLLIN, EPOLLOUT, etc.)
// 事件类型
epoll_data_t data; // User data variable
// 用户数据
};
typedef union epoll_data {
void *ptr; // Pointer to user-defined data
// 指向用户定义数据的指针
int fd; // File descriptor
// 文件描述符
uint32_t u32; // 32-bit integer
uint64_t u64; // 64-bit integer
} epoll_data_t;
epoll_data 是一个联合体,允许用户在事件中携带额外信息。最常用的是 fd 字段,用于在事件触发时识别是哪个文件描述符;ptr 字段可以指向用户自定义的连接对象。
┌─────────────────────────────────────────────────────────────────┐
│ epoll_ctl 操作示意 │
│ epoll_ctl Operations │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Red-Black Tree │
│ 红黑树 │
│ │
│ ┌───┐ │
│ │ 5 │ │
│ └─┬─┘ │
│ ╱ ╲ │
│ ┌─┴─┐ ┌─┴─┐ │
│ │ 3 │ │ 8 │ │
│ └───┘ └───┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ EPOLL_CTL_ADD fd=6: │ │
│ │ 添加 fd=6: │ │
│ │ │ │
│ │ ┌───┐ │ │
│ │ │ 5 │ │ │
│ │ └─┬─┘ │ │
│ │ ╱ ╲ │ │
│ │ ┌─┴─┐ ┌─┴─┐ │ │
│ │ │ 3 │ │ 8 │ │ │
│ │ └───┘ └─┬─┘ │ │
│ │ ╱ │ │
│ │ ┌─┴─┐ │ │
│ │ │ 6 │ ◀── NEW │ │
│ │ └───┘ 新增 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ EPOLL_CTL_MOD fd=3: │ │
│ │ 修改 fd=3: │ │
│ │ │ │
│ │ Find fd=3 in tree, update its events │ │
│ │ 在树中找到 fd=3,更新其事件 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ EPOLL_CTL_DEL fd=8: │ │
│ │ 删除 fd=8: │ │
│ │ │ │
│ │ ┌───┐ │ │
│ │ │ 5 │ │ │
│ │ └─┬─┘ │ │
│ │ ╱ ╲ │ │
│ │ ┌─┴─┐ ┌─┴─┐ │ │
│ │ │ 3 │ │ 6 │ │ │
│ │ └───┘ └───┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
epoll_wait - 等待事件发生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
此函数阻塞等待事件发生,是 epoll 的核心调用。
epfd 是 epoll 文件描述符。
events 是用于存放就绪事件的数组,由调用者分配。
maxevents 是 events 数组的大小,告诉内核最多返回多少个事件。
timeout 参数控制等待行为:-1 表示永久阻塞直到有事件发生或被信号中断,0 表示立即返回,即使没有事件也不阻塞(非阻塞轮询模式),正数表示最多等待指定的毫秒数。
返回值有三种情况:大于 0 表示就绪的文件描述符数量,events 数组的前 n 个元素包含就绪事件;等于 0 表示超时,没有任何事件发生;等于 -1 表示出错,具体错误码在 errno 中。
┌─────────────────────────────────────────────────────────────────┐
│ epoll_wait 执行流程 │
│ epoll_wait Execution Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User Code Kernel │
│ 用户代码 内核 │
│ │
│ epoll_event events[1024]; │
│ int n = epoll_wait(epfd, events, 1024, -1); │
│ │ │
│ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ Call │ │
│ │ 调用 │ │
│ └────┬────┘ │
│ │ │
│ │ ┌─────────────────────┐ │
│ │ │ Check ready list │ │
│ └─────────────────────────▶│ 检查就绪链表 │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ ┌────│ Ready list empty? │ │
│ │ │ 就绪链表为空? │ │
│ │ └─────────────────────┘ │
│ │ │
│ ┌──────────┴──────────┐ │
│ │ Yes │ No │
│ │ 是 │ 否 │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Add to wait queue │ │ Copy ready events │ │
│ │ Block thread │ │ to user space │ │
│ │ 加入等待队列 │ │ 拷贝就绪事件到 │ │
│ │ 阻塞线程 │ │ 用户空间 │ │
│ └──────────┬──────────┘ └──────────┬──────────┘ │
│ │ │ │
│ │ Event arrives │ │
│ │ 事件到达 │ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────────────┐ │ │
│ │ Wake up thread │ │ │
│ │ 唤醒线程 │ │ │
│ └──────────┬──────────┘ │ │
│ │ │ │
│ └────────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────┐ ┌─────────────────────┐ │
│ │ n = 3 │◀──────│ Return count │ │
│ │ │ │ 返回数量 │ │
│ └─────────┘ └─────────────────────┘ │
│ │ │
│ ▼ │
│ Process events[0], events[1], events[2] │
│ 处理 events[0], events[1], events[2] │
│ │
└─────────────────────────────────────────────────────────────────┘
四、epoll 事件类型详解
epoll 支持多种事件类型,理解它们对于正确处理各种 I/O 场景至关重要。
┌─────────────────────────────────────────────────────────────────┐
│ epoll 事件类型 │
│ epoll Event Types │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 事件类型 触发条件 常见场景 │
│ Event Type Trigger Condition Use Case │
│ ───────────────────────────────────────────────────────────── │
│ │
│ EPOLLIN 数据可读 接收数据 │
│ Data available to read Receive │
│ │
│ EPOLLOUT 可以写入数据 发送数据 │
│ Ready for writing Send │
│ │
│ EPOLLERR 发生错误 错误处理 │
│ Error condition Error │
│ │
│ EPOLLHUP 文件描述符被挂起 连接关闭 │
│ Hang up Closed │
│ │
│ EPOLLRDHUP 对端关闭连接或关闭写端 对端关闭 │
│ Peer closed or shutdown write Peer close │
│ │
│ EPOLLET 边缘触发模式 高性能场景 │
│ Edge-triggered mode High perf │
│ │
│ EPOLLONESHOT 单次触发后自动禁用 多线程安全 │
│ One-shot mode Thread safe │
│ │
│ EPOLLEXCLUSIVE 独占唤醒模式 避免惊群 │
│ Exclusive wakeup mode No stampede │
│ │
└─────────────────────────────────────────────────────────────────┘
EPOLLIN 和 EPOLLOUT
EPOLLIN 表示文件描述符可读,即接收缓冲区中有数据可以读取。对于监听套接字(listening socket),EPOLLIN 表示有新的连接请求到达,可以调用 accept。
EPOLLOUT 表示文件描述符可写,即发送缓冲区有空间可以写入。需要注意的是,大多数时候套接字都是可写的,因此不应该一直监听 EPOLLOUT,否则会导致 busy loop。正确的做法是只在需要写入数据且上次写入返回 EAGAIN 时才添加 EPOLLOUT 监听。
EPOLLERR 和 EPOLLHUP
EPOLLERR 表示文件描述符发生了错误。这个事件会被自动监听,不需要显式指定。发生时应该关闭连接。
EPOLLHUP 表示文件描述符被挂起,通常意味着连接的另一端已经关闭。这个事件也会被自动监听。
EPOLLRDHUP
EPOLLRDHUP 是 Linux 2.6.17 引入的事件,表示对端关闭了连接或者关闭了写入端(half-close)。使用这个事件可以更快地检测到连接关闭,而不需要等到 read 返回 0。这是一个非常有用的事件,推荐在实际应用中使用。
EPOLLET
EPOLLET 启用边缘触发模式,这是一个重要的性能优化选项。关于边缘触发和水平触发的详细区别,将在下一节详细讨论。
EPOLLONESHOT
EPOLLONESHOT 使事件只触发一次,触发后文件描述符会被自动禁用,需要使用 EPOLL_CTL_MOD 重新启用。这个选项主要用于多线程环境,确保同一个文件描述符的事件只被一个线程处理。
五、触发模式:LT vs ET 深入分析
epoll 支持两种触发模式,理解它们的区别对于正确使用 epoll 至关重要。
水平触发 Level Triggered (LT)
水平触发是默认模式,也是 select 和 poll 使用的模式。其行为特点是:只要文件描述符处于就绪状态,epoll_wait 就会持续返回这个文件描述符。
┌─────────────────────────────────────────────────────────────────┐
│ 水平触发模式 (Level Triggered) │
│ Level Triggered Mode │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Time ──────────────────────────────────────────────────────▶ │
│ 时间 │
│ │
│ Buffer State (缓冲区状态): │
│ │
│ 1000 bytes arrive │
│ 1000 字节到达 │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │████████████│ │ │ │ │ │ │
│ │ 1000 B │ 900 B │ 800 B │ 700 B │ ... │ 0 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ │
│ epoll_wait returns (epoll_wait 返回): │
│ │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ ✓ │ │ ✓ │ │ ✓ │ │ ✓ │ ... │ ✗ │ │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
│ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ read 100B read 100B read 100B read 100B │
│ │
│ │
│ 特点 (Characteristics): │
│ • As long as data in buffer, epoll_wait keeps returning │
│ 只要缓冲区有数据,epoll_wait 持续返回 │
│ • Safe to read any amount each time │
│ 每次可以只读取部分数据 │
│ • May cause more system calls │
│ 可能导致更多系统调用 │
│ │
└─────────────────────────────────────────────────────────────────┘
水平触发模式的优点是编程简单,不容易丢失数据。即使一次 read 没有读取完所有数据,下次 epoll_wait 还会通知你。缺点是当缓冲区一直有数据时,会产生很多次 epoll_wait 返回和系统调用。
边缘触发 Edge Triggered (ET)
边缘触发需要显式指定 EPOLLET 标志。其行为特点是:只有当文件描述符的状态发生变化时才会通知,即从不可读变为可读,或从不可写变为可写。
┌─────────────────────────────────────────────────────────────────┐
│ 边缘触发模式 (Edge Triggered) │
│ Edge Triggered Mode │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Time ──────────────────────────────────────────────────────▶ │
│ 时间 │
│ │
│ Buffer State (缓冲区状态): │
│ │
│ 1000 bytes arrive │
│ 1000 字节到达 │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │████████████│ │ │ │ │
│ │ 1000 B │ 900 B │ ...remaining data... │ 0 │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ │
│ epoll_wait returns (epoll_wait 返回): │
│ │
│ ┌───┐ ┌───┐ ┌───┐ │
│ │ ✓ │ │ ✗ │ NO MORE NOTIFICATIONS! │ ✗ │ │
│ └───┘ └───┘ 不再有通知! └───┘ │
│ │
│ │ │ │
│ ▼ │ │
│ read 100B │ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ PROBLEM: 900 bytes stuck in buffer! │ │
│ │ 问题:900 字节数据滞留在缓冲区! │ │
│ │ │ │
│ │ No notification until NEW data arrives │ │
│ │ 直到新数据到达才会有通知 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ 正确做法 (Correct Approach): │
│ │
│ ┌───┐ │
│ │ ✓ │ │
│ └───┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Loop read until EAGAIN! │ │
│ │ 循环读取直到 EAGAIN! │ │
│ │ │ │
│ │ while (true) { │ │
│ │ n = read(fd, buf, size); │ │
│ │ if (n == -1 && errno == EAGAIN) │ │
│ │ break; // Done, no more data │ │
│ │ // process data... │ │
│ │ } │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
边缘触发模式的优点是效率高,每次状态变化只通知一次,减少了系统调用次数。缺点是编程复杂度高,必须循环读取直到 EAGAIN,必须使用非阻塞 I/O,否则可能永久丢失事件通知。
两种模式的选择
┌─────────────────────────────────────────────────────────────────┐
│ LT vs ET 选择指南 │
│ LT vs ET Selection Guide │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ 选择触发模式? │ │
│ │ Choose mode? │ │
│ └────────┬────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ 追求简单可靠? │ │ 追求极致性能? │ │
│ │ Simple&reliable?│ │ Max performance?│ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ LT │ │ ET │ │
│ │ 水平触发 │ │ 边缘触发 │ │
│ └─────────────────┘ └─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ • 代码简单 • 减少系统调用 │
│ Simple code Fewer syscalls │
│ • 不易出错 • 需要非阻塞 I/O │
│ Less error-prone Requires non-blocking │
│ • 适合初学者 • 必须循环读写 │
│ Good for beginners Must loop until EAGAIN │
│ • 性能稍低 • 适合高并发场景 │
│ Slightly lower perf Good for high concurrency │
│ │
│ 实际应用 (Real-world usage): │
│ • Nginx: 默认使用 ET 模式 │
│ • Redis: 使用 LT 模式 │
│ • libevent: 支持两种模式 │
│ │
└─────────────────────────────────────────────────────────────────┘
六、完整代码实现
下面是一个完整的 echo 服务器实现,展示了 epoll 的实际应用。代码使用现代 C++ 风格,采用 RAII 管理资源,使用边缘触发模式以获得最佳性能。
头文件和常量定义
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <memory>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// Server configuration constants
// 服务器配置常量
constexpr int PORT = 8080;
constexpr int MAX_EVENTS = 1024;
constexpr int BUFFER_SIZE = 4096;
工具函数:设置非阻塞模式
在使用边缘触发模式时,文件描述符必须设置为非阻塞模式。这是因为边缘触发只在状态变化时通知一次,如果使用阻塞 I/O,可能会导致程序在没有数据时阻塞,而后续到达的数据不会再触发通知。
// Set file descriptor to non-blocking mode
// This is REQUIRED for edge-triggered epoll
// 设置文件描述符为非阻塞模式
// 边缘触发 epoll 必须使用非阻塞模式
bool set_nonblocking(int fd) {
// Get current flags
// 获取当前标志
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
std::cerr << "fcntl F_GETFL failed: " << strerror(errno) << "\n";
return false;
}
// Add O_NONBLOCK flag
// 添加 O_NONBLOCK 标志
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
std::cerr << "fcntl F_SETFL failed: " << strerror(errno) << "\n";
return false;
}
return true;
}
FileDescriptor 类:RAII 封装
使用 RAII(Resource Acquisition Is Initialization)模式管理文件描述符,确保资源在对象生命周期结束时自动释放,避免资源泄漏。
// RAII wrapper for file descriptors
// Automatically closes fd when object is destroyed
// 文件描述符的 RAII 封装
// 对象销毁时自动关闭 fd
class FileDescriptor {
public:
FileDescriptor() : fd_(-1) {}
explicit FileDescriptor(int fd) : fd_(fd) {}
// Move constructor - transfer ownership
// 移动构造函数 - 转移所有权
FileDescriptor(FileDescriptor&& other) noexcept : fd_(other.fd_) {
other.fd_ = -1;
}
// Move assignment - transfer ownership
// 移动赋值 - 转移所有权
FileDescriptor& operator=(FileDescriptor&& other) noexcept {
if (this != &other) {
close();
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
// Disable copy to prevent double-close
// 禁用拷贝以防止重复关闭
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
// Destructor automatically closes fd
// 析构函数自动关闭 fd
~FileDescriptor() {
close();
}
void close() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
int get() const { return fd_; }
bool valid() const { return fd_ >= 0; }
// Release ownership without closing
// 释放所有权但不关闭
int release() {
int fd = fd_;
fd_ = -1;
return fd;
}
private:
int fd_;
};
Connection 类:客户端连接抽象
每个客户端连接被封装为一个 Connection 对象,包含文件描述符、连接信息和读写缓冲区。
// Represents a single client connection
// 表示单个客户端连接
class Connection {
public:
Connection(int fd, const std::string& addr, int port)
: fd_(fd), address_(addr), port_(port) {}
int fd() const { return fd_.get(); }
const std::string& address() const { return address_; }
int port() const { return port_; }
// Read data from connection
// Returns: >0 bytes read, 0 closed, -1 EAGAIN, -2 error
// 从连接读取数据
// 返回: >0 读取字节数, 0 关闭, -1 EAGAIN, -2 错误
ssize_t read(std::string& data) {
char buffer[BUFFER_SIZE];
ssize_t n = ::read(fd_.get(), buffer, sizeof(buffer));
if (n > 0) {
// Successfully read data
// 成功读取数据
data.append(buffer, n);
return n;
} else if (n == 0) {
// Peer closed connection (EOF)
// 对端关闭连接 (EOF)
return 0;
} else {
// n == -1, check errno
// n == -1, 检查 errno
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// No more data available in non-blocking mode
// 非阻塞模式下没有更多数据
return -1;
}
// Actual error occurred
// 发生实际错误
return -2;
}
}
// Write data to connection
// 向连接写入数据
ssize_t write(const std::string& data) {
return ::write(fd_.get(), data.c_str(), data.size());
}
private:
FileDescriptor fd_;
std::string address_;
int port_;
};
Epoll 类:epoll 操作封装
将 epoll 相关操作封装为类,提供更清晰的接口和更好的资源管理。
// Wrapper class for epoll operations
// epoll 操作的封装类
class Epoll {
public:
Epoll() = default;
// Create epoll instance
// 创建 epoll 实例
bool init() {
// epoll_create1(0) creates an epoll instance
// Returns a file descriptor referring to the new epoll instance
// epoll_create1(0) 创建 epoll 实例
// 返回引用新 epoll 实例的文件描述符
int fd = epoll_create1(0);
if (fd == -1) {
std::cerr << "epoll_create1 failed: " << strerror(errno) << "\n";
return false;
}
epfd_ = FileDescriptor(fd);
return true;
}
// Add fd to epoll interest list
// 将 fd 添加到 epoll 兴趣列表
bool add(int fd, uint32_t events) {
epoll_event ev{};
ev.events = events;
ev.data.fd = fd;
if (epoll_ctl(epfd_.get(), EPOLL_CTL_ADD, fd, &ev) == -1) {
std::cerr << "epoll_ctl ADD failed: " << strerror(errno) << "\n";
return false;
}
return true;
}
// Modify events for existing fd
// 修改已有 fd 的事件
bool modify(int fd, uint32_t events) {
epoll_event ev{};
ev.events = events;
ev.data.fd = fd;
if (epoll_ctl(epfd_.get(), EPOLL_CTL_MOD, fd, &ev) == -1) {
std::cerr << "epoll_ctl MOD failed: " << strerror(errno) << "\n";
return false;
}
return true;
}
// Remove fd from epoll
// 从 epoll 移除 fd
bool remove(int fd) {
// For EPOLL_CTL_DEL, the event parameter is ignored
// 对于 EPOLL_CTL_DEL,event 参数被忽略
if (epoll_ctl(epfd_.get(), EPOLL_CTL_DEL, fd, nullptr) == -1) {
std::cerr << "epoll_ctl DEL failed: " << strerror(errno) << "\n";
return false;
}
return true;
}
// Wait for events
// timeout: -1 block forever, 0 return immediately, >0 wait ms
// 等待事件
// timeout: -1 永久阻塞, 0 立即返回, >0 等待毫秒
int wait(std::vector<epoll_event>& events, int timeout = -1) {
int n = epoll_wait(epfd_.get(), events.data(), events.size(), timeout);
if (n == -1) {
if (errno == EINTR) {
// Interrupted by signal, not an error
// 被信号中断,不是错误
return 0;
}
std::cerr << "epoll_wait failed: " << strerror(errno) << "\n";
}
return n;
}
private:
FileDescriptor epfd_;
};
TcpServer 类:完整服务器实现
这是服务器的核心实现,整合了上述所有组件。
// Main TCP server class using epoll
// 使用 epoll 的主 TCP 服务器类
class TcpServer {
public:
explicit TcpServer(int port) : port_(port), running_(false) {}
~TcpServer() {
stop();
}
// Initialize server: create socket, bind, listen, setup epoll
// 初始化服务器:创建套接字,绑定,监听,设置 epoll
bool init() {
if (!create_listen_socket()) {
return false;
}
if (!epoll_.init()) {
return false;
}
// Add listening socket to epoll
// Only need EPOLLIN for accepting new connections
// 将监听套接字添加到 epoll
// 只需要 EPOLLIN 来接受新连接
if (!epoll_.add(listen_fd_.get(), EPOLLIN)) {
return false;
}
std::cout << "[Server] Listening on port " << port_ << "\n";
return true;
}
// Main event loop
// 主事件循环
void run() {
running_ = true;
std::vector<epoll_event> events(MAX_EVENTS);
while (running_) {
// Block until events are ready
// 阻塞直到有事件就绪
int nready = epoll_.wait(events, -1);
if (nready < 0) {
break;
}
// Process all ready events
// Note: only first nready elements are valid
// 处理所有就绪事件
// 注意:只有前 nready 个元素有效
for (int i = 0; i < nready; ++i) {
int fd = events[i].data.fd;
uint32_t ev = events[i].events;
if (fd == listen_fd_.get()) {
// Event on listening socket means new connection
// 监听套接字上的事件意味着新连接
handle_accept();
} else {
// Event on client socket
// 客户端套接字上的事件
handle_client_event(fd, ev);
}
}
}
}
void stop() {
running_ = false;
}
private:
// Create and configure the listening socket
// 创建并配置监听套接字
bool create_listen_socket() {
// Create TCP socket
// AF_INET: IPv4, SOCK_STREAM: TCP
// 创建 TCP 套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
std::cerr << "socket failed: " << strerror(errno) << "\n";
return false;
}
listen_fd_ = FileDescriptor(fd);
// Enable address reuse to avoid "Address already in use" error
// when restarting server quickly
// 启用地址重用,避免快速重启服务器时出现
// "Address already in use" 错误
int opt = 1;
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
std::cerr << "setsockopt failed: " << strerror(errno) << "\n";
return false;
}
// Configure server address
// 配置服务器地址
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY; // Accept on all interfaces
addr.sin_port = htons(port_); // Convert to network byte order
// Bind socket to address
// 绑定套接字到地址
if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
std::cerr << "bind failed: " << strerror(errno) << "\n";
return false;
}
// Start listening for connections
// SOMAXCONN: use system maximum backlog
// 开始监听连接
// SOMAXCONN: 使用系统最大积压队列
if (listen(fd, SOMAXCONN) == -1) {
std::cerr << "listen failed: " << strerror(errno) << "\n";
return false;
}
// Set non-blocking mode for edge-triggered epoll
// 为边缘触发 epoll 设置非阻塞模式
if (!set_nonblocking(fd)) {
return false;
}
return true;
}
// Accept all pending connections
// Must loop in edge-triggered mode
// 接受所有等待的连接
// 边缘触发模式下必须循环
void handle_accept() {
while (true) {
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(
listen_fd_.get(),
reinterpret_cast<sockaddr*>(&client_addr),
&client_len
);
if (client_fd == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// All pending connections have been accepted
// 所有等待的连接都已被接受
break;
}
std::cerr << "accept failed: " << strerror(errno) << "\n";
break;
}
// Get client information for logging
// 获取客户端信息用于日志
std::string client_ip = inet_ntoa(client_addr.sin_addr);
int client_port = ntohs(client_addr.sin_port);
std::cout << "[+] New connection: fd=" << client_fd
<< ", ip=" << client_ip
<< ", port=" << client_port << "\n";
// Set non-blocking mode
// 设置非阻塞模式
if (!set_nonblocking(client_fd)) {
::close(client_fd);
continue;
}
// Add to epoll with edge-triggered mode
// EPOLLIN: monitor for readable events
// EPOLLET: edge-triggered mode
// EPOLLRDHUP: monitor for peer close
// 使用边缘触发模式添加到 epoll
if (!epoll_.add(client_fd, EPOLLIN | EPOLLET | EPOLLRDHUP)) {
::close(client_fd);
continue;
}
// Store connection in our map
// 将连接保存到 map 中
connections_.emplace(
client_fd,
std::make_unique<Connection>(client_fd, client_ip, client_port)
);
}
}
// Handle events on client socket
// 处理客户端套接字上的事件
void handle_client_event(int fd, uint32_t events) {
auto it = connections_.find(fd);
if (it == connections_.end()) {
return;
}
Connection* conn = it->second.get();
// Check for errors or connection close
// EPOLLERR: error condition
// EPOLLHUP: hang up
// EPOLLRDHUP: peer closed or shutdown write
// 检查错误或连接关闭
if (events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
close_connection(fd);
return;
}
// Handle readable event
// 处理可读事件
if (events & EPOLLIN) {
handle_read(conn);
}
}
// Read all available data from client
// CRITICAL: Must loop until EAGAIN in edge-triggered mode!
// 从客户端读取所有可用数据
// 关键:边缘触发模式下必须循环直到 EAGAIN!
void handle_read(Connection* conn) {
std::string data;
while (true) {
ssize_t result = conn->read(data);
if (result > 0) {
// More data might be available, continue reading
// 可能还有更多数据,继续读取
continue;
} else if (result == 0) {
// Connection closed by peer
// 对端关闭连接
close_connection(conn->fd());
return;
} else if (result == -1) {
// EAGAIN: no more data available
// EAGAIN: 没有更多数据
break;
} else {
// Error occurred
// 发生错误
close_connection(conn->fd());
return;
}
}
// Echo data back to client
// 将数据回显给客户端
if (!data.empty()) {
std::cout << "[fd=" << conn->fd() << "] Received "
<< data.size() << " bytes\n";
conn->write(data);
}
}
// Close connection and cleanup resources
// 关闭连接并清理资源
void close_connection(int fd) {
auto it = connections_.find(fd);
if (it != connections_.end()) {
std::cout << "[-] Connection closed: fd=" << fd << "\n";
// Remove from epoll before closing fd
// 在关闭 fd 之前先从 epoll 移除
epoll_.remove(fd);
// Remove from map, destructor closes fd
// 从 map 移除,析构函数关闭 fd
connections_.erase(it);
}
}
private:
int port_;
bool running_;
FileDescriptor listen_fd_;
Epoll epoll_;
std::unordered_map<int, std::unique_ptr<Connection>> connections_;
};
主函数
int main(int argc, char* argv[]) {
int port = PORT;
if (argc > 1) {
port = std::stoi(argv[1]);
}
TcpServer server(port);
if (!server.init()) {
std::cerr << "Failed to initialize server\n";
return 1;
}
std::cout << "Echo server started. Press Ctrl+C to stop.\n";
std::cout << "Test with: nc localhost " << port << "\n";
server.run();
return 0;
}
七、编译与测试
编译
g++ -std=c++17 -O2 -Wall -o echo_server echo_server.cpp
运行服务器
./echo_server 8080
测试连接
在另一个终端使用 netcat 测试:
nc localhost 8080
输入任意文本并按回车,服务器会将输入内容原样返回。
多客户端测试
可以打开多个终端同时连接,验证服务器能够正确处理多个并发连接:
# Terminal 2
nc localhost 8080
# Terminal 3
nc localhost 8080
# Terminal 4
nc localhost 8080
八、实现注意点详解
注意点一:边缘触发模式必须循环读取直到 EAGAIN
这是使用边缘触发模式最容易犯的错误,也是最致命的错误。
┌─────────────────────────────────────────────────────────────────┐
│ 边缘触发必须循环读取 (Must Loop in ET Mode) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 错误做法 (Wrong): │
│ │
│ void handle_read(int fd) { │
│ char buf[1024]; │
│ int n = read(fd, buf, sizeof(buf)); // Only read once! │
│ // process... // 只读一次! │
│ } │
│ │
│ 问题 (Problem): │
│ If 5000 bytes arrive, only 1024 are read. │
│ 如果到达 5000 字节,只读取了 1024 字节。 │
│ Remaining 3976 bytes are LOST forever (no more notifications)! │
│ 剩余 3976 字节永久丢失(不会再有通知)! │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ✅ 正确做法 (Correct): │
│ │
│ void handle_read(int fd) { │
│ char buf[1024]; │
│ while (true) { // Loop! │
│ int n = read(fd, buf, sizeof(buf)); // 循环! │
│ │
│ if (n > 0) { │
│ // Process data, continue loop │
│ // 处理数据,继续循环 │
│ process(buf, n); │
│ continue; │
│ } │
│ else if (n == 0) { │
│ // EOF - peer closed │
│ // EOF - 对端关闭 │
│ close_connection(fd); │
│ return; │
│ } │
│ else { // n == -1 │
│ if (errno == EAGAIN || errno == EWOULDBLOCK) { │
│ // All data read, exit loop │
│ // 所有数据已读取,退出循环 │
│ break; // ← This is the key! │
│ } // 这是关键! │
│ // Other error │
│ // 其他错误 │
│ handle_error(fd); │
│ return; │
│ } │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────────────┘
在示例代码中的实现:
// handle_read 函数中的循环
// Loop in handle_read function
void handle_read(Connection* conn) {
std::string data;
// CRITICAL: Must loop until EAGAIN!
// 关键:必须循环直到 EAGAIN!
while (true) {
ssize_t result = conn->read(data);
if (result > 0) {
// More data might be available
// Continue reading to ensure we get all data
// 可能还有更多数据
// 继续读取以确保获取所有数据
continue;
} else if (result == 0) {
// EOF - connection closed by peer
// EOF - 对端关闭连接
close_connection(conn->fd());
return;
} else if (result == -1) {
// EAGAIN - no more data available right now
// This is the signal to exit the loop
// EAGAIN - 当前没有更多数据
// 这是退出循环的信号
break;
} else {
// Actual error
// 实际错误
close_connection(conn->fd());
return;
}
}
// Now we have ALL available data
// 现在我们拥有所有可用数据
if (!data.empty()) {
conn->write(data);
}
}
注意点二:非阻塞 I/O 是边缘触发的前提
边缘触发模式必须配合非阻塞 I/O 使用,这是一个硬性要求。
┌─────────────────────────────────────────────────────────────────┐
│ 非阻塞 I/O 的必要性 (Why Non-blocking I/O is Required) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 场景 (Scenario): │
│ Using blocking I/O + ET mode │
│ 使用阻塞 I/O + ET 模式 │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Time Action Result │ │
│ │ 时间 动作 结果 │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ T1 1000 bytes arrive epoll_wait returns EPOLLIN │ │
│ │ 1000 字节到达 epoll_wait 返回 EPOLLIN │ │
│ │ │ │
│ │ T2 read() 1000 bytes Success, got all data │ │
│ │ read() 1000 字节 成功,获取所有数据 │ │
│ │ │ │
│ │ T3 read() again BLOCKS! (blocking I/O) │ │
│ │ 再次 read() 阻塞!(阻塞 I/O) │ │
│ │ Waiting for more data... │ │
│ │ 等待更多数据... │ │
│ │ │ │
│ │ T4 Other fd has event CANNOT process! │ │
│ │ 其他 fd 有事件 无法处理! │ │
│ │ Thread is blocked on read() │ │
│ │ 线程在 read() 上阻塞 │ │
│ │ │ │
│ │ 💀 SERVER STUCK! 服务器卡住! │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 解决方案 (Solution): Non-blocking I/O │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Time Action Result │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ T1 1000 bytes arrive epoll_wait returns EPOLLIN │ │
│ │ │ │
│ │ T2 read() 1000 bytes Success │ │
│ │ │ │
│ │ T3 read() again Returns -1, errno=EAGAIN │ │
│ │ 返回 -1, errno=EAGAIN │ │
│ │ No blocking! │ │
│ │ 不阻塞! │ │
│ │ │ │
│ │ T4 Back to epoll_wait Can process other events │ │
│ │ 返回 epoll_wait 可以处理其他事件 │ │
│ │ │ │
│ │ ✅ SERVER RESPONSIVE! 服务器响应正常! │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
设置非阻塞模式的代码:
bool set_nonblocking(int fd) {
// fcntl - file control operations
// F_GETFL - get file status flags
// fcntl - 文件控制操作
// F_GETFL - 获取文件状态标志
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
return false;
}
// F_SETFL - set file status flags
// O_NONBLOCK - enable non-blocking mode
// F_SETFL - 设置文件状态标志
// O_NONBLOCK - 启用非阻塞模式
//
// After this:
// - read() returns -1 with errno=EAGAIN when no data
// - write() returns -1 with errno=EAGAIN when buffer full
// - accept() returns -1 with errno=EAGAIN when no connections
// 设置后:
// - read() 无数据时返回 -1,errno=EAGAIN
// - write() 缓冲区满时返回 -1,errno=EAGAIN
// - accept() 无连接时返回 -1,errno=EAGAIN
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
return false;
}
return true;
}
注意点三:正确处理 EINTR
epoll_wait 可能被信号中断,此时返回 -1 且 errno 为 EINTR。这不是错误,应该重新调用。
┌─────────────────────────────────────────────────────────────────┐
│ 处理 EINTR (Handling EINTR) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 什么是 EINTR?(What is EINTR?) │
│ │
│ When a signal (like SIGINT from Ctrl+C) arrives while │
│ epoll_wait is blocking, the system call is interrupted. │
│ │
│ 当信号(如 Ctrl+C 的 SIGINT)在 epoll_wait 阻塞时到达, │
│ 系统调用会被中断。 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Thread blocked in Signal arrives │ │
│ │ epoll_wait() 信号到达 │ │
│ │ │ │ │ │
│ │ │ ┌───────────────────┘ │ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ ┌───────────┐ │ │
│ │ └───▶│ Interrupt │ │ │
│ │ │ 中断 │ │ │
│ │ └─────┬─────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Returns -1 │ │
│ │ errno = EINTR │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 正确处理 (Correct Handling): │
│ │
│ int wait(std::vector<epoll_event>& events, int timeout) { │
│ int n = epoll_wait(epfd, events.data(), events.size(), │
│ timeout); │
│ │
│ if (n == -1) { │
│ if (errno == EINTR) { │
│ // Signal interrupted the call │
│ // This is NOT an error! │
│ // 信号中断了调用 │
│ // 这不是错误! │
│ return 0; // Return 0 to indicate "retry" │
│ // 返回 0 表示"重试" │
│ } │
│ // Actual error │
│ // 实际错误 │
│ std::cerr << "epoll_wait error: " << strerror(errno); │
│ return -1; │
│ } │
│ return n; │
│ } │
│ │
│ // In main loop: │
│ // 在主循环中: │
│ while (running_) { │
│ int nready = epoll_.wait(events, -1); │
│ │
│ if (nready < 0) { │
│ break; // Real error, exit │
│ } │
│ if (nready == 0) { │
│ continue; // EINTR, just retry │
│ } │
│ │
│ // Process events... │
│ } │
│ │
└─────────────────────────────────────────────────────────────────┘
注意点四:关闭连接时的清理顺序
关闭连接时,应该先从 epoll 移除文件描述符,再关闭它。虽然关闭文件描述符会自动将其从 epoll 中移除,但显式移除是更好的实践。
┌─────────────────────────────────────────────────────────────────┐
│ 关闭连接的正确顺序 (Correct Cleanup Order) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ⚠️ 潜在问题场景 (Potential Problem Scenario): │
│ │
│ Thread A: Thread B: │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ close(fd=5) │ │ fd=5 reused │ │
│ │ │ │ by accept() │ │
│ │ (auto │ │ │ │
│ │ removed │ │ epoll_ctl │ │
│ │ from │──────────────│ ADD fd=5 │ │
│ │ epoll) │ │ │ │
│ │ │ │ (same fd!) │ │
│ │ connections │ │ │ │
│ │ .erase(5) │ │ │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Race condition possible! │
│ 可能发生竞态条件! │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ✅ 正确做法 (Correct Approach): │
│ │
│ void close_connection(int fd) { │
│ auto it = connections_.find(fd); │
│ if (it != connections_.end()) { │
│ │
│ // Step 1: Remove from epoll FIRST │
│ // 第一步:先从 epoll 移除 │
│ epoll_.remove(fd); │
│ │
│ // Step 2: Remove from our data structure │
│ // This also closes the fd via RAII │
│ // 第二步:从数据结构移除 │
│ // 同时通过 RAII 关闭 fd │
│ connections_.erase(it); │
│ │
│ // Now fd is fully cleaned up │
│ // 现在 fd 已完全清理 │
│ } │
│ } │
│ │
│ 原因 (Reasons): │
│ 1. Explicit removal makes intent clear │
│ 显式移除使意图清晰 │
│ 2. Avoids subtle race conditions │
│ 避免微妙的竞态条件 │
│ 3. Better for debugging and logging │
│ 更便于调试和日志记录 │
│ │
└─────────────────────────────────────────────────────────────────┘
注意点五:使用 RAII 管理资源
文件描述符是系统资源,泄漏会导致服务器无法接受新连接。使用 RAII 封装确保资源在任何情况下都能正确释放。
┌─────────────────────────────────────────────────────────────────┐
│ RAII 资源管理 (RAII Resource Management) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 不使用 RAII 的问题 (Problems without RAII): │
│ │
│ void process_client(int listen_fd) { │
│ int client_fd = accept(listen_fd, ...); │
│ char* buffer = new char[4096]; │
│ │
│ if (some_error) { │
│ return; // LEAK! fd not closed, memory not freed │
│ } // 泄漏!fd 未关闭,内存未释放 │
│ │
│ read(client_fd, buffer, 4096); │
│ │
│ if (another_error) { │
│ close(client_fd); │
│ return; // LEAK! memory not freed │
│ } // 泄漏!内存未释放 │
│ │
│ delete[] buffer; │
│ close(client_fd); │
│ } │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ✅ 使用 RAII (With RAII): │
│ │
│ void process_client(int listen_fd) { │
│ FileDescriptor client_fd(accept(listen_fd, ...)); │
│ std::vector<char> buffer(4096); │
│ │
│ if (some_error) { │
│ return; // OK! Destructor closes fd, vector freed │
│ } // 没问题!析构函数关闭 fd,vector 释放 │
│ │
│ read(client_fd.get(), buffer.data(), buffer.size()); │
│ │
│ if (another_error) { │
│ return; // OK! Same as above │
│ } // 没问题!同上 │
│ │
│ // Automatic cleanup when function returns │
│ // 函数返回时自动清理 │
│ } │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ FileDescriptor 类设计要点 (FileDescriptor Design Points): │
│ │
│ class FileDescriptor { │
│ public: │
│ explicit FileDescriptor(int fd); // Take ownership │
│ // 获取所有权 │
│ │
│ ~FileDescriptor() { close(); } // Auto cleanup │
│ // 自动清理 │
│ │
│ // Move semantics - transfer ownership │
│ // 移动语义 - 转移所有权 │
│ FileDescriptor(FileDescriptor&&) noexcept; │
│ FileDescriptor& operator=(FileDescriptor&&) noexcept; │
│ │
│ // Delete copy - prevent double close │
│ // 删除拷贝 - 防止重复关闭 │
│ FileDescriptor(const FileDescriptor&) = delete; │
│ FileDescriptor& operator=(const FileDescriptor&) = delete; │
│ │
│ int get() const; // Access raw fd │
│ int release(); // Release without closing │
│ }; │
│ │
└─────────────────────────────────────────────────────────────────┘
注意点六:SO_REUSEADDR 选项
服务器重启时,之前的 TCP 连接可能还处于 TIME_WAIT 状态,导致 bind 失败。
┌─────────────────────────────────────────────────────────────────┐
│ SO_REUSEADDR 的作用 (Purpose of SO_REUSEADDR) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ TCP TIME_WAIT 状态 (TCP TIME_WAIT State): │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Server Network Client │ │
│ │ │ │ │ │
│ │ │◀─────────── FIN ──────────────────────│ │ │
│ │ │ │ │ │
│ │ │──────────── ACK ────────────────────▶│ │ │
│ │ │ │ │ │
│ │ │──────────── FIN ────────────────────▶│ │ │
│ │ │ │ │ │
│ │ │◀─────────── ACK ────────────────────│ │ │
│ │ │ │ │ │
│ │ TIME_WAIT │ │ │
│ │ (2*MSL, │ │ │
│ │ ~60 sec) │ │ │
│ │ │ │ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 问题 (Problem): │
│ │
│ $ ./server │
│ [Server] Listening on port 8080 │
│ ^C # Stop server │
│ │
│ $ ./server │
│ bind failed: Address already in use ← TIME_WAIT 阻止绑定 │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 解决方案 (Solution): │
│ │
│ int opt = 1; │
│ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); │
│ │
│ // What SO_REUSEADDR does: │
│ // SO_REUSEADDR 的作用: │
│ // │
│ // 1. Allow binding to address in TIME_WAIT state │
│ // 允许绑定到 TIME_WAIT 状态的地址 │
│ // │
│ // 2. Allow multiple sockets to bind to same port │
│ // (with different local addresses) │
│ // 允许多个套接字绑定到同一端口(使用不同本地地址) │
│ // │
│ // 3. Essential for server restart without waiting │
│ // 服务器不等待立即重启的必要选项 │
│ │
│ ⚠️ 注意 (Note): │
│ Also consider SO_REUSEPORT for load balancing scenarios │
│ 负载均衡场景下也考虑 SO_REUSEPORT │
│ │
└─────────────────────────────────────────────────────────────────┘
注意点七:EPOLLRDHUP 事件的使用
EPOLLRDHUP 提供了更快检测连接关闭的方式。
┌─────────────────────────────────────────────────────────────────┐
│ EPOLLRDHUP 的优势 (Benefits of EPOLLRDHUP) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 不使用 EPOLLRDHUP (Without EPOLLRDHUP): │
│ │
│ 检测对端关闭的唯一方式是 read() 返回 0 │
│ The only way to detect peer close is read() returning 0 │
│ │
│ // Add to epoll │
│ epoll_ctl(epfd, EPOLL_CTL_ADD, fd, {EPOLLIN | EPOLLET}); │
│ │
│ // In event loop │
│ if (events & EPOLLIN) { │
│ int n = read(fd, buf, size); │
│ if (n == 0) { │
│ // Peer closed - but we had to call read() to know │
│ // 对端关闭 - 但我们必须调用 read() 才知道 │
│ close_connection(fd); │
│ } │
│ } │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ✅ 使用 EPOLLRDHUP (With EPOLLRDHUP): │
│ │
│ // Add to epoll │
│ epoll_ctl(epfd, EPOLL_CTL_ADD, fd, │
│ {EPOLLIN | EPOLLET | EPOLLRDHUP}); │
│ ───────────── │
│ │ │
│ └──── Monitor peer close explicitly │
│ 显式监控对端关闭 │
│ │
│ // In event loop │
│ if (events & EPOLLRDHUP) { │
│ // Peer closed - detected immediately without read() │
│ // 对端关闭 - 无需 read() 立即检测到 │
│ close_connection(fd); │
│ return; │
│ } │
│ │
│ if (events & EPOLLIN) { │
│ // Normal read handling │
│ // 正常读取处理 │
│ } │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ EPOLLRDHUP 触发场景 (When EPOLLRDHUP triggers): │
│ │
│ 1. Peer calls close() │
│ 对端调用 close() │
│ │
│ 2. Peer calls shutdown(fd, SHUT_WR) │
│ 对端调用 shutdown(fd, SHUT_WR) │
│ │
│ 3. Peer process crashes │
│ 对端进程崩溃 │
│ │
│ 4. Network disconnection detected │
│ 检测到网络断开 │
│ │
└─────────────────────────────────────────────────────────────────┘
注意点八:accept 也需要循环
在边缘触发模式下,当监听套接字可读时,可能有多个连接同时到达,必须循环调用 accept 直到返回 EAGAIN。
┌─────────────────────────────────────────────────────────────────┐
│ accept 循环 (Loop in accept) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 场景 (Scenario): │
│ Multiple clients connect simultaneously │
│ 多个客户端同时连接 │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Client A ──┐ │ │
│ │ │ │ │
│ │ Client B ──┼──────▶ Server (listening) │ │
│ │ │ │ │
│ │ Client C ──┘ │ │
│ │ │ │
│ │ All 3 connect requests arrive before server │ │
│ │ calls epoll_wait │ │
│ │ 在服务器调用 epoll_wait 之前,3 个连接请求都到达 │ │
│ │ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ❌ 错误做法 (Wrong): │
│ │
│ void handle_accept() { │
│ int client_fd = accept(listen_fd, ...); // Gets client A │
│ // Add to epoll... │
│ } │
│ // Client B and C are LOST! 客户端 B 和 C 丢失! │
│ // No more EPOLLIN notification until NEW connection! │
│ // 直到新连接才会有 EPOLLIN 通知! │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ✅ 正确做法 (Correct): │
│ │
│ void handle_accept() { │
│ while (true) { // Loop until EAGAIN! │
│ // 循环直到 EAGAIN! │
│ int client_fd = accept(listen_fd, ...); │
│ │
│ if (client_fd == -1) { │
│ if (errno == EAGAIN || errno == EWOULDBLOCK) { │
│ // All pending connections accepted │
│ // 所有等待的连接都已接受 │
│ break; │
│ } │
│ // Handle error │
│ break; │
│ } │
│ │
│ // Process client_fd... │
│ set_nonblocking(client_fd); │
│ epoll_.add(client_fd, EPOLLIN | EPOLLET | EPOLLRDHUP); │
│ } │
│ } │
│ // All 3 clients properly accepted! │
│ // 3 个客户端都正确接受! │
│ │
└─────────────────────────────────────────────────────────────────┘
九、常见错误与调试
┌─────────────────────────────────────────────────────────────────┐
│ 常见错误汇总 (Common Mistakes) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 错误 1: 忘记设置非阻塞模式 │
│ Mistake 1: Forgetting to set non-blocking mode │
│ 症状: 服务器在某些时候无响应 │
│ Symptom: Server becomes unresponsive │
│ 解决: 确保所有 fd 都调用 set_nonblocking() │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 错误 2: ET 模式下不循环读取 │
│ Mistake 2: Not looping read in ET mode │
│ 症状: 数据丢失,消息不完整 │
│ Symptom: Data loss, incomplete messages │
│ 解决: while 循环读取直到 EAGAIN │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 错误 3: 重复关闭文件描述符 │
│ Mistake 3: Double-closing file descriptors │
│ 症状: 随机崩溃,文件描述符错乱 │
│ Symptom: Random crashes, fd confusion │
│ 解决: 使用 RAII 封装,禁用拷贝 │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 错误 4: 忽略 EINTR │
│ Mistake 4: Ignoring EINTR │
│ 症状: Ctrl+C 后服务器异常退出 │
│ Symptom: Server exits abnormally after Ctrl+C │
│ 解决: 检查 errno == EINTR 并重试 │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 错误 5: 文件描述符泄漏 │
│ Mistake 5: File descriptor leaks │
│ 症状: "Too many open files" 错误 │
│ Symptom: "Too many open files" error │
│ 解决: 使用 RAII,确保所有路径都关闭 fd │
│ 调试: lsof -p <pid> | wc -l 查看打开的 fd 数量 │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 错误 6: 没有处理 partial write │
│ Mistake 6: Not handling partial write │
│ 症状: 大数据发送不完整 │
│ Symptom: Large data sent incompletely │
│ 解决: 检查 write 返回值,使用写缓冲区 │
│ │
└─────────────────────────────────────────────────────────────────┘
十、性能优化建议
在实际生产环境中,还可以考虑以下优化:
第一,使用线程池处理业务逻辑。I/O 事件循环只负责接收和发送数据,实际的业务处理交给线程池,避免阻塞事件循环。
第二,使用对象池减少内存分配。频繁的 new/delete 或 malloc/free 会产生内存碎片,使用对象池可以显著提高性能。
第三,正确处理写操作。本文的示例简化了写操作,实际应用中写操作也可能返回 EAGAIN,需要使用写缓冲区并监听 EPOLLOUT 事件。
第四,考虑使用 EPOLLONESHOT。在多线程环境下,使用 EPOLLONESHOT 可以避免同一个文件描述符的事件被多个线程同时处理。
第五,合理设置 TCP 参数。根据应用场景设置 TCP_NODELAY、SO_SNDBUF、SO_RCVBUF 等参数。
第六,使用 EPOLLEXCLUSIVE(Linux 4.5+)。在多线程 accept 场景下,避免惊群效应。
十一、总结
epoll 是 Linux 下高性能网络编程的基础,理解其工作原理和正确使用方式对于开发高并发服务器至关重要。
┌─────────────────────────────────────────────────────────────────┐
│ 关键要点总结 │
│ Key Takeaways │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. epoll 三个核心 API: │
│ epoll_create1() - 创建实例 │
│ epoll_ctl() - 管理文件描述符 │
│ epoll_wait() - 等待事件 │
│ │
│ 2. 两种触发模式: │
│ LT (Level Triggered) - 简单可靠,性能稍低 │
│ ET (Edge Triggered) - 高性能,编程复杂 │
│ │
│ 3. ET 模式的铁律: │
│ • 必须使用非阻塞 I/O │
│ • 必须循环读/写直到 EAGAIN │
│ • accept 也要循环 │
│ │
│ 4. 资源管理: │
│ • 使用 RAII 管理文件描述符 │
│ • 先从 epoll 移除,再关闭 fd │
│ • 设置 SO_REUSEADDR 便于重启 │
│ │
│ 5. 错误处理: │
│ • EINTR 不是错误,重试即可 │
│ • EAGAIN/EWOULDBLOCK 表示操作完成 │
│ • 使用 EPOLLRDHUP 检测对端关闭 │
│ │
└─────────────────────────────────────────────────────────────────┘
掌握 epoll 后,你就拥有了构建高性能网络服务的基础能力。在此基础上,可以进一步学习 Reactor 模式、Proactor 模式,以及 libevent、libev、libuv 等事件库的实现原理。
更多推荐

所有评论(0)