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

实战: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(和上篇一致,略作精简)

cpp 复制代码
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 拿结果。

cpp 复制代码
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 转型后写入:

cpp 复制代码
// 调度器主循环
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 的全部逻辑

cpp 复制代码
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);
}

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

cpp 复制代码
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 与主循环

cpp 复制代码
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 ms 1.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 解析器状态机,或者按读者反馈来------评论区见。

参考:

相关推荐
云飞云共享云桌面3 小时前
机械制造研发部门:一台SolidWorks云主机替代 10 台传统图形工作站
运维·服务器·网络·3d·自动化·制造
loulanyue_3 小时前
从碎片文档到工业知识网络:TDengine工业知识图谱架构设计与大模型落地破局——读涛思数据OpenDay实录
网络·知识图谱·tdengine
东方佑4 小时前
去魅:工具调用、Agent 调用、A2A、MCP、Harness 到底都是什么
服务器·网络·数据库
上海云盾-小余4 小时前
七层 CC 攻击检测难点:如何区分真实用户与攻击请求
网络·人工智能·windows·tcp/ip·udp
2401_885885044 小时前
巴林语音通知接口如何对接?系统告警语音API接入教程
android·前端·网络·python·语音识别
JSON_L4 小时前
TCP 粘包拆包解析及示例
网络·网络协议·tcp/ip
科技小E4 小时前
私有化部署:私有化/本地化AI模型训推工作站DLTM数据不出内网的“安全锁”
网络·安全·自动化
汤愈韬5 小时前
OSPF外部路由引入
网络·网络协议
Lzg_na5 小时前
OSPF协议学习
网络