欢迎光临
我们一直在努力

用 epoll 实现 io_uring multishot:preactor 模型设计

一、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 的对比

特性epoll + recvio_uring multishot
内存分配 每次 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->res < 0)
  • 资源枯竭(如 buffer 用尽)
  • 用户 ASYNC_CANCEL
  • 对端关闭,操作正常完成(cqe->res == 0)
  • 终止特征:最后一个 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 只做“唤醒”,用用户态代码做“消费”,示意图如下:
      epoll multishot示意图

    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的技术研究室”,收徒传道。持续分享信创、软件性能测试、调优、编程技巧、软件调试技巧相关内容,输出有价值、有沉淀的技术干货

    赞(0)
    未经允许不得转载:171主机测评 » 用 epoll 实现 io_uring multishot:preactor 模型设计
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址