【 基于 io_uring 的 C++20 协程网络库】01:基础骨架与 Awaiter 机制
-
目标与设计边界
本文旨在实现一个基于
io_uring封装的 C++ 协程网络库。在着手编码前,我们先确立一个严格的设计边界:不考虑跨平台,不考虑兼容 epoll 等传统多路复用机制。
为什么舍弃跨平台等通用性?
一旦引入跨平台封装,不仅维护成本陡增,更关键的是性能势必要做出妥协。不同操作系统的异步 API 在机制上存在根本分歧,强行封装通常只能取它们的公共子集,或者在用户态引入额外的抽象层来模拟缺失的语义。无论哪种方式,都会对最终性能造成不可预期的损耗。在基础设施级别的系统库中,性能是无法在项目后期通过“手法”来弥补的,必须在架构初期就定下基调。因此,我们选择不给自己埋雷,直接将底层与
io_uring强绑定。为什么选择 io_uring?
相比于 epoll,
io_uring的心智模型更加契合协程。
epoll 暴露的是 Reactor 模型接口(就绪通知),本质上依然是接近线程回调的处理方式。而io_uring是标准的 Proactor 模型(完成通知)。C++20 的协程天然就是一个异步操作状态机,也是标准的 Proactor 范式。两者的结合能最大程度地降低封装阻抗,减少无谓的状态转换代码。IOContext 是什么?
对于初接触异步网络库的读者,可以简单将
IOContext理解为事件收割机与协程调度中枢。在代码中,你提交的各种异步操作(即
io_uring的 SQE 事件),最终都需要一个统一的执行流去收割它们的完成结果(CQE)。成熟的模式是借鉴 Boost.Asio 的io_context抽象:通过阻塞调用IOContext::run()来消耗掉所有已就绪事件,唤醒对应的协程,然后继续等待下一轮事件就绪。构建基础框架
基于 C++ 的 RAII 原则,
IOContext的首要任务是管理io_uring实例的生命周期。1. 实例初始化
int io_uring_queue_init(unsigned entries, struct io_uring* ring, unsigned flags);entries: 提交队列(SQ)的深度。必须是 2 的幂(如 128, 256)。内核会基于此分配共享内存环。ring: 指向待初始化的实例。成功后,内存映射地址、队列掩码等状态将被写入该结构。flags: 控制行为的标志位(如启用SQPOLL消除系统调用)。我们这里默认置 0 即可。
失败时直接返回负值的系统错误码,不依赖全局
errno。2. 实例销毁
void io_uring_queue_exit(struct io_uring* ring);该函数负责解除内存映射 (
munmap),并关闭io_uring在内核中对应的匿名文件描述符,防止虚拟内存与文件句柄泄漏。IOContext 资源管理骨架
基于上述 API,我们搭建出
IOContext的核心轮廓。由于该上下文作为核心中枢运转,移动语义会引发悬垂指针等复杂问题,因此我们在设计上严格禁用拷贝与移动。#include <liburing.h> #include <atomic> class IOContext { public: explicit IOContext(unsigned entries) { if (::io_uring_queue_init(entries, &ring_, 0) < 0) { xin::throw_system_error("io_uring_queue_init"); } } IOContext(const IOContext&) = delete; auto operator=(const IOContext&) -> IOContext& = delete; // 为了简化实现,我们不支持移动 IOContext(IOContext&&) = delete; auto operator=(IOContext&&) -> IOContext& = delete; ~IOContext() { ::io_uring_queue_exit(&ring_); } [[nodiscard]] auto ring() noexcept -> ::io_uring* { return &ring_; } [[nodiscard]] auto ring() const noexcept -> const ::io_uring* { return &ring_; } private: ::io_uring ring_; };收割已就绪事件 (CQE)
接下来实现核心引擎
IOContext::run()。这涉及三个底层操作流:-
等待事件就绪:
io_uring_submit_and_wait(struct io_uring* ring, unsigned wait_nr);将提交操作和阻塞等待融合成一次系统调用。
wait_nr指明线程必须阻塞到至少出现多少个完成事件才唤醒返回。返回值陷阱:成功时返回的是提交的 SQE 数量,而非完成的 CQE 数量。
-
遍历完成队列 (CQ):
io_uring_for_each_cqe是一个纯用户态宏。它通过带有 Acquire 语义的内存屏障读取 CQ 尾指针,无锁且零拷贝地遍历就绪事件。状态剥离陷阱:该宏只是只读遍历,不修改内核视角的头部指针。如果仅仅遍历而不推进状态,队列最终会溢出导致
-EBUSY。 -
确认事件消费:
io_uring_cq_advance(struct io_uring* ring, unsigned nr);修改用户空间的 Head 指针,并通过 Store-Release 语义发布给内核,正式确认这些事件已被收割。
user_data 与类型擦除
io_uring_cqe结构中包含一个__u64 user_data字段。当我们在 SQE 中设置它时,内核会原封不动地将其带入 CQE 返回。这使得我们能够将该标识强制转换回 C++ 对象的指针。为此,我们提供一个
Operation基类,所有协程 Awaiter 都必须继承此接口:struct Operation { virtual ~Operation() = default; virtual void complete(int res, unsigned flags) = 0; };优雅的退出:should_stop_ 的无锁设计
为了安全退出事件循环,我们引入
should_stop_变量。即便网络库采用 Core Per Thread 模型,不涉及跨业务线程的同步,但stop()操作往往是由操作系统的信号处理器(Signal Handler,如处理 Ctrl+C)触发的。信号中断具有强抢占性,因此必须使用std::atomic。值得注意的是,这里我们不使用 CAS(Compare-And-Swap)。由于停止是一个幂等且无条件的覆盖动作,我们完全不关心过去的运行状态。直接使用
store配合最松散的std::memory_order_relaxed即可。这提供了硬件级别的防数据撕裂保证,同时将同步开销降到了绝对的最低点。WARN: 只有这个变量显然是不足以完整实现 stop 功能的,还需要考虑如何取消已经提交但尚未完成的 I/O 请求,以及如何通知正在等待的 run() 方法尽快返回。我们将在未来的版本中逐步完善这个功能。
完整的 run() 实现
结合外部任务追踪机制,事件循环的最终代码如下:
class IOContext { // ... 构造与析构保持不变 ... void run() { ::io_uring_cqe* cqe{ nullptr }; while (!should_stop_.load(std::memory_order_relaxed) && outstanding_works_ > 0) { auto res = ::io_uring_submit_and_wait(&ring_, 1); if (res < 0) xin::throw_system_error("io_uring_submit_and_wait"); unsigned head; unsigned count{ 0 }; io_uring_for_each_cqe(&ring_, head, cqe) { ++count; if (cqe->user_data != 0) { auto* op = reinterpret_cast<Operation*>(cqe->user_data); op->complete(cqe->res, cqe->flags); } } if (count > 0) { outstanding_works_ -= count; ::io_uring_cq_advance(&ring_, count); } } } [[nodiscard]] auto sqe() -> ::io_uring_sqe* { auto* sqe = ::io_uring_get_sqe(&ring_); if (!sqe) xin::throw_system_error("io_uring_get_sqe"); add_work(); return sqe; } void stop() noexcept { should_stop_.store(true, std::memory_order_relaxed); } // 为了搭配co_spawn,需要暴露add_work和drop_work方法 void add_work() noexcept { ++outstanding_works_; } void drop_work() noexcept { assert(outstanding_works_ > 0); --outstanding_works_; } private: ::io_uring ring_; std::size_t outstanding_works_{ 0 }; std::atomic<bool> should_stop_{ false }; };深入 Awaiter 机制:SleepAwaiter 实践
单有一个
IOContext是跑不起来的,我们需要验证它与 C++20 协程的交互机制。在此,我们实现一个SleepAwaiter,封装io_uring的IORING_OP_TIMEOUT定时器。#include <chrono> #include <coroutine> #include <expected> #include <system_error> #include <utility> class SleepAwaiter : public Operation { public: template<typename Duration> SleepAwaiter(IOContext& context, Duration d) noexcept : context_{ context } { using namespace std::chrono; ts_.tv_sec = duration_cast<seconds>(d).count(); ts_.tv_nsec = duration_cast<nanoseconds>(d % seconds(1)).count(); } [[nodiscard]] constexpr auto await_ready() const noexcept -> bool { return false; } void await_suspend(std::coroutine_handle<> handle) noexcept { handle_ = handle; auto* sqe = context_.sqe(); // 提交纯超时指令,count 设为 0 表示只受时间触发 ::io_uring_prep_timeout(sqe, &ts_, 0, 0); ::io_uring_sqe_set_data(sqe, this); } [[nodiscard]] auto await_resume() const noexcept -> std::expected<void, std::error_code> { // io_uring 中,超时正常结束会返回 -ETIME if (error_code_ == -ETIME || error_code_ == 0) { return {}; } // 其他错误(如 -ECANCELED 被提前强杀) return std::unexpected{ std::error_code{ -error_code_, std::generic_category() } }; } void complete(int res, [[maybe_unused]] std::uint32_t flags) noexcept override { error_code_ = res; if (handle_) { auto handle = std::exchange(handle_, nullptr); handle.resume(); } } private: IOContext& context_; struct __kernel_timespec ts_{}; std::coroutine_handle<> handle_{ nullptr }; int error_code_{ 0 }; }; template<typename Duration> [[nodiscard]] auto sleep_for(IOContext& context, Duration duration) noexcept -> SleepAwaiter { return SleepAwaiter{ context, duration }; }零开销生命周期管理
留意
await_suspend中的::io_uring_prep_timeout(sqe, &ts_, 0, 0);。我们将局部对象ts_的地址交给了内核。在传统的异步回调编程中,这是一个极易触发悬垂指针的致命错误,通常需要用std::shared_ptr在堆上分配来强行续命。但在这里,它是绝对安全的。因为
SleepAwaiter本身的生命周期被牢牢绑定在了协程帧内部。直到complete回调中触发handle.resume()彻底唤醒协程后,该 Awaiter 才会被销毁。协程从语言底层提供了天然的内存安全保障,这也是 C++ 追求零开销抽象的绝佳体现。测试示例
最后,我们用一段简单的代码来验证整个基建流转:
auto demo(IOContext& context) -> xin::async::Task<void> { using namespace std::chrono_literals; xin::log::info("before sleep..."); co_await sleep_for(context, 5s); xin::log::info("after sleep..."); } int main(int argc, char* argv[]) { IOContext context{128}; // 搭配协程任务分离器运行 xin::async::co_spawn(context, demo(context)); context.run(); return EXIT_SUCCESS; }输出如下,可以看到,5s后再次输出内容,这证明从请求提交、内核响应、上下文分发到协程唤醒的全链路已完全贯通:
blog.io_context_v1 [2026-04-19 16:18:17.642] [info] before sleep... [2026-04-19 16:18:22.642] [info] after sleep...