第23.1章:协程机器
1. 问题的形状
普通函数只有一个入口和一个出口。它一直运行到返回。如果它需要的数据还没准备好,它有三个选择: 阻塞线程;返回一个句柄让调用者轮询;或者把自己拆成回调。这三种选择都会出现在源码里, 也都让程序更难读。
协程(coroutine)是一个可以在中间停下、把控制权交还给调用者、之后再从 停下的地方继续的函数。协程的局部变量、当前语句和调用栈在暂停期间都保留下来。调用者不会阻塞。 协程等待时,操作系统线程也不会被占用。
定义。协程是一个执行可以被暂停和恢复的函数。在 C++20 及以后,只要函数体里 含有
co_await、co_yield或co_return,它就是协程。 编译器把这个函数改写成状态机,把它的状态放进协程帧(coroutine frame), 并把它与用户定义的 promise 类型连接起来。
关键词是函数。协程不是线程,不是任务队列,也不是回调。它是一个带有保存状态的函数。 总得有东西来恢复它:调度器、事件循环、另一个协程,或者调用者自己。
2. 三个关键字,一台机器
| 关键字 | 含义 | 编译器生成什么 |
|---|---|---|
co_await expr |
在这里暂停,直到 expr 就绪。 |
调用 awaiter 协议:await_ready,然后 await_suspend,
然后 await_resume。
|
co_yield expr |
产出一个值并暂停,用于生成器。 |
等价于 co_await promise.yield_value(expr)。promise 保存值或把它暴露给消费者。
|
co_return expr |
结束协程并交付结果。 |
调用 promise.return_value(expr);对于 co_return;
则调用 promise.return_void()。
|
这三个关键字不是三个独立特性。它们是到达同一个 promise 对象的三种方式。promise 是协程的控制块: 它决定协程返回什么、第一次暂停时做什么、最后一次暂停时做什么,以及异常如何处理。
#include <generator>
#include <print>
std::generator<int> iota(int n)
{
for (int i{}; i != n; ++i)
{
co_yield i;
}
}
int main()
{
for (int value : iota(5))
{
std::print("{} ", value);
}
std::print("\n");
}
输出 0 1 2 3 4。函数 iota 不是被调用五次。它只被调用一次,创建一个
协程帧,然后恢复五次。每次 co_yield 都在同一点暂停;循环和变量 i
活在帧里,而不是调用者的栈上。
3. 编译器构建了什么
对每个协程,编译器创建一个帧。帧是一个隐藏对象,至少包含:
- 协程参数,被复制或移动进帧;
- 生命周期跨越暂停点的局部变量;
- promise 对象;
- 恢复点,让下一次
resume()知道从哪条语句继续; - 当前状态:运行中、已暂停或已结束。
帧通常用 operator new 分配,并在协程帧销毁时释放。当编译器能看到完整生命周期时,
它可以省略这次分配,但在库 API 里你不能依赖这一点。这就是为什么 fast_io 的
io_async_task 这样的协程返回类型把分配器作为模板参数:帧通过与库其余部分相同的
分配器纪律分配,而不是走全局默认。
promise 类型是定制点。协程返回类型 task<T> 必须提供嵌套的
promise_type。编译器在固定时刻调用 promise:
| Promise 钩子 | 何时运行 | 控制什么 |
|---|---|---|
get_return_object() |
函数体开始前 | 构造调用者收到的对象。 |
initial_suspend() |
函数体开始前 |
suspend_always 让任务惰性启动;
suspend_never 立即启动。
|
final_suspend() |
函数体结束后 | 决定帧是保留给调用者读结果,还是自行销毁。 |
return_value / return_void |
co_return 时 |
把结果存进 promise。 |
yield_value |
co_yield 时 |
向消费者发布一个值。 |
unhandled_exception |
普通 C++ 异常逃出函数体时 | 保存或终止;它不能正常返回。 |
unhandled_herbception |
fast_io 的赫布异常逃出函数体时 |
把原始两字错误存进 promise,供等待的调用者或
rethrow_if_error() 之后交付。
|
没有 promise_type 的协程是编译错误。promise 不是可以忽略的细节:它把语言的
暂停/恢复原语变成生成器、任务或异步操作。
4. Awaiter 协议
co_await expr 不直接调用 expr。它向结果索取一个
awaiter,然后最多调用三个成员函数:
-
await_ready()—— 如果返回true,操作已经完成,协程不暂停。 -
await_suspend(handle)—— 协程即将暂停时调用。它可以返回void(暂停)、bool(为 true 则暂停),或另一个协程句柄 (把控制权转交给它)。 -
await_resume()—— 恢复后产生整个co_await表达式的值。
#include <coroutine>
struct immediate
{
bool await_ready() const noexcept { return true; }
void await_suspend(std::coroutine_handle<>) const noexcept {}
int await_resume() const noexcept { return 42; }
};
some_task<int> use()
{
int value = co_await immediate{};
co_return value;
}
some_task 是协程返回类型的占位符,它必须有 promise_type;
fast_io 的对应类型是 io_async_task。这个例子从不暂停,因为
await_ready 返回 true。有意思的是返回
false 的情况:协程保存恢复点,调用 await_suspend,然后把控制权
交还给恢复它的人。之后,操作的完成回调调用 handle.resume(),执行从
await_resume 继续。
异步 I/O 的全部技巧就在这里。协程不在 socket 上等待。它提交一个操作,保存自己的句柄,然后暂停。 事件循环等待内核通知操作完成,再恢复保存的句柄。在协程内部,代码读起来就像阻塞调用: 读、打印、再读。
5. 有栈与无栈
C++ 协程是无栈(stackless)的。暂停的协程不保留独立的调用栈。它的状态就是帧, 并且只保留协程自己的局部变量。你不能在协程调用的普通函数里暂停,然后指望普通函数的栈存活; 只有协程体有暂停点。
这不同于有栈协程或绿色线程,后者保存并恢复整个栈。有栈设计允许在任何地方暂停,但每个协程需要
一个栈和一次上下文切换。无栈设计更便宜、更显式:编译器确切知道哪些状态跨越暂停点,
程序员在允许暂停的地方写 co_await。
| 属性 | 无栈 C++ 协程 | 有栈协程 / 线程 |
|---|---|---|
| 暂停时保留的状态 | 一个帧,含参数、局部变量和 promise | 整个调用栈 |
| 能在任何地方暂停吗? | 只能在 co_await / co_yield |
通常可以 |
| 每个实例的成本 | 帧大小,通常一次分配 | 栈大小,通常大得多 |
| 谁来恢复它? | 显式句柄、调度器或事件循环 | 调度器或上下文切换 |
| 本身提供并发吗? | 不提供 | 不提供 |
6. 协程不是线程
这是最容易混淆的一点。协程提供暂停,不提供并行。两个协程可以同时活在一个线程上, 但任意时刻只有一个在运行。它们在暂停点协作式地交错。没有抢占,也不会产生数据竞争, 除非你引入另一个线程。
caller: resume A ──► A runs ──► A suspends ──► caller continues
event loop: wait ──► completion ──► resume A ──► A runs ──► A finishes
线程是操作系统拥有的调度单位。协程是你的程序拥有的函数状态。一个服务器可以用少量线程处理一万个 连接,因为大多数连接都在等待 I/O 而暂停,而不是因为它创建了一万个线程。并发来自事件循环和内核, 不是来自协程关键字。
推论同样重要:在协程里执行阻塞操作会阻塞整个事件循环。在异步协程里调用
read、sleep 或同步的 fast_io 操作,会停住那个调度器上的所有其他协程。
异步代码是一种纪律,不是装饰。
7. 生命周期、成本与失败
协程帧一直活到被销毁。对于惰性任务,任务对象通常拥有帧,并在析构时销毁它。对于 detached 任务,
帧在 final_suspend 处自行销毁。如果异步操作仍然持有指向帧的指针,而帧被销毁,
就是 use-after-free;操作的回调会恢复一个已经不存在的帧。这就是异步层要区分拥有任务和 detached
任务的原因,也是你必须让拥有任务活到 done() 的原因。
成本有三部分:帧分配、帧大小和恢复。语法不会隐藏它们。每个请求分配一个帧的协程,不会因为它是 协程就免费。fast_io 允许调度器提供分配器,让帧来自与其余 I/O 状态相同的池;异步任务类型也可以 按那个分配器参数化。
失败在本教程中有两个通道。普通 C++ 异常可以逃出协程,交给
promise.unhandled_exception()。fast_io 的赫布异常使用 throws 标记和
throw throws 形式;逃出 io_async_task 函数体的错误被存进 promise,
并在 co_await 处重新抛出,或由非协程调用者的 rethrow_if_error()
交付。钩子收到的是原始两字错误值,当前实现拼作 ::std::cxx_std_error
(第19.1章);
catch throws (::std::error e) 里的公开句柄在
第3.16章介绍。它们是同一对
域指针加错误码在不同层上的表示。这里只需要记住规则:异步操作通过与同步操作相同的通道报告失败。
要点
- 协程是可以暂停和恢复的函数;它不是线程。
-
co_await、co_yield和co_return是仅有的协程关键字; 任意一个都会让函数成为协程。 - 编译器构建帧并调用 promise。promise 决定惰性、结果、产出和错误处理。
-
co_await使用 awaiter 协议:ready、suspend、resume。异步 I/O 就是这个协议 加上一个在内核报告完成时恢复句柄的调度器。 - 暂停是协作式的。在协程里阻塞会阻塞同一事件循环上的所有协程。
- 帧有生命周期。让拥有任务活到结束,或者 detach 它,让它自行销毁。
下一页把这套语言机制变成 fast_io 的异步模型:任务、调度器、观察者、操作和事件循环。