最近有不少同行问同一个问题:在 C++20 里 co_await 到底是怎么工作的?协程这个特性从标准定稿到现在,社区的争议一直没停过——喜欢的人觉得它是做异步 I/O 的利器,讨厌的人抱怨标准库什么都没给,连最基本的 task 都得自己造。我自己踩了几个月的坑之后才摸清底细:co_await 这个语法糖背后,是编译器帮你做的一套状态机转换,以及 Awaitable 和 Awaiter 两个概念的严格约定。
这篇文章只干一件事:把 co_await 的实现机制掰开揉碎了讲。你会看到一个协程函数在被编译器改写后长什么样,三个约定的成员函数 await_ready、await_suspend、await_resume 各自承担什么职责,await_suspend 的三种返回值会对控制流造成什么影响,以及对称转移(symmetric transfer)为什么是 C++20 协程设计里最容易被忽视、又最容易踩栈溢出的地方。内容会很底层,但我会尽量用代码和伪代码把它讲明白。
如果你正处于“能写出协程 demo,但稍微加点业务逻辑就不知道控制流怎么走”的状态,这篇文章就是给你准备的。
1. 协程函数编译后是什么:状态机、协程帧与挂起点
1.1 编译器把一个函数拆成了什么
普通函数在调用时,操作系统会为它在当前线程的栈上分配一块栈帧。函数结束,栈帧弹出,局部变量随之销毁。协程函数不一样:一个函数只要包含了 co_await、co_yield 或 co_return 中的任意一个,它就不再是一个普通函数,编译器不会按照普通函数的调用约定来生成代码。
编译器会把协程函数改写成一个状态机对象。大致过程是:函数第一次被调用时,在堆上分配一块“协程帧”(coroutine frame),把所有可能跨越挂起点继续存活的变量——参数副本、局部变量、当前执行到的位置——都塞进这块帧里。之后每次调用 coroutine_handle::resume(),本质上就是让这个状态机从上次停下的地方继续跑,跑完一个又一个挂起点。
这里有一个很关键的点:协程帧不是在栈上分配的。原因很简单,协程挂了之后,调用它的那个栈帧可能早就没了。如果协程的局部变量还留在原来的栈上,恢复执行的时候这些数据早就被覆盖了。所以编译器必须把这些变量搬到一块独立的内存上,这块内存的生命周期和协程本身的生命周期绑定。
我写一个简化版的结构示意,你感受一下编译器眼中的函数是什么样子:
cpp复制// 源代码
CoroutineTask foo(int x) {
int y = x + 1;
co_await something;
int z = y * 2;
co_return z;
}
// 编译器眼中的状态机结构(简化示意,非真实实现)
struct __foo_frame {
using promise_t = CoroutineTask::promise_type;
promise_t __promise; // 编译器和开发者之间的约定接口
int __suspend_index = 0; // 当前执行到哪个挂起点
int x_copy; // 参数 x 的副本
int y; // 局部变量 y
int z; // 局部变量 z
};
// resume 时的入口逻辑(伪代码)
void __resume(__foo_frame* __frame) {
switch (__frame->__suspend_index) {
case 0: goto __start;
case 1: goto __susp1;
case 2: goto __susp2;
}
__start:
__frame->y = __frame->x_copy + 1;
__frame->__suspend_index = 1;
// co_await something
// 这里会判断是否挂起;如果挂起,直接 return
// 恢复时跳转到 __susp1
__susp1:
__frame->z = __frame->y * 2;
__frame->__suspend_index = 2;
// co_await another
__susp2:
__frame->__promise.return_value(__frame->z);
// 最终挂起点 final_suspend,之后根据约定决定是否释放帧
}
你不需要把这个结构背下来,只需要记住三件事:第一,协程函数变成了一个帧对象,帧里存了所有需要跨挂起存活的变量;第二,帧里有一个挂起点索引,记录执行到哪个位置;第三,resume() 的逻辑本质上是根据挂起点索引做一次跳转,回到之前的执行位置。
1.2 promise_type:开发者与编译器的约定接口
协程函数返回的对象类型里,必须嵌套定义 promise_type。这个类型就是编译器和开发者之间的“通信协议”,编译器在生成的代码中会主动创建 promise_type 对象,并在恰当的时机调用它的成员函数。
promise_type 至少需要具备以下几个关键成员(编译器会在这些时机调用它们):
| 成员函数 | 调用时机 | 典型用途 |
|---|---|---|
get_return_object() |
协程帧分配完成后、协程体执行前 | 返回给外部调用者的协程对象 |
initial_suspend() |
协程体开始执行前 | 决定协程是否立即挂起 |
final_suspend() |
协程体执行完成后 | 决定协程结束时的挂起行为 |
return_void() 或 return_value(v) |
co_return 触发时 |
把返回值存入 promise |
unhandled_exception() |
协程体内异常未被捕获时 | 处理异常 |
这里提醒一个初学者很容易搞混的点:initial_suspend() 返回 std::suspend_never 时,协程函数被调用后会直接执行函数体;返回 std::suspend_always 时,协程函数被调用后挂在起点,必须手动 resume() 才会执行函数体。很多异步框架选择 `sus
