从 Future 到异步 Echo Server:读懂 tinyCoroLab 的运行模型

假设一个 TCP 服务器只有少量工作线程,却要同时照顾许多连接。某个连接调用 read 后,数据可能很久才到。如果工作线程一直等它,其他连接即使已经有数据,也不能及时处理。我们想要的代码仍像顺序程序:读一段数据,处理,再写回去;但在等待数据时,工作线程应当去做别的事。

tinyCoroLab 把这个目标拆成五个实验。前面三个实验构成主线:用 C++20 协程表示一个可暂停的任务,让执行引擎运行它,再让 io_uring 的完成事件把它唤醒。后面两个实验讨论任务之间如何等待和通信。本文沿着这条路线讲清每一步已有的能力、遇到的新问题、引入的抽象及其边界。

从 std::promise / std::future 出发:我们还缺什么?

std::promise<T> 可以由生产者写入结果,std::future<T> 可以让消费者稍后取得结果。这已经解决了“结果尚未产生,如何在两处代码间交付”的问题。std::async 还能安排函数执行并返回一个 future。不过,如果消费者在结果未就绪时调用 future.get(),当前线程会等待。我们当然可以轮询 wait_for(),或自行安排回调,但这仍没有提供一个自然的写法,让“当前业务逻辑暂停,线程转去处理其他连接,结果到来后从原处继续”。

C++20 协程补的是这一层控制流。编译器把协程函数变成带有状态的协程帧:局部变量、执行到哪一行,以及等待恢复所需的对象,都可以跨越一次挂起而存在。co_await 让当前协程有机会挂起,coroutine_handle 指向这块帧,外部代码日后可以用它恢复执行。

这里容易因同名而误解:std::promise<T> 是标准库里向 future 交付结果的对象;协程的 promise_type 是编译器与协程返回类型之间的协议,负责创建返回对象、保存结果或异常、规定初次和最终挂起的行为。两者都与“结果”有关,但协程的 promise_type 还参与生命周期和调度。C++20 只提供机制,不会自动创建线程、把 socket 变成异步,也不会替我们决定谁销毁协程帧。

例如 task<int> answer() { co_return 42; }:调用 answer() 时,编译器创建协程帧,借助 promise_type::get_return_object() 交出一个 task<int>;等协程真正运行到 co_return 42,结果写进该帧关联的 promise,随后进入 final_suspend。这个 promise 由协程机制调用,不需要像 std::promise<int> 那样由业务代码手动 set_value(42)。task<int> 则是供外部持有帧、等待结果的包装对象。

co_await 究竟做了什么

面对一个 awaiter,可以把 co_await 想成依次回答三个问题:

方法它回答的问题
await_ready()结果现在就有了吗?若为 true,无需挂起。
await_suspend(handle)如果要挂起,保存当前协程的句柄到哪里?接下来由谁运行?
await_resume()协程恢复后,co_await 表达式产生什么结果?

因此,co_await 不等于“一定切线程”或“一定提交 I/O”。是否挂起、怎样重新入队,取决于 awaiter 的实现。这个事实贯穿后面所有实验。

Lab 1:让协程成为可管理的 task

有了语言层面的协程帧,我们仍需要一个 C++ 对象保存它。项目里的 task<T> 就承担这件事:它持有句柄,以 RAII 方式在析构时销毁自己仍持有的帧,并提供 operator co_await(),使一个任务可以等待另一个任务。task<T> 是管理帧的对象;句柄只是指向帧的引用,复制句柄并不会复制任务或转移所有权。

创建任务后,先不运行

task 的 initial_suspend() 返回 std::suspend_always。所以调用一个返回 task<> 的协程函数时,先得到任务对象,函数体暂不执行。我们由此把“创建任务”和“安排任务运行”分开:服务器接受连接后可以创建 session(fd),再把它交给执行引擎。

