一、io_uring 的设计初衷回顾
io_uring 的核心目标是:
- 减少系统调用次数、减少上下文切换、减少内核/用户态交互成本
为此,io_uring 提供了:
- 共享内存队列(SQ / CQ)
- 批量提交 / 批量完成
- 异步执行 + 事件驱动
- 更接近“完成端口”的编程模型
但在最初的 io_uring 设计中,一个 SQE ≈ 一次完成(CQE),这在某些高频事件场景下仍然存在明显瓶颈。
二、为什么需要 Multishot?
2.1 单次(oneshot)模型的痛点
典型模式(以 socket recv / poll 为例):
提交 SQE
↓
事件到来 → 生成一个 CQE
↓
用户态处理
↓
重新提交 SQE
问题在于:
- 每次事件都要 重新提交 SQE
- 高频场景(网络 IO / poll / accept)下:
- SQ 压力大
- 提交路径频繁进入内核
- cache / 分支预测效率下降
这在本质上与 epoll 的 “一次注册,多次触发” 模型不一致。
2.2 Multishot 的核心思想
一次提交,多次完成,即:
- 一个 SQE 在生命周期内
- 可以持续产生多个 CQE
- 直到条件失效 / 错误发生 / 被显式取消
这就是 multishot。
三、Multishot 在 io_uring.h 中的总体抽象
从头文件可以清楚看到:multishot 并不是一个“统一开关”,而是按操作类型分别支持。
三个关键要素:
- SQE 上的 multishot 标志
- CQE 上的 IORING_CQE_F_MORE
- 明确支持 multishot 的 opcode
四、CQE 侧:Multishot 的核心语义标志
IORING_CQE_F_MORE
#define IORING_CQE_F_MORE (1U << 1)
语义非常关键:当前 CQE 不是“最终完成”,该 SQE 后续还会产生 CQE。
这是用户态判断 multishot 生命周期的唯一权威信号。
用户态处理逻辑示意:
if (cqe->flags & IORING_CQE_F_MORE) {
// SQE 仍然有效,不需要重新提交
} else {
// SQE 生命周期结束
}
⚠️ 注意:
- 用户态 不能假设 multishot 永远存在
- 必须以 F_MORE 为准
五、具体支持 Multishot 的几类操作
5.1 Poll:最接近 epoll 的 multishot
SQE 标志
#define IORING_POLL_ADD_MULTI (1U << 0)
语义
- 注册一次 poll;
- 每次事件就绪:
- 产生一个 CQE
- IORING_CQE_F_MORE 置位
- 行为等价于 epoll level-triggered / edge-triggered 的结合体。
5.2 Recv:高性能网络 IO 的关键
标志定义
#define IORING_RECV_MULTISHOT (1U << 1)
特点
- 一个 IORING_OP_RECV / RECVMSG
- 持续接收数据
- 每次 recv 完成:
- 返回一个 CQE
- 可配合:
- buffer group
- provided buffers
- IORING_CQE_F_SOCK_NONEMPTY
设计价值
- 消除:
- recv → EAGAIN → poll → recv 的经典三段式
- 减少 SQE churn
- 更接近 真正的异步 socket
5.3 Accept:连接洪峰场景利器
#define IORING_ACCEPT_MULTISHOT (1U << 0)
- 一次 accept SQE
- 多个新连接到来 → 多个 CQE
- CQE.res = 新 fd
- 直到:
- backlog 耗尽
- 错误发生
- 用户取消
适合:
- proxy
- gateway
- 高频短连接服务
5.4 Timeout:周期性 / 持续定时器
#define IORING_TIMEOUT_MULTISHOT (1U << 6)
语义:
- 不自动删除 timeout
- 每次超时:
- 产生一个 CQE
- F_MORE 置位
这使得 io_uring 可以:
- 原生承担 timerfd / POSIX timer 角色
- 与 IO 完全统一到一个事件模型
5.5 READ_MULTISHOT(受限)
IORING_OP_READ_MULTISHOT
⚠️ 重要限制:
- 只支持特定文件类型
如 pipe / socket / 某些字符设备 - 不支持普通文件
因为普通文件没有“事件驱动”的语义
这体现了 multishot 的本质:它不是“循环 read”,而是“事件触发型 read”。
六、Multishot 与 Buffer 管理的协同设计
6.1 Multishot + Buffer 复用的性能优势
multishot 的最大优势之一,就是能够在一次提交请求后,通过 复用 buffer 来 持续处理多个完成事件,从而消除 传统方式下的多次提交、内存分配和数据拷贝 的开销。这不仅优化了 I/O 性能,还减少了系统调用次数,并降低了内核与用户态的交互成本。
Buffer 复用的原理
- IOSQE_BUFFER_SELECT 标志允许应用选择一个指定的 buffer 组,从而避免每次事件到来时重新分配内存。
- IORING_CQE_F_BUFFER 标志将 CQE 中的 buffer ID 返回给用户态,指示哪一块缓冲区被用于此次数据传输。
- 当一个 multishot 请求成功处理时,缓冲区会被标记为可复用,直接使用之前分配的 buffer 进行下一轮数据传输,而无需再次申请内存。
性能提升的关键因素
- 零拷贝:通过复用 buffer,避免了数据在用户态和内核态之间的拷贝。
- 零系统调用:因为数据已直接落入用户态预分配的缓冲区,所以不再需要频繁的内存分配和系统调用来传输数据。
- 持续 IO 流:buffer 的复用保证了 数据流的连续性,而不必在每次事件发生时重新配置内存,从而使得 I/O 操作更加平滑和高效。
通过这种方式,multishot 实现了 真正的高效 I/O,尤其是在 高频数据流 或 低延迟要求 的场景中,能够大幅度减少系统的资源消耗和延迟。
6.2 与传统 epoll + recv 的对比
| 内存分配 | 每次 recv 都需重新提供buffer内存 | 复用预分配的 buffer,无需频繁分配 |
| 系统调用 | 每次 recv 都触发一次系统调用 | 一次提交,多次完成,减少系统调用 |
| 数据传输效率 | 每次 I/O 操作都有可能产生复制与延迟 | 零拷贝,数据直接落入用户态缓冲区 |
| 事件驱动 | 每次 recv 需要重新注册和提交 | 事件触发后持续消费,直到条件不满足 |
| 适用场景 | 适用于大部分场景,效果尚可 | 适用于高频数据流、高并发短连接、大规模并发等场景 |
| 内存管理 | 每次 I/O 操作都需要独立的内存空间 | buffer 管理灵活,避免重复分配内存 |
6.3 小结
通过 multishot 与 buffer 管理的协同工作,io_uring 能够实现 零拷贝、零系统调用的持续 IO 流,极大地提升了性能,尤其在 高频数据流 和 低延迟要求 场景中,其优势明显。
这正是 epoll + recv 机制无法实现的目标,io_uring 的 multishot 模型与 buffer 复用机制 的结合,为现代高并发、高性能网络应用提供了理想的解决方案。
七、Multishot 的生命周期管理
结束条件(任何一个都会终止):
终止特征:最后一个 CQE 不带 IORING_CQE_F_MORE。
八、基于 epoll 的 multishot 模拟机制设计
8.1 设计背景
io_uring multishot 的核心能力在于:
- 一次提交请求,多次返回结果,直到条件不满足为止(如 socket 无数据、连接关闭)。
而 epoll 本身是事件通知模型,并不具备请求生命周期管理能力,因此要在用户态模拟一个 multishot 行为模型,需要额外构建一套机制。
本章节描述如何基于 epoll + ET 模式,在用户态模拟 multishot 的完整设计。
8.2 总体设计思路
epoll 模拟 multishot,需要在用户态完成以下几件事:
- 注册 buffer(Buffer Registration)
- 接管 fd 的读写行为
- 维护请求(Request)生命周期
- 在 IO 完成时触发用户态回调
- 用 ET(Edge Trigger)模式驱动“拉干净”为止的读写
本质上是:
- 用 epoll 只做“唤醒”,用用户态代码做“消费”,示意图如下:

8.3 Buffer 注册机制
8.3.1 为什么需要注册 buffer
multishot 的一个关键点是:
- 每次数据到来都直接落到用户态 buffer
- 避免频繁分配 / 释放内存
- 方便回调中直接处理数据
8.3.2 接口设计
因此,需要提供一个 buffer 注册接口和释放接口:
// 返回一个buffer group id
int32_t register_ring_buffer(uint32_t block_size, uint32_t block_count);
// 释放buffer group id为buf_group_id的缓冲区
int32_t unregister_ring_buffer(uint16_t buf_group_id);
注册的buffer是一块一块的,用户态需要记录:
- buffer中每块内存的起始地址
- buffer每块内存的大小
- 当前用了哪些块
这些信息会绑定到 一个“伪 multishot 请求对象” 上。此buffer的实现可以使用环形缓冲区(ring_buffer)数据结构。后续会写文章详细讲解。
8.4 用户态接管 fd 读写
8.4.1 epoll 不负责读写
epoll 只告诉你一件事: “现在fd可读 / 可写了”。
真正的 read / write 必须由用户态完成。因此需要:
- 在 epoll 事件触发后,标记某fd的状态是可读/可写;
- 查看此fd上是否存在读/写/multishot请求,有则执行读/写操作;
- 直到返回 EAGAIN / EWOULDBLOCK,则将fd设置为不可读/写状态。
这几步是模拟 multishot 的关键。
8.4.2 请求维护
epoll 只认识 fd,不认识“请求”。
而 multishot 语义是:一个请求,对应多次完成事件。
因此需要在用户态维护:
fd -> multishot_request
每个 multishot_request 至少包含:
- buffer 信息
- 回调函数
- 用户上下文(user_data)
在 epoll 事件触发时,根据 fd 找到对应的 request,驱动该 request 完成一次或多次 IO,对完成的IO调用回调。
8.5 为什么必须使用 ET(Edge Trigger)模式
8.5.1 LT 模式的问题
在 epoll LT(Level Trigger)模式下:
- 只要 fd 仍然“可读 / 可写”,epoll 就会持续回调
如果用户态 明知道有数据,但选择暂时不读(例如等待调度、限流、批处理),那么:
- fd 仍然处于 readable
- epoll 会不停地产生事件
- 线程会被反复唤醒,但无法推进 IO
这在模拟 io_uring 场景中是灾难性的:
- io_uring 的语义是:“不提交请求 = 内核不做事”
- LT epoll 的行为是:“你不读,我就一直提醒你”
最终结果是:
- CPU 空转
- 回调风暴
- 无法精确控制请求节奏
8.5.2 ET 模式的优势
ET(Edge Trigger)模式的语义是:
- 状态从“不可用”变为“可用”时,只通知一次
这与 multishot 的模型高度一致:
- epoll 只负责“有新数据到了”、
- 用户态必须一次性把数据读干净,或者记录fd的可读状态
- 不会因为“暂时不读”而被反复打扰
ET 模式强制用户态遵循以下规则:
- 要么读到 EAGAIN,要么等下一次边沿
- 这正是模拟 io_uring multishot 所需要的行为约束。
epoll + ET 模拟 multishot 的核心原则,可以总结为几点:
- epoll 只做事件边沿通知
- 所有 IO 行为都在用户态完成
- 一次事件触发,尽可能多地完成 IO,若用户态缓冲区不够,则需要记录fd的可读状态
8.5.3 小结
LT epoll 本质是在“催你读”,而 io_uring multishot 是“你不提请求我就不干活”。
ET 模式配合用户态请求、buffer 和回调管理,才能把 epoll 从状态轮询,拉到接近 multishot 的请求完成语义。
epoll + ET 不是 io_uring,但这是在不进内核前提下,最接近 multishot 的用户态解法。
epoll 负责唤醒,用户态负责推进 IO,一次注册,多次回调——在使用方式和行为语义上,已经能够高度逼近 io_uring multishot。
九、结语
epoll 和 io_uring 的差异,本质上不是接口差异,而是模型差异:epoll 是状态通知,io_uring 是请求驱动。
io_uring multishot 将请求驱动推向“一次提交、多次完成”,显著减少无效唤醒和状态判断。而 epoll 如果仍停留在 LT 模式的状态轮询,就很难接近这一语义。
通过使用 ET 模式,并在用户态引入 fd 状态记录、请求生命周期管理、buffer 注册与复用等机制,epoll 可以从“事件通知”演进为“请求完成模型”。
最终形成的使用方式是:epoll 只负责唤醒,用户态负责推进 IO;一次注册,多次回调。
在语义和行为上,这已经能够高度逼近 io_uring multishot。
它并不能替代 io_uring,但通过 preactor 抽象,可以让 epoll 和 io_uring 站在同一套编程模型下。
📬 欢迎关注公众号“Hankin-Liu的技术研究室”,收徒传道。持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货