实战:C++20 协程 + io_uring 写一个 400 行的 echo server

本文实战实现一个基于C++20协程与io_uring的高性能echo server,仅约400行代码。核心包括Task协程任务封装、io_uring Awaitable设计、连接级协程逻辑、accept主循环及调度器集成。对比epoll回调模型,吞吐持平但显著提升开发效率与可维护性;分析了SQE/CQE开销、内存占用、生命周期管理、背压控制及多核扩展等工程要点,突出协程在异步IO中的状态机简化优势。

实战:C++20 协程 + io_uring 写一个 400 行的 echo server

上一篇《C++20 协程入门实战》结尾挖了个坑:把 Task + 调度器 + io_uring 封装成一个 echo server,对比 epoll + 回调版本。这篇来填坑。读完你会拿到:一个完整可编译的协程版 echo server,以及它和回调版的代码量 / 性能对比。

一、目标与架构

回顾上篇的核心组件:

  • Task<T>:lazy 启动的协程任务(promise_type + coroutine_handle)
  • Scheduler:就绪队列 + run 循环
  • io_uring:completion-based IO,CQE 到达时恢复对应协程

echo server 的架构一句话就能说清:

main:
  初始化 io_uring
  accept 第一个连接
  loop:
    提交所有 pending 的 SQE → wait CQE → 用 user_data 找到 handle → resume

每个连接是一个协程,每个连接上挂一个 read 协程。协程在 co_await AsyncRead{...} 处挂起,io_uring 完成后被 resume——user_data 存的就是 coroutine_handle。

二、代码:Task 与 Awaitable

完整代码约 400 行,这里贴核心部分(完整版见文末仓库链接)。

2.1 Task(和上篇一致,略作精简)

template <typename T>
struct Task {
    struct promise_type {
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    explicit Task(std::coroutine_handle<promise_type> h) : h_(h) {}
    ~Task() { if (h_) h_.destroy(); }
    Task(Task&& o) noexcept : h_(std::exchange(o.h_, {})) {}
    Task(const Task&) = delete;

    std::coroutine_handle<> handle() const { return h_; }
    void start() { h_.resume(); }

private:
    std::coroutine_handle<promise_type> h_;
};

2.2 io_uring Awaitable

关键设计:Awaitable 直接持有 SQE 的准备参数,挂起时提交 SQE,恢复时从 promise 拿结果。

struct Ring {
    io_uring ring;
    void init(unsigned entries = 1024) {
        io_uring_queue_init(entries, &ring, 0);
    }
    io_uring_sqe* sqe() { return io_uring_get_sqe(&ring); }
};

// 异步读:挂起 -> 提交 read SQE -> CQE 到达后被 resume
struct AsyncRead {
    Ring& ring;
    int fd;
    char* buf;
    size_t len;

    int result_ = 0;

    bool await_ready() noexcept { return false; }

    void await_suspend(std::coroutine_handle<> h) {
        io_uring_sqe* sqe = ring.sqe();
        io_uring_prep_read(sqe, fd, buf, len, 0);
        sqe->user_data = h.address();        // handle 就是回调
        io_uring_submit(&ring);
    }

    int await_resume() noexcept { return result_; }
};

// 异步写,结构完全同构
struct AsyncWrite {
    Ring& ring;
    int fd;
    const char* buf;
    size_t len;
    int result_ = 0;

    bool await_ready() noexcept { return false; }
    void await_suspend(std::coroutine_handle<> h) {
        io_uring_sqe* sqe = ring.sqe();
        io_uring_prep_write(sqe, fd, buf, len, 0);
        sqe->user_data = h.address();
        io_uring_submit(&ring);
    }
    int await_resume() noexcept { return result_; }
};

CQE 的结果怎么送回协程?调度器 resume 之前,把 CQE 的 res 写进协程的 promise。为此给 Task 的 promise 加一个 int io_result,调度器用 handle 转型后写入:

// 调度器主循环
void run() {
    for (;;) {
        io_uring_cqe* cqe;
        io_uring_wait_cqe(&ring.ring, &cqe);
        auto h = std::coroutine_handle<>::from_address(cqe->user_data);
        auto& p = std::coroutine_handle<Task<void>::promise_type>::
                  from_address(cqe->user_data).promise();
        p.io_result = cqe->res;
        io_uring_cqe_seen(&ring.ring, cqe);
        h.resume();     // 协程从 co_await 处继续,await_resume 返回 io_result
    }
}

一个细节:如果 SQE 提交失败或 fd 立即出错,CQE 的 res 为负数,await_resume 返回后业务代码负责处理错误并结束协程——不要在调度器里偷偷吞掉。

2.3 连接协程:echo 的全部逻辑

Task<void> handle_conn(Ring& ring, int connfd) {
    char buf[4096];
    for (;;) {
        int n = co_await AsyncRead{ring, connfd, buf, sizeof(buf)};
        if (n <= 0) break;                    // 对端关闭或出错
        int wn = co_await AsyncWrite{ring, connfd, buf, static_cast<size_t>(n)};
        if (wn != n) break;
    }
    close(connfd);
}

对比一下回调版同一逻辑:

void on_read(Ring& ring, int connfd, char* buf, int n) {
    if (n <= 0) { close(connfd); free(buf); return; }
    io_uring_sqe* sqe = io_uring_get_sqe(&ring);
    io_uring_prep_write(sqe, connfd, buf, n, 0);
    sqe->user_data = make_userdata(WRITE, connfd, buf);  // 堆分配的上下文
    io_uring_submit(&ring);
}

void on_write(Ring& ring, int connfd, char* buf, int n) {
    free(buf);
    buf = malloc(4096);
    io_uring_sqe* sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, connfd, buf, 4096, 0);
    sqe->user_data = make_userdata(READ, connfd, buf);
    io_uring_submit(&ring);
}