这个选择带来一个新问题:一个协程 co_await 另一个 task 时,子任务结束后,挂起的父任务由谁恢复?普通函数调用会自然返回到调用点;这里的父协程已经挂起,不能指望 C++ 调用栈替我们完成续接。

父子续接:FinalSuspendAwaiter

假设 session() 等待一个返回 task<int> 的子任务。父协程对它执行 co_await 时,子任务的 await_suspend 把父协程句柄存进子任务 promise 的 parent,然后返回子协程句柄,使子任务开始运行。子任务执行 co_return 后进入 final_suspend;项目的 FinalSuspendAwaiter 返回保存的 parent,将执行权交还给父协程。没有父协程时则返回 noop_coroutine()。

父协程 co_await 子 task
  → 父协程挂起,子协程运行
  → 子协程 co_return,进入 final_suspend
  → FinalSuspendAwaiter 返回 parent
  → 父协程从 co_await 后继续

这让代码在阅读上像嵌套函数调用,但底层依据的是显式保存的续接句柄。parent 只说明“结束时继续谁”,不说明“谁拥有子协程帧”。把恢复关系当成所有权关系,是后续悬空句柄问题的根源。

谁来销毁帧:detach() 与提交接口

通常,task 析构会销毁它持有的协程帧。若把一个临时任务作为独立根任务交给执行引擎,临时 task 很快就会析构;执行引擎若只保留句柄,就会指向已销毁的帧。因此,submit_to_scheduler(task<void>&&) 先保存句柄,再对任务调用 detach():task 清空自己的句柄,promise 记下 detached 状态,执行引擎在任务完成后通过 clean() 清理帧。

这个接口还有左值 task<void>& 和裸 coroutine_handle<> 重载。它们只提交句柄,不转移帧所有权。调用者必须保证原来的 task 及其帧在排队、挂起和恢复期间一直存活。即使是右值重载,也应把它用于独立、清理责任明确的根任务;不能把父协程正在持有的子帧当成独立根任务随意交给调度器。

可以给每个句柄问三个问题:它指向哪一次协程调用创建的帧?谁持有这块帧?恢复它的代码在 resume() 返回后还会不会访问它?仅凭 task<bool> 这样的类型名无法回答,因为两次返回同类型 task<bool> 的调用仍会创建两块不同的帧。

Lab 2:有了任务,谁来运行它?

task 解决了“可暂停的工作如何表示”。现在需要一套运行时,让一小群线程在许多任务之间切换,同时处理 I/O 完成事件。项目把职责分成三层:

层次负责什么不应混淆的边界
engine保存可运行句柄,恢复任务,提交 I/O,处理完成事件将句柄放进就绪队列,不等于提交 I/O。
context持有一个 engine 和一个工作线程,反复驱动本地运行循环它是运行单元,不只是线程的别名。
scheduler创建多个 context,用 dispatcher 分发根任务,协调统一停机一个 context 暂时空闲,不代表整个调度器可以结束。

项目的 dispatcher 使用简单的轮询分发。一个 context 对应一个工作线程及一个 io_uring 实例;协程在哪个线程恢复取决于谁将它放入哪个 context 的就绪队列,不能单凭它最初在哪个线程创建来推断。

三种“提交”不是一回事

submit_task(handle) 是把可以运行的协程放入 engine 的队列。工作线程取出后执行 resume();协程这时才可能构造 I/O awaiter。add_io_submit() 则表示某个 awaiter 已准备了一项待提交的 I/O 请求。poll_submit() 才把 SQE 送给 io_uring,并在事件到来后处理 CQE。把这三步混在一起,就很难解释为什么一个纯计算任务也需要唤醒工作线程。

项目还使用 eventfd 给工作线程“按门铃”。io_uring 的完成事件会通知它,新任务入队也会通知它。一次读取的数值不是 CQE 数量;通知可能合并,而且可能只是新任务,尚无 I/O 完成。醒来以后,工作线程应重新检查真实的任务队列和完成队列。先发布任务,再通知等待者,否则等待者可能醒来却看见空队列。

