C++20 Coroutines:无栈协程的机制与开销
C++20 Coroutines:无栈协程的机制与开销
C++20 引入的协程(Coroutines)是最受期待也最让人困惑的特性之一。它不像其他语言那样提供一个开箱即用的"async/await"运行时——C++ 的协程是一套可定制的基础设施,三个关键字(co_await、co_return、co_yield)和一套 Promise/Awaiter 协议构成了一个编译器变换框架,库作者在此基础上构建具体的协程类型(如 generator<T>、task<T>)。
本文从"编译器把协程函数变成了什么"出发,拆开协程帧(coroutine frame)、Promise 类型、Awaiter 协议和 HALO 优化,帮你建立起对 C++ 协程运行时行为的精确心智模型。
1. 问题:协程解决什么?
考虑一个典型的异步 IO 场景:
Task<Response> handle_request(Socket s) {
auto header = co_await s.read_header(); // 等待头部到达
auto body = co_await s.read_body(header.content_length); // 等待正文
co_return Response{header, body};
}co_await 处,当前函数可以"挂起"——把控制权还给调用者/事件循环,当数据就绪后再"恢复"执行。期间不需要阻塞任何线程。
但如果用传统的回调或状态机来实现,代码会被切成碎片:
// 等价的手写状态机(伪代码)
void handle_request(Socket s, callback cb) {
s.read_header([s, cb](Header h) {
s.read_body(h.content_length, [s, cb](Body b) {
cb(Response{h, b});
});
});
}协程的价值就是用同步语法写异步逻辑,让编译器代劳状态机的生成。
2. 三个关键字:co_await / co_return / co_yield
一个函数只要出现了这三个关键字之一,就成为协程:
Task<int> compute() {
int x = co_await async_read(); // 挂起点
co_return x * 2; // 返回最终值
}
Generator<int> sequence() {
for (int i = 0; i < 10; ++i)
co_yield i; // 产出一个值,挂起,等待下次迭代
}co_yield value 等价于 co_await promise.yield_value(value)。
3. 协程帧:编译器替你分配的状态机
当编译器遇到协程函数时,它会做三件事:
- 分配协程帧(coroutine frame)——堆(通常是
operator new) - 把所有跨越挂起点的局部变量打包到协程帧中
- 生成状态机代码:根据
co_await的挂起/恢复切换状态
Task<int> example(int n) {
auto a = co_await async_op1(); // 挂起点 1
auto b = n * 2; // n 和 b 必须存活到挂起点 2 之后
auto c = co_await async_op2(); // 挂起点 2
co_return a + b + c;
}
// 编译器生成的状态机(概念性伪代码):
// struct __frame {
// promise_type __promise;
// int __state; // 0: 初始, 1: 在 async_op1 之后, 2: 在 async_op2 之后
// int n, a, b, c; // 跨挂起点的局部变量
// Awaiter1 __awaiter1;
// Awaiter2 __awaiter2;
// };无栈 vs 有栈协程
C++ 的协程是无栈(stackless)——协程帧分配在堆上,不依赖独立的调用栈。这与 Go 的 goroutine(有栈)不同。无栈协程的优势是不需要运行时来管理栈切换,每次挂起/恢复的开销只相当于一个虚函数调用;但没有独立的栈也就意味着协程内不能递归太深,且所有跨越挂起点的数据必须被显式"提升"到堆上的协程帧中。
4. Promise 类型:协程的控制中枢
每个协程都与一个 Promise 类型关联。Promise 不是 std::promise<T>(那个是线程间传值的),而是协程框架中的一个概念——它负责:
- 构造协程的返回对象(
get_return_object()) - 定义初始行为(
initial_suspend()) - 处理
co_return的值(return_value()或return_void()) - 处理未捕获异常(
unhandled_exception()) - 定义最终行为(
final_suspend())
一个完整的 Promise 骨架:
template<typename T>
struct TaskPromise {
Task<T> get_return_object();
std::suspend_never initial_suspend() { return {}; } // 不挂起,立即执行
std::suspend_always final_suspend() noexcept { return {}; } // 最后挂起,等待结果被取走
void return_value(T value) { result_ = std::move(value); }
void unhandled_exception() { exception_ = std::current_exception(); }
T result_;
std::exception_ptr exception_;
};编译器通过 typename std::coroutine_traits<ReturnType>::promise_type 来查找 Promise 类型。默认规则是 ReturnType::promise_type。
5. Awaiter 协议:挂起与恢复的接缝
co_await expr 不是简单的"等待一个值"。编译器会将它展开为一个三步的 Awaiter 协议:
await_ready()→ 如果返回true,表示结果已就绪,跳过挂起(同步路径)await_suspend(handle)→ 如果await_ready()返回false,传入当前协程的 handle。此处可以把 handle 注册到 IO 完成通知、调度队列等await_resume()→ 协程被恢复后,这个函数提供co_await的返回值
struct SimpleAwaiter {
bool ready_;
bool await_ready() const noexcept { return ready_; }
void await_suspend(std::coroutine_handle<>) const noexcept {
// 注册 handle 到某个调度器,异步完成时调用 handle.resume()
}
int await_resume() const noexcept { return 42; }
};
int result = co_await SimpleAwaiter{true}; // ready_=true → await_resume() 被直接调用await_suspend 的返回值有几种形式:
void→ 挂起后控制权返回给resume()的调用者bool→true表示确实挂起,false表示不挂起(走同步路径)std::coroutine_handle<>→ 对称转移——跳转到另一个协程,不回到调用者
6. HALO 优化:协程帧在堆上的消除
HALO(Heap Allocation eLision Optimization)是协程最关键的优化。正常情况下,协程帧分配在堆上——这需要一次动态分配。但编译器可以在以下情况下消除堆分配:
- 协程帧的大小在编译期可确定
- 协程帧的生命周期被嵌套在调用者的栈帧内
- 编译器通过别名分析可以确定指针不会逃逸
在实际代码中,最简单的触发 HALO 的方式是:使用 generator 并在 range-for 中消费它——不要把它捕获到 lambda 或 std::function 中。
HALO 依赖编译器和上下文
HALO 不是 C++ 标准强制要求的——它是"as-if"规则下的编译器优化。Clang 和 MSVC 在简单场景下表现良好,GCC 的 HALO 覆盖范围也在逐步增加。如果协程帧分配是你的性能瓶颈,请务必检查编译器输出的汇编,而不是假设 HALO 一定发生。
7. 标准库提供的"积木块"
C++20 标准库不提供 Task<T>、Generator<T> 等高层协程类型,但提供了几个构建块:
// suspend_never / suspend_always
std::suspend_never // await_ready() 始终返回 true —— 不挂起
std::suspend_always // await_ready() 始终返回 false —— 总是挂起
// coroutine_handle<void>
// 协程的"地址" —— 可调用 handle.resume() 或 handle.destroy()完整的高层协程类型(如 task<T>、sync_wait、when_all)由第三方库提供(如 cppcoro、libunifex、folly::coro),或等待 C++23/26 的 std::execution(Senders/Receivers 模型)。
8. Coroutines 与 Senders/Receivers (C++26)
C++23/26 正在标准化的 std::execution 引入了一个更通用的异步模型——Senders and Receivers(P2300)。它将异步操作建模为三个抽象:
- Sender:描述一个异步操作("发送一个值")
- Receiver:接受异步操作的结果("接收完成/值/错误")
- Scheduler:控制异步操作在哪个执行上下文运行
Coroutines 的 co_await 与 Senders/Receivers 可以桥接:一个 task<T> 既是 sender(可以被另一个协程 co_await),也是 receiver(可以等待一个 sender 的结果)。这套模型很可能成为 C++26 并发设施的核心。
总结
| 概念 | 角色 |
|---|---|
| 协程帧 | 编译器分配的、存储跨挂起点状态的结构体 |
| Promise | 协程的控制中枢——定义创建、返回、异常、终结行为 |
| Awaiter | co_await 的三步协议——就绪检查、挂起注册、恢复取值 |
| coroutine_handle | 协程的轻量指针——用于 resume() 或 destroy() |
| HALO | 编译器的堆分配消除优化 |
C++ 的协程选择了"零开销抽象"的设计哲学:标准库只提供底层积木,不替你做决策。这对库作者来说是自由,对应用开发者来说是门槛。理解协程帧的分配、Awaiter 协议的三步展开,以及 HALO 的触发条件,是"不害怕 C++ 协程"的起点。