实战: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 与网卡,不在用户态模型)。不一致的地方可能有两点:
- io_uring 并没有碾压 epoll。echo 这种小 IO 场景,SQE/CQE 的提交收割开销和 epoll_wait 相当;io_uring 的优势要在 SQPOLL、批量提交、零拷贝(sendzc)这些特性上才体现。
- 协程版内存略高但换来巨大的人体工学。协程帧 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 解析器状态机,或者按读者反馈来------评论区见。
参考:
- 上篇:C++20 协程入门实战:从 co_await 到一个 100 行的异步任务调度器
- liburing:https://github.com/axboe/liburing
- cppcoro(协程工程化范本):https://github.com/lewissbaker/cppcoro