context::run() 在“执行一个可运行任务、处理 I/O、判断是否空闲”之间循环。当前源码里,poll_submit() 等的是 eventfd;另有 engine::wait() 用 std::condition_variable 等待新任务或停止信号。这是两条不同的等待路径。register_wait() / unregister_wait() 记录协程同步组件中尚未解除的等待,不能把这个计数当作 CQE 数,也不能让 poll_submit() 等它归零。

空闲不等于停机

一个 context 本地没有就绪任务,甚至没有 I/O,也可能稍后收到另一个 context 派来的任务。如果它此时销毁执行引擎或退出工作线程,scheduler 后续分发就会落到已停止的目标上。统一停机要考虑全部 context 的状态,并使“确认无工作”与“并发提交新任务”遵守同一个同步协议;否则会出现状态记账说全局已空闲、队列却已有任务的竞态。

把任务、待提交 I/O、运行中 I/O 和已登记等待者分别记账,是为了回答不同问题。运行循环要能在它们之间公平地重新检查;不能只等某一类工作“全部做完”才看另一类。

Lab 3:Echo Server 的一次读写如何暂停又继续?

现在回到开头的服务器。它可以先运行 server(8000),在 co_await server.accept() 得到连接后,把 session(fd) 作为独立根任务提交。每个 session 用近似顺序代码写循环:

task<> session(int fd) {
    char buf[10240]{};
    io::net::tcp::tcp_connector conn(fd);
    int n = 0;
    while ((n = co_await conn.read(buf, sizeof(buf))) > 0) {
        co_await conn.write(buf, n);
    }
    co_await conn.close();
}

这段代码能在 read 尚未完成时让出工作线程,关键不是 co_await 这几个字符,而是 conn.read() 返回的 I/O awaiter 把请求与续接信息联系起来。要看清它,我们先把 io_uring 简化成两种条目:SQE 描述“请执行什么 I/O”,CQE 描述“这项 I/O 完成了,结果是什么”。准备 SQE 和真正提交它,是两个时刻。

以一次 read 为例,按时间顺序发生的是:

  1. conn.read() 构造 awaiter。基类从当前 engine 获取 SQE;具体 TCP awaiter 用 io_uring_prep_recv 填写操作,把指向自身 io_info 的指针放进 SQE,并通过 add_io_submit() 记账。此时请求已准备,尚未因构造函数调用就提交到内核。
  2. co_await 调用 awaiter 的 await_suspend(handle),把正在执行 read 的这一层协程句柄存到 io_info,然后挂起该协程。工作线程可以回到运行循环做别的事。
  3. engine 调用 poll_submit() 提交 SQE。内核完成操作后产生 CQE,并通过 eventfd 提示工作线程。engine 用 CQE 的 user data 找回 io_info,把 CQE 的结果交给预先登记的回调。
  4. 回调保存结果,调用 submit_to_context(handle) 将原等待者放回可运行队列。它不在回调中直接执行整段 session 业务代码。工作线程以后恢复这个句柄,await_resume() 返回结果,session 才从 co_await 后面继续。
业务代码 co_await read
  → awaiter 保存句柄并准备请求
  → engine 提交 SQE
  → 内核产生 CQE
  → 回调保存结果、重新入队句柄
  → engine 恢复协程,await_resume 交付结果

io_info 在 awaiter 内;awaiter 又处于挂起协程要保存的状态中。因而协程帧必须活到 CQE 和恢复完成,read 使用的缓冲区也必须在 I/O 等待期间有效。这正是“协程帧承载异步操作状态”的具体含义。多个 I/O 互相独立:例如 socket 对端关闭,不会自动让另一个仍在等待 stdin 的读取完成。

回调能重新入队,不代表任何句柄都能安全入队

