2002 年 linux 2.5.44 发布了 epoll, epoll 是 linux 实现高并发网络 I/O 处理的基础, 但凡存在网络 I/O 的系统或框架 (比如 nginx、netty、kafka、redis 等) 都离不开 epoll;
因此学习 epoll 的原理对于应用开发者理解网络框架的运行原理有着巨大的帮助;
前置基础知识
DMA 与中断
当 NIC 网卡收到网络数据时的处理流程:
- 网卡通过 Direct Memory Access 将数据写到内核内存 (Ring Buffer 和 sk_buff);
- 网卡向 CPU 发出中断请求 (Interrupt Request, IRQ);
- 为解决频繁 IRQ 导致大量上下文切换的开销, 内核采用了请求预聚合技术 (N-API, 即 New API):
- 内核的 napi_schedule 函数专门快速响应 IRQ, 只记录必要信息, 并在合适的时机发出软中断 softirq;
- 内核的 net_rx_action 函数响应软中断, 从 Ring Buffer 中批量拉取收到的数据并处理协议栈, 填充 socket 再交付上层;net data transfer
linux: 一切皆文件
在 linux 系统中 “一切皆文件 (Everything is a file)” 是一个核心的设计理念,
linux 将所有资源 (无论是普通文件、目录、设备、网络套接字还是进程信息) 都抽象为文件的形式, 并以一个抽象层 (Virtual File System) 统一管理, 使得用户可以用统一的文件描述符处理各种资源:
- 普通文件:如文本文件、图片文件等;
- 目录:目录本身也被视为一种特殊的文件;
- 设备:硬件设备(如硬盘、键盘、显示器)被抽象为设备文件;
- 管道和套接字:用于进程间通信的管道和网络通信的套接字也可以像文件一样操作;
- 系统信息:例如 /proc 和 /sys 文件系统中的内容,提供对系统状态和内核参数的访问;
当我们使用 epoll 时, 内核会创建一个 struct eventpoll
, eventpoll 也是文件系统中的一种资源类型;
文件系统的 wait queue
在 Linux 文件系统中,等待队列是一种内核同步机制,用于管理因某些条件未满足而需要等待的进程; 它广泛应用于文件 I/O 操作、设备驱动程序和网络通信中,尤其是在处理阻塞操作时:
- 当读取文件时,如果文件的数据尚未准备好 (如管道或设备文件), 进程会被加入到等待队列中;
- 当写入文件时,如果缓冲区已满,进程也会被加入到等待队列中;
当条件满足时 (如数据到达或缓冲区有空间可用) 内核会唤醒等待队列中的进程;
当我们使用 epoll 时, 我们主要与两种文件类型打交道: eventpoll 和 socket, 他们分别有自己的等待队列 (wq), 存放不同类型的资源:
- eventpoll 队列: 存放的是等待 IO 事件到来的进程;
- socket 队列: 存放的是 struct eventpoll;
linux 内核中通常用 wait_queue_head_t
双向链表类型来定义等待队列;
epoll 的数据结构
eventpoll
eventpoll 是 epoll 核心数据结构, 用于管理一个 epoll 实例的所有相关信息:1
2
3
4
5
6
7
8
9
10
11
12
13
14struct eventpoll {
struct rb_root rbr; // 红黑树,存储所有注册的 epitem
struct list_head rdllist; // 就绪链表,存储当前就绪的 epitem
wait_queue_head_t wq; // 等待队列,用于阻塞调用 epoll_wait() 的进程
spinlock_t lock; // 保护 eventpoll 对象的自旋锁
struct mutex mtx; // 互斥锁,用于保护某些关键操作
wait_queue_head_t poll_wait; // 用于支持嵌套 epoll (epoll over epoll)
struct epitem *ovflist; // 溢出链表,用于处理边缘触发模式下的特殊情况
struct user_struct *user; // 用户信息,用于资源限制跟踪
struct file *file; // 指向与该 epoll 实例关联的文件对象
int visited; // 用于标记是否访问过,避免循环嵌套
...
};
eventpoll 结构体主要成员如下:
- rbr: 用于记录用户注册的其所感兴趣的 socket 事件, 使用红黑树结构, 方便后续查找;
- rdllist: 当向 epoll 注册的 socket 事件发生后, 就绪队列会记录相关 socket 的读/写事件并返回给用户进程;
- wq: 当用户进程调用 epoll_wait 进入休眠后, 被挂在该等待队列, 用于注册事件发生时回调唤醒该进程;
epitem
epitem 是 epoll 另一个核心数据结构, 承载了相关文件描述符 (通常为 socket 描述符) 及其事件订阅信息:
- ffd: 通过 ffd.fd 可以获取目标文件描述符;
- event: 事件类型, 常用如下: EPOLLIN (socket 可读), EPOLLOUT (socket 可写)
1
2
3
4
5
6
7
8
9
10struct epitem {
struct epoll_filefd ffd; // 文件描述符及其相关信息
struct epoll_event event; // 用户注册的事件信息
struct rb_node rbn; // 红黑树节点, 用于插入到红黑树 (rbr) 中
struct list_head rdllink; // 就绪链表节点, 用于插入到就绪链表 (rdllist) 中
struct eventpoll *ep; // 指向所属的 eventpoll 对象
...
};
除此之外, epitem 还有两个关键的字段:
- rbn: 作为一个 rb_node 类型 (linux 内核中通用的红黑树节点结构体), 它是 epitem 的字段, 同时也是 eventpoll rbr 红黑树的节点, eventpoll 通过内核中的一个通用宏定义
container_of
, 可依据局部字段的地址计算出整个结构体的地址, 从而定位到 epitem; - rdllink: 作为一个 list_head 类型 (linux 内核中通用的双向链表节点结构体), 与 rbn 类似, 它是 epitem 的字段, 同时也是 eventpoll rdllist 双向链表的节点, eventpoll 同样可以基于
container_of
根据 rdllink 字段定位到 epitem;
可以看到, epoll 的数据结构非常紧凑, eventpoll 的 rbr 和 rdllist 都是基于对 epitem 的引用构建的, 以最大限度地提升性能和节约内存:
epoll 数据结构的选型
有很多文章说, eventpoll 使用红黑树管理 socket 事件订阅只是一种实现方式, 也可以换成哈希表等其他高效的内存结构, 个人认为这是一个值得讨论的问题;
首先我们先梳理一下 epoll 面临的业务场景:
- 大量、高并发的 socket 请求: epoll 通常被服务端用于处理高并发的海量用户请求, 对低延迟要求很高;
- 低活跃的单个 socket 对象: 虽然有海量的请求连接到服务器, 但是对每一个具体的 socket 对象, 却活跃度很低, 在其生命周期的大部分时间内都不会产生 IO 事件;
- 朝生夕死的 socket 对象: socket 对象通常不会存活很久, 用户请求结束后会关闭连接;
总结一下业务特点就是:
- 存在 socket 的大量新增;
- 存在 socket 的大量删除;
- 对 socket 事件的响应处理必须要快;
- 虽然 socket 总量很大, 但是同一时刻发生 IO 事件的 socket 占总量比例不高;
再来看下不同数据结构对以上业务特点的支持能力:
- 红黑树:
- 优点:
- 依托于链式结构无需大块的连续内存, 不管当前树结构有多么庞大, 创建新节点的成本都极低, 只要能找到一块可用内存就行;
- 删除节点的成本也相对可控, 通过有限的旋转和重新着色即可维护树的平衡;
- 缺点:
- 查询时间复杂度是 O(logn), 当节点数量巨大, 查询性能会受一定影响;
- 优点:
- 哈希表:
- 优点:
- 当内存足够大时, 查询 socket 的时间复杂度为 O(1);
- 缺点:
- 需要申请一大片连续的内存用作文件描述符到 socket 的映射, 不够灵活;
- 当 socket 数量不断增长, hash 冲突的概率越大, 查询复杂度会劣化 (最差可劣化到 O(n));
- 如果需要扩容哈希表以减少 key 冲突, 需要申请一片新的连续内存, 并将旧的数据复制过去, 开销很大;
- 当请求流量进入低谷期时, 已扩容的哈希表如果不缩容, 会造成内存浪费; 如果缩容, 则又涉及数据复制, 增大开销;
- 优点:
综合考虑: 我们需要的理想数据结构是:
- 增删 socket 订阅事件、维护索引的成本要足够低;
- socket 事件订阅查询效率要尽可能高, 且要保持查询性能稳定, 不能因 socket 数量规模的急剧变化而产生明显劣化;
因此 linux 内核选择了查询性能十分稳定、对内存使用效率更高、使用方式更灵活的红黑树作为 eventpoll 索引 socket 订阅事件的数据结构;
补充: 内核常用结构和宏定义
rb_node: 内核通用红黑树节点
1
2
3
4
5struct rb_node {
unsigned long __rb_parent_color; // 父节点指针和颜色标志
struct rb_node *rb_right; // 右子节点指针
struct rb_node *rb_left; // 左子节点指针
};rb_root: 内核通用红黑树根
1
2
3struct rb_root {
struct rb_node *rb_node; // 指向红黑树的根节点
};list_head: 内核通用双向链表节点
1
2
3
4struct list_head {
struct list_head *next; // 指向下一个节点
struct list_head *prev; // 指向前一个节点
};
container_of 宏定义:1
2
3
4
5
6
7
8/**
* @param ptr:指向结构体中某个字段的指针 (如 rb_node 的地址)
* @param type:结构体的类型 (如 struct epitem)
* @param member:字段的名称 (如 rbn)
*/
const typeof(((type *)0)->member) *__mptr = (ptr); \
(type *)((char *)__mptr - offsetof(type, member)); })
epoll 的执行逻辑
一般来说, 用户使用 epoll 的逻辑是:
- 调用 epoll_create, 创建对象;
- 调用 epoll_ctl, 订阅感兴趣的 socket 事件;
- 调用 epoll_wait, 等待订阅事件的发生;
epoll_create
1 |
|
进程调用 epoll_create 方法, 内核会创建出一个 struct eventpoll, 注册到文件列表中, 并将其文件描述符 epfd 返回给进程;
epoll_ctl
1 |
|
进程调用 epoll_ctl, 内核主要做了三件事:
- 在内存中创建一个 struct epitem;
- 插入目标 epitem 的 rbn 节点至 eventpoll 的 rbr 红黑树, 从而建立对 socket 事件订阅的高效查询;
- 将 struct eventpoll 插入到目标 socket 等待队列, 用于 socket 接收到数据时回调以唤醒调用 epoll 线程;
epoll_wait
1 |
|
关于 timeout 的设置:
- timeout > 0: 如果在指定的时间内没有事件发生, epoll_wait 将阻塞最多 timeout 毫秒;
如果在此期间有事件发生则立即返回, 且返回值为触发事件的数量; - timeout == 0: 不阻塞而是立即返回, 这种模式通常用于非阻塞检查, 适合需要快速轮询的场景;
- timeout == -1: 无限期阻塞, 直到至少有一个事件发生, 这是最常用的模式, 适合不需要定时功能的场景;
绝大多数 I/O 多路复用场景, 尤其是长时间运行的服务端程序都应该是这个模式;
进程调用 epoll_wait 的过程:
- 如果没有注册的 socket 事件触发, 则进程被挂到 struct eventpoll 的等待队列, 然后休眠: epoll_wait_sleep
- 如果有注册的 socket 事件触发, 中断程序会唤醒 eventpoll 等待队列中的进程, 同时将目标 socket 挂到 eventpoll 的 rdllist 上并返回给目标进程:
整体事件/数据流
- 事件流图:
- 红色数字: 调用 epoll_wait 并被挂起的执行顺序;
- 蓝色字母: socket 事件触发唤醒进程并返回结果的执行顺序;epoll_wait events flow
- 数据流图:
- 有 socket 事件触发, 从 rbr 中查找是否有注册的 socket 事件与之匹配;
- 匹配到了 socket 事件, 则将 rbr 上对应的 epitem 选出, 组装到 rdlist 链中返回给应用进程;epoll_wait data flow
epoll 的触发模式
水平触发 (LT)
Level Trigger, 适合需要确保所有数据都被读取的场景:
当调用 epoll_wait 没有全部读完 socket 缓冲区, 下一次调用 epoll_wait 依然能够检测到 socket 就绪事件, 直到 socket 缓冲区数据被全部读完为止;
边缘触发 (ET)
Edge Trigger, 适合高效快速响应的场景:
当 socket 接收数据后, epoll rdlist 只会插入一次 socket 就绪事件, epoll_wait 检测到 socket 读事件后, 必须一次性把 socket 缓冲区数据全部读完, 否则数据可能丢失;