【C/C++】Linux epoll详解与实战
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>intepoll_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 - 管理监控的文件描述符
intepoll_ctl(int epfd,int op,int fd,structepoll_event*event);此函数用于添加、修改或删除被监控的文件描述符。
epfd 是 epoll_create1 返回的 epoll 文件描述符。
op 参数指定操作类型,有三个可选值:EPOLL_CTL_ADD 用于注册新的文件描述符到 epoll 实例,EPOLL_CTL_MOD 用于修改已注册的文件描述符的关注事件,EPOLL_CTL_DEL 用于从 epoll 实例中删除文件描述符。
fd 是要操作的目标文件描述符。
event 是指向 epoll_event 结构的指针,描述要监控的事件和关联的数据。
structepoll_event{uint32_t events;// Epoll events (EPOLLIN, EPOLLOUT, etc.)// 事件类型 epoll_data_t data;// User data variable// 用户数据};typedefunion epoll_data {void*ptr;// Pointer to user-defined data// 指向用户定义数据的指针int fd;// File descriptor// 文件描述符uint32_t u32;// 32-bit integeruint64_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 - 等待事件发生
intepoll_wait(int epfd,structepoll_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// 服务器配置常量constexprint PORT =8080;constexprint MAX_EVENTS =1024;constexprint BUFFER_SIZE =4096;工具函数:设置非阻塞模式
在使用边缘触发模式时,文件描述符必须设置为非阻塞模式。这是因为边缘触发只在状态变化时通知一次,如果使用阻塞 I/O,可能会导致程序在没有数据时阻塞,而后续到达的数据不会再触发通知。
// Set file descriptor to non-blocking mode// This is REQUIRED for edge-triggered epoll// 设置文件描述符为非阻塞模式// 边缘触发 epoll 必须使用非阻塞模式boolset_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";returnfalse;}// Add O_NONBLOCK flag// 添加 O_NONBLOCK 标志if(fcntl(fd, F_SETFL, flags | O_NONBLOCK)==-1){ std::cerr <<"fcntl F_SETFL failed: "<<strerror(errno)<<"\n";returnfalse;}returntrue;}FileDescriptor 类:RAII 封装
使用 RAII(Resource Acquisition Is Initialization)模式管理文件描述符,确保资源在对象生命周期结束时自动释放,避免资源泄漏。
// RAII wrapper for file descriptors// Automatically closes fd when object is destroyed// 文件描述符的 RAII 封装// 对象销毁时自动关闭 fdclassFileDescriptor{public:FileDescriptor():fd_(-1){}explicitFileDescriptor(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();}voidclose(){if(fd_ >=0){::close(fd_); fd_ =-1;}}intget()const{return fd_;}boolvalid()const{return fd_ >=0;}// Release ownership without closing// 释放所有权但不关闭intrelease(){int fd = fd_; fd_ =-1;return fd;}private:int fd_;};Connection 类:客户端连接抽象
每个客户端连接被封装为一个 Connection 对象,包含文件描述符、连接信息和读写缓冲区。
// Represents a single client connection// 表示单个客户端连接classConnection{public:Connection(int fd,const std::string& addr,int port):fd_(fd),address_(addr),port_(port){}intfd()const{return fd_.get();}const std::string&address()const{return address_;}intport()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;}elseif(n ==0){// Peer closed connection (EOF)// 对端关闭连接 (EOF)return0;}else{// n == -1, check errno// n == -1, 检查 errnoif(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 操作的封装类classEpoll{public:Epoll()=default;// Create epoll instance// 创建 epoll 实例boolinit(){// 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";returnfalse;} epfd_ =FileDescriptor(fd);returntrue;}// Add fd to epoll interest list// 将 fd 添加到 epoll 兴趣列表booladd(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";returnfalse;}returntrue;}// Modify events for existing fd// 修改已有 fd 的事件boolmodify(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";returnfalse;}returntrue;}// Remove fd from epoll// 从 epoll 移除 fdboolremove(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";returnfalse;}returntrue;}// Wait for events// timeout: -1 block forever, 0 return immediately, >0 wait ms// 等待事件// timeout: -1 永久阻塞, 0 立即返回, >0 等待毫秒intwait(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// 被信号中断,不是错误return0;} std::cerr <<"epoll_wait failed: "<<strerror(errno)<<"\n";}return n;}private: FileDescriptor epfd_;};TcpServer 类:完整服务器实现
这是服务器的核心实现,整合了上述所有组件。
// Main TCP server class using epoll// 使用 epoll 的主 TCP 服务器类classTcpServer{public:explicitTcpServer(int port):port_(port),running_(false){}~TcpServer(){stop();}// Initialize server: create socket, bind, listen, setup epoll// 初始化服务器:创建套接字,绑定,监听,设置 epollboolinit(){if(!create_listen_socket()){returnfalse;}if(!epoll_.init()){returnfalse;}// Add listening socket to epoll// Only need EPOLLIN for accepting new connections// 将监听套接字添加到 epoll// 只需要 EPOLLIN 来接受新连接if(!epoll_.add(listen_fd_.get(), EPOLLIN)){returnfalse;} std::cout <<"[Server] Listening on port "<< port_ <<"\n";returntrue;}// Main event loop// 主事件循环voidrun(){ 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);}}}}voidstop(){ running_ =false;}private:// Create and configure the listening socket// 创建并配置监听套接字boolcreate_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";returnfalse;} 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";returnfalse;}// 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";returnfalse;}// Start listening for connections// SOMAXCONN: use system maximum backlog// 开始监听连接// SOMAXCONN: 使用系统最大积压队列if(listen(fd, SOMAXCONN)==-1){ std::cerr <<"listen failed: "<<strerror(errno)<<"\n";returnfalse;}// Set non-blocking mode for edge-triggered epoll// 为边缘触发 epoll 设置非阻塞模式if(!set_nonblocking(fd)){returnfalse;}returntrue;}// Accept all pending connections// Must loop in edge-triggered mode// 接受所有等待的连接// 边缘触发模式下必须循环voidhandle_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// 使用边缘触发模式添加到 epollif(!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// 处理客户端套接字上的事件voidhandle_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!voidhandle_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;}elseif(result ==0){// Connection closed by peer// 对端关闭连接close_connection(conn->fd());return;}elseif(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// 关闭连接并清理资源voidclose_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_;};主函数
intmain(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";return1;} std::cout <<"Echo server started. Press Ctrl+C to stop.\n"; std::cout <<"Test with: nc localhost "<< port <<"\n"; server.run();return0;}七、编译与测试
编译
g++ -std=c++17 -O2 -Wall -o echo_server echo_server.cpp 运行服务器
./echo_server 8080测试连接
在另一个终端使用 netcat 测试:
nc localhost 8080输入任意文本并按回车,服务器会将输入内容原样返回。
多客户端测试
可以打开多个终端同时连接,验证服务器能够正确处理多个并发连接:
# Terminal 2nc localhost 8080# Terminal 3nc localhost 8080# Terminal 4nc 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 functionvoidhandle_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;}elseif(result ==0){// EOF - connection closed by peer// EOF - 对端关闭连接close_connection(conn->fd());return;}elseif(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! 服务器响应正常! │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ 设置非阻塞模式的代码:
boolset_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){returnfalse;}// 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=EAGAINif(fcntl(fd, F_SETFL, flags | O_NONBLOCK)==-1){returnfalse;}returntrue;}注意点三:正确处理 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 等事件库的实现原理。