上面的 session(fd) 由调度器作为 detached 根任务持有,完成后执行引擎可以清理它。若 I/O awaiter 位于父协程 co_await 的子 task 里,回调保存并重新入队的就是子协程句柄。子协程在这次恢复中结束时,final_suspend 可能立刻恢复父协程;父协程离开 co_await 表达式,销毁持有子帧的临时 task。然而当前 engine::exec_one_task() 在 coro.resume() 返回后还会访问 coro.done()。此时子帧可能已经被父协程销毁,形成 use-after-free。

这与 channel 等同步组件中直接重新入队子任务的风险同源。判断标准始终是:入队的是哪一层帧?谁拥有它?执行引擎在恢复它之后还会做什么?“这是 I/O 回调提交的句柄”本身并不是安全证明。

Lab 4、5:协程之间如何等待?

Echo server 展示了“等待外部 I/O”。再看一个较小的内部场景:生产者尚未写入数据,消费者需要等待。线程版 mutex、条件变量或阻塞队列可以工作,但如果把工作线程长期停在这些等待上,这个线程就无法运行其他已就绪协程。协程同步组件做的是把等待者的协程句柄登记起来,满足条件后重新入队,让线程在此期间继续处理别的任务。

这些组件并不需要逐个重造一套复杂运行时。它们共享一个最重要的正确性约束:检查条件、登记等待者、发布条件变化必须形成不会丢唤醒的协议。比如 event.wait() 的 await_ready() 刚看到“尚未 set”,另一个线程就可能 set();如果等待者随后才入队,且没有在登记时重新检查状态,就再也等不到那次通知。登记时还要把句柄及其原 context 作为同一位等待者的信息保存,确保恢复与等待记账配对。

在这个模型上,各组件只是表达不同的条件:

组件条件及恢复方式
event一次设定后放行等待者;可带值。
latch、wait_group计数达到零后放行等待者;前者适合固定阶段,后者追踪一组工作。
协程 mutex锁被占用时挂起协程;解锁后给一位等待者继续争用或接过锁的机会。
when_all等所有子任务结束;必须处理“子任务先完成,父协程还未登记好”的竞态。
condition_variable通知不积攒为许可;围绕谓词检查、登记、释放锁和重新检查建立协议。
channel用有界缓冲区连接生产者和消费者;取出一个元素释放容量时,也要让等待发送者有机会继续。关闭后通常先取尽缓冲数据,再报告结束。

这里要分清因业务条件而等待和为维护内部共享状态而短暂加锁。协程组件仍可能使用 std::mutex 保护等待队列、计数等数据。竞争激烈时,线程锁可能进入内核等待,阻塞整个工作线程;但不能由“用了 std::mutex”推出每次竞争都会发生 futex 调用。若不讨论高性能实现,最值得守住的是短临界区与正确的唤醒顺序,而不是把每把内部锁都改成无锁结构。

最后,协程 mutex 的可重入与重复 unlock() 是两个不同问题。当前接口没有提供重入或“无持有权也能解锁”的契约;不能因为它是协程版,就假设这些操作被允许。

用一张表检查整套模型

场景暂停的是什么谁安排恢复谁负责帧的存活与清理
父协程等待子 task父协程子任务的 final_suspend 返回 parent父协程持有的 task 管理子帧;父帧另有自己的所有者。
独立根任务等待 I/O根协程CQE 回调将根句柄放回 contextdetached 根帧由执行引擎在完成后清理。
子任务等待 I/O子协程CQE 回调将子句柄放回 context子帧仍由父协程中的 task 持有;恢复路径须避免引擎在帧销毁后访问句柄。
等待 event、mutex 或 channel 条件执行 co_await 的那一层协程条件变化时,组件把等待句柄重新入队或按既定续接链恢复组件登记等待,不自动取得那层协程帧的所有权。

回看整个项目,可以把问题压缩成两句:**什么时候当前协程让出执行权?让出以后,谁保证它仍然活着,并在条件满足时把它接回来?**前一句引出了 awaiter、engine 与 io_uring;后一句贯穿 task、scheduler 和所有同步组件。只有同时回答这两句,顺序写法的异步程序才真正成立。