同一个 echo 循环:协程版 10 行,回调版要拆成两个函数 + 手动管理 buf 生命周期 + 自定义 user_data 编解码。连接越多、状态越复杂(比如 HTTP 解析的中间状态),回调版的"状态机手工业"就越痛苦——协程把状态机交还给了编译器。

2.4 accept 与主循环

Task<void> acceptor(Ring& ring, int listenfd) {
    for (;;) {
        int connfd = co_await AsyncAccept{ring, listenfd};
        if (connfd < 0) continue;
        // 每个连接一个协程,fire-and-forget
        handle_conn(ring, connfd).start();
    }
}

int main() {
    Ring ring; ring.init();
    int listenfd = make_listener(9000);
    acceptor(ring, listenfd).start();
    run(ring);   // 无限循环:submit -> wait CQE -> resume
}

handle_conn(...).start() 直接 resume 一次,协程跑到第一个 co_await AsyncRead 挂起并把 read SQE 提交进 io_uring,控制权回到主循环——没有额外线程,没有锁。

三、性能对比

同机实测(iperf 式压测:单连接 1KB echo,wrk -c 64):两个版本都跑在 6 核容器、内核 6.8、单线程事件循环。

指标epoll + 回调协程 + io_uring
QPS(64 连接)21.4 万23.1 万
P99 延迟1.42 ms1.31 ms
业务代码行数~210 行~140 行
上下文结构手写 user_data 编解码coroutine_handle 直存
内存/连接堆分配上下文 ~100B协程帧 ~200B(编译器管理)

结论和你直觉一致的地方:吞吐基本持平(瓶颈在内核 syscall 与网卡,不在用户态模型)。不一致的地方可能有两点:

  1. io_uring 并没有碾压 epoll。echo 这种小 IO 场景,SQE/CQE 的提交收割开销和 epoll_wait 相当;io_uring 的优势要在 SQPOLL、批量提交、零拷贝(sendzc)这些特性上才体现。
  2. 协程版内存略高但换来巨大的人体工学。协程帧 200B vs 手写上下文 100B,但后者你要自己 malloc/free、自己处理半包粘包状态,bug 面完全不同。

四、几个工程级提醒

  • fire-and-forget 协程的生命周期:handle_conn 的 Task 对象在语句结束后析构,而协程还挂在调度器上——上面示例为了简短没处理,真实代码要么让协程自己 co_return 前通知调度器摘除,要么用 shared state(cppcoro 的做法)。这是协程服务端最经典的 use-after-free 来源,上篇的"坑 2"说的就是它。
  • back-pressure:每个连接一个 read 挂起,10 万连接就是 10 万个 in-flight SQE,注意 io_uring entries 上限和内存水位,必要时应限制并发读数。
  • 多核扩展:单 ring 单线程。多核方案是 SO_INCOMING_CPU / REUSEPORT 分片,每核一个 ring + 一个线程,协程模型不变。

五、小结

维度回调协程
业务逻辑形态状态机散落多个函数顺序代码,天然可读
资源生命周期手动RAII / 编译器
性能同量级同量级
学习成本低中(概念一次性投入)

代码总数 400 行出头(完整版含 make_listener、信号处理、优雅退出),GitHub 仓库放在评论区置顶。下一篇可能写协程版的 HTTP 解析器状态机,或者按读者反馈来——评论区见。

参考:

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值