最近有不少同行问同一个问题:在 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() 才会执行函数体。很多异步框架选择 suspend_always,是因为函数被调用时,调用者可能还需要一些时间把协程句柄注册到事件循环里,在此之前不希望协程体抢先执行。
final_suspend() 也很有讲究。如果它返回 std::suspend_always,协程执行到最后会挂起,协程帧不会自动释放,你必须持有句柄并手动调用 handle.destroy();如果返回 std::suspend_never,协程执行完会自动释放帧。这里的取舍直接影响内存管理模型,我后面在第五节还会展开。
一个最简单的 promise_type 长这样:
cpp复制class Task {
public:
struct promise_type {
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
Task get_return_object() noexcept {
return Task(std::coroutine_handle<promise_type>::from_promise(*this));
}
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
~Task() { if (handle_) handle_.destroy(); }
std::coroutine_handle<promise_type> handle_;
};
1.3 帧内局部变量的生命周期问题
普通函数的局部变量会随着函数返回自动析构,协程不会。因为局部变量被搬到了协程帧里,析构时机推迟到了帧销毁的时候。如果你在协程里用 RAII 对象管理锁、文件句柄、内存,这些资源在协程挂起的过程中依然是持有的。
这点和一般人的直觉不太一样:挂起不等于结束,更不等于资源释放。
我在实际开发中见过一个很典型的 bug:协程里创建了一个 std::lock_guard<std::mutex> 锁住某个互斥量,然后 co_await 等待网络响应。作者以为挂起时锁会被释放,结果锁一直持有到响应返回、协程继续执行并退出锁定作用域之后。另一个线程想获取同一把锁时直接卡死。问题的根源就是没理解“帧内对象生命周期被拉长”这件事。
所以写协程时必须时刻问自己:这个对象在挂起期间被谁持有?会不会有别的路径提前销毁它?凡是需要跨挂起点持有的资源,生命周期管理必须显式化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. co_await 的幕后脚本:从表达式到三条编译器推进逻辑
2.1 co_await 的完整翻译过程
现在进入正题:co_await exp 这一句话,编译器到底做了什么?
标准流程大致是这样的:编译器先对 exp 求值,得到一个“可等待体”(Awaitable)。如果这个可等待体本身没有 operator co_await,那它就直接被当作 awaiter 使用;如果有,则调用 operator co_await 转换得到一个 awaiter。之后,编译器把这个 awaiter 的三个成员函数拼装成一段逻辑:
cpp复制// co_await exp 的编译器生成逻辑(伪代码)
{
auto&& awaiter = /* exp 可能的 operator co_await 展开 */;
if (!awaiter.await_ready()) {
// 保存当前挂起点到帧
__frame->__suspend_index = 当前挂起点编号;
// 调用 await_suspend,传入当前协程句柄
if (awaiter.await_suspend({__frame})) {
return; // 挂起:控制权返回调用者
}
// 如果返回 false,则继续往下走,不挂起
}
// 恢复点 / 快速路径
auto result = awaiter.await_resume();
// 继续执行 co_await 之后的代码
}
注意这里的执行顺序:先 await_ready(),再 await_suspend(),最后 await_resume()。很多人以为 await_suspend 是在挂起之后才调用的,这是误解。准确地说,await_suspend 是在“决定要不要挂起”和“正式挂起”之间调用的钩子。它执行完返回之后,协程才决定是真正把控制权交出去还是继续往下跑。
2.2 await_ready:先问一句“还要不要挂”
await_ready() 的作用是提供一个快速路径,避免无意义的挂起和恢复。如果异步结果已经就绪(比如数据已经从 socket 里读出来了),就没必要做整套的状态机切换,直接把结果取走往下执行即可。
比如一个封装了已完成任务的 awaiter:
cpp复制struct ReadyAwaiter {
bool await_ready() const noexcept { return true; }
void await_suspend(std::coroutine_handle<>) const noexcept {}
int await_resume() const noexcept { return 42; }
};
因为 await_ready() 返回 true,所以 await_suspend() 永远不会被调用,协程会直接执行 await_resume() 拿到结果。这种设计对于“某些调用已经完成、某些调用还在等待”的场景非常有价值,可以避免不必要的状态切换开销。
实现自己的 awaiter 时,我建议在 await_ready() 里做尽可能多的低成本检查,能短路的尽量短路。这比在 await_suspend() 里反复判断要省事得多,也符合协程设计的初衷:能立刻返回就立刻返回,别动不动挂起。
2.3 await_suspend:挂起动作发生的时机与上下文
await_suspend() 是三个函数里最核心、也是最容易用错的。它在 await_ready() 返回 false 之后被调用,编译器会传入当前协程的句柄 std::coroutine_handle<>。这个句柄是不透明的,你不需要关心它内部存了什么指针,只需要知道三件事:
- 把这个句柄保存到任意容器里,之后在任何地方调用它的
resume(),协程就会从挂起点恢复; - 调用
destroy(),协程帧会被销毁,协程结束; from_promise()可以从 promise 对象反推句柄,用于在协程外部访问 promise 状态。
await_suspend 是异步机制真正发生的地方。比如你要封装一个网络读操作,完整流程是:在 await_suspend 里发起非阻塞 read(),把协程句柄注册到 I/O 完成回调里。等数据到达,回调线程调用 handle.resume(),协程恢复执行。
挂起本身并不阻塞任何线程,它的语义是“我现在退让出来,将来你再通过这个句柄把我叫回来”。
2.4 await_resume:结果如何回到协程里
当协程被 resume() 恢复之后,执行流会跳回到挂起点,然后调用 await_resume(),它的返回值就是整个 co_await 表达式的结果。
await_resume() 可以返回任意类型,也可以返回 void。它在协程恢复执行的上下文中被调用,所以这里面可以安全地做结果校验、异常抛出等操作。比如读操作失败了,可以在 await_resume() 里 throw 异常,这个异常会从当前协程内部抛出,走 promise_type::unhandled_exception() 的异常处理路径。
值得注意的一点是:await_resume() 的签名决定了表达式结果的类型。一个常见的误区是试图在 await_ready() 里返回值——这个函数只被用来判断是否就绪,它的返回值必须是 bool,不承载业务数据。业务数据要挂在 await_resume() 上,两个函数的职责一定要分开。
3. await_suspend 返回值的三种命运:void、bool 与对称转移
3.1 void:最简单的“挂住就完事”
当 await_suspend() 返回 void 时,编译器生成的逻辑等价于:调用完 await_suspend() 后,无论它内部做了什么,直接挂起,控制权返回调用者。
这是最常见的一种写法。典型的场景是,我已经把协程句柄交给某个事件循环了,接下来就是要等事件触发,不需要再做任何条件判断。
cpp复制struct VoidAwaiter {
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) const noexcept {
// 把 h 注册到某个事件源,等事件触发后调用 h.resume()
}
void await_resume() const noexcept {}
};
注意,即使 await_suspend 内部发生了异常,协程的挂起逻辑也不会被跳过。标准规定,如果 await_suspend 抛异常,异常会被捕获并交给 promise_type::unhandled_exception(),而不是继续执行协程体。这点比较容易踩坑,我后面第五节会详细说。
3.2 bool:要不要挂,运行时再定
await_suspend() 返回 bool 时,语义是:返回 true 挂起,返回 false 不挂起。如果返回 false,编译器会跳过“挂起返回”这个分支,直接进入恢复点并调用 await_resume()。
这个设计很巧妙,它给“尝试性操作”留下了空间。举个例子,我想让协程尝试获取一把非阻塞锁。拿到锁了,我不需要挂起,直接继续执行;没拿到锁,我把协程句柄放进锁的等待队列,挂起等待。
cpp复制struct TryLockAwaiter {
std::mutex& mutex_;
bool locked_ = false;
bool await_ready() const noexcept { return false; }
bool await_suspend(std::coroutine_handle<> h) {
locked_ = mutex_.try_lock();
if (!locked_) {
// 注册到锁的等待队列,mutex_ 释放时调用 h.resume()
wait_queue_.push(h);
}
return !locked_; // true 挂起;false 不挂起,直接继续
}
void await_resume() { if (!locked_) mutex_.lock(); }
};
这里 return !locked_; 就很自然地表达了“没锁上就挂起,锁上了就继续”的控制流。如果不支持 bool 返回值,这种逻辑就得拆到 await_ready() 里,而 await_ready() 拿不到协程句柄,没法注册回调,实现起来会很别扭。
3.3 coroutine_handle 与对称转移的原理
await_suspend() 最不直观的返回值是 std::coroutine_handle<>。如果返回一个协程句柄,编译器会直接 resume() 那个句柄对应的协程,然后让当前协程立即返回到它的调用者。翻译成伪代码:
cpp复制// 如果 await_suspend 返回 coroutine_handle<>
if (auto next = awaiter.await_suspend({__frame}); next != nullptr) {
next.resume(); // 被恢复的协程运行在“当前协程的栈帧位置”
return; // 当前协程不再继续,直接返回
}
这本质上是一个尾调用。当前协程的调用栈帧没有留下来等 next.resume() 返回,而是在调用 next.resume() 之前就把当前协程的控制权交出去了。
这就是 C++20 协程设计里非常重要的“对称转移”机制。它解决的是协程之间互相同意、链式切换时的栈增长问题。
3.4 对称转移如何避免递归栈溢出
想象这样一个场景:协程 A 通过 co_await 切到协程 B,B 切到 C,C 切回 A……如果采用“每个协程在自己栈帧里等待对方完成”的嵌套模型,每次切换都会让调用栈多一层。假设有 10 万个协程按链式关系切换,栈深度就会是 10 万层,程序大概率直接栈溢出。
对称转移通过“尾调用式”的切换避免了这个问题。A 挂起并 resume(B) 之后,A 的栈帧被释放了,B 实际上是在 A 原来所在的栈上继续跑。B 再切到 C 时,B 的栈帧也被释放。无论切换链有多长,调用栈的深度始终保持在比较小的水平。
为什么要单独强调这个机制?因为如果 await_suspend() 返回 void 或 bool,并且协程 A 在 await_suspend 内部直接调用 h.resume(),那就不存在对称转移了,这是普通函数调用,栈会累积。
我举个例子:
cpp复制// 危险做法:在 void await_suspend 里直接 resume 另一个协程
void await_suspend(std::coroutine_handle<> h) {
another_coroutine_.resume(); // 递归式 resume
h.resume();
}
这种写法在协程数量少的时候看不出问题,一旦协程链变长,栈会一层层叠上去,最终栈溢出。遇到高并发场景,正确做法是让 await_suspend() 返回另一个协程的句柄,启用对称转移。
4. 从零手写一个可等待定时器:把事件机制变成协程
4.1 设计一个纯用户态的 sleep_awaitable
理解了原理,我们用一个实际例子把拼图拼起来。下面手写一个最简单的定时器 awaiter,核心就是:不在 await_ready() 里阻塞,而是在 await_suspend() 里启动一个延迟回调,回调触发时再 resume()。
cpp复制#include <coroutine>
#include <chrono>
#include <thread>
#include <iostream>
struct SleepAwaiter {
std::chrono::steady_clock::duration duration_;
std::coroutine_handle<> handle_;
bool await_ready() const noexcept {
// 时间为零或负数,不需要等待
return duration_ <= std::chrono::steady_clock::duration::zero();
}
void await_suspend(std::coroutine_handle<> h) {
handle_ = h;
// 教学简化版:单独起线程等待,真实场景应交给事件循环
std::thread([this]() {
std::this_thread::sleep_for(duration_);
handle_.resume();
}).detach();
}
void await_resume() noexcept {}
};
用的时候也很直白:
cpp复制Task demo() {
std::cout << "begin\n";
co_await SleepAwaiter{std::chrono::milliseconds(500)};
std::cout << "500ms passed\n";
}
这是一个能跑起来的完整示例,但我要强调:上面这段代码是教学简化版,不要在生产代码里用 std::thread 加 detach() 实现定时器。真实工程里如果逐个协程起线程,系统很快会被线程灾难搞垮。正确的做法是交给统一的调度批次处理,比如把句柄注册进 epoll 或 io_uring 的超时管理结构里。
4.2 集成到事件循环的 awaiter 改造
把上面的睡眠 awaiter 改造成事件循环版本,唯一需要变化的就是 await_suspend() 内部的实现。假设有一个事件循环对象 io_context,它提供 schedule_after(duration, callback) 接口:
cpp复制struct TimerAwaiter {
std::chrono::steady_clock::duration duration_;
std::coroutine_handle<> handle_;
bool await_ready() const noexcept {
return duration_ <= std::chrono::steady_clock::duration::zero();
}
void await_suspend(std::coroutine_handle<> h) {
handle_ = h;
io_context.schedule_after(duration_, [this]() {
handle_.resume(); // 事件循环超时后回调
});
}
void await_resume() noexcept {}
};
这里最关键的一点是:handle_ 被存进了 awaiter 的成员变量,而 awaiter 的生命周期是由协程帧保证的,不是由 co_await 所在的那个词法作用域保证的。编译器在生成代码时会把 awaiter 对象放进协程帧,所以即使 co_await 表达式所在的函数栈已经释放了,awaiter 仍然存活。这正是协程能实现异步回调的核心保障。
不过要注意,如果 co_await 的是一个右值临时对象,编译器仍会保证它活到整个 co_await 表达式结束;但如果这个临时对象的内部又持有了别的对象的引用,那就得确保那个被引用对象在协程挂起期间不会销毁。这一点上没有例外,全看你的帧里放的是什么。
4.3 线程安全与生命周期:最容易翻车的两个点
第一个坑是并发 resume()。多个线程同时调用同一个协程句柄的 resume() 会导致未定义行为。大多数事件循环框架会保证同一个协程的完成回调不会被并发触发,但在自定义的 awaiter 里,你需要自己确认这一点。比如上面定时器的回调,如果事件循环是线程池实现的,你很难保证同一个计时器不会被两个线程同时触发,所以必须在调度器里加锁或串行化。
第二个坑是 awaiter 本身的状态同步。await_suspend() 在发起异步操作的线程执行,await_resume() 在将来某个线程执行,二者之间可能有数据竞争。如果你在 awaiter 中保存了共享状态,比如一个 int 结果值,恢复操作和读取操作需要通过 std::atomic 或锁来保护,或者用严格的事件循环线程模型保证读写发生在同一线程上。
我在实际工程里处理过这样一个问题:一个 awaiter 内部用一个 std::future<T> 存结果,await_ready() 里调用 future.wait_for(0) 检查是否就绪,await_suspend() 里再起一个线程 wait() 并 resume()。看似没毛病,但 future 对象的引用计数在多个线程间共享,在没有同步的情况下,waiter 可能已经被回收,另一个线程还在用,内存直接崩溃。后来改成把 future 放在 shared_ptr 里,并让回调持有这个 shared_ptr,问题才消失。
5. 实践中的坑与调试经验
5.1 悬垂引用:协程挂起不等于对象保活
协程帧只保存值类型的局部变量。如果你在协程里引用了一个栈上的对象,协程挂起后,那个栈帧很可能已经随着外层函数返回而销毁了。等协程恢复,你拿到的引用就是悬垂引用。
举一个反面例子:
cpp复制Task bad_coroutine(const std::string& text) {
co_await std::suspend_always{};
std::cout << text << "\n"; // text 引用的对象可能已经销毁
}
如果调用方传入的是一个临时字符串:
cpp复制bad_coroutine(std::string("hello"));
临时字符串的生命周期到完整表达式结束时终止,但协程第一次进入时因为 suspend_always 挂起了,之后恢复执行再访问 text,这个引用已经指向一块被释放的内存。
这类问题在协程里比普通函数隐蔽得多,因为编译器很难在协程语义下检测到生命周期错误。我的建议:协程函数的所有参数,凡是需要在挂起点之后使用的,一律按值接收,不要按常量引用接收。
5.2 谁在哪个线程 resume 你
C++20 协程没有规定 resume() 必须在发起挂起的同一个线程调用。这意味着协程恢复后可能运行在完全不同的线程上。对于依赖 thread_local 变量、线程绑定锁、GPU context 的代码,这是最容易踩的暗雷。
比如,你在主线程创建了一个协程,挂起后把句柄交给 I/O 线程。I/O 完成回调在 I/O 线程里调用了 resume(),那么协程恢复之后的代码就运行在 I/O 线程。如果你在协程里访问了主线程才有的 thread_local,就会拿到 I/O 线程里的另一份数据;如果你在协程里尝试获取主线程持有的非递归锁,可能直接死锁。
排查这类问题有个笨办法,但很有效:在协程入口和恢复点各打印一下 std::this_thread::get_id()。如果两次线程 ID 不一致,说明发生了跨线程恢复。你必须决定是接受这种线程迁移(大多数异步框架是接受的),还是在 resume 时把协程投递回原线程。
5.3 多阶段 await 的调用顺序与异常路径
一个 co_await 表达式涉及 await_ready()、await_suspend()、await_resume() 三步,每一步都可能抛异常。标准规定,await_ready() 和 await_resume() 抛出的异常,如果不在协程体内被捕获,会进入 promise_type::unhandled_exception()。await_suspend() 抛出的异常也一样,会进入 unhandled_exception(),而不是直接传回调用者线程。
但在实践中,不同编译器的实现细节会有差别,尤其是 await_suspend() 抛异常时“当前协程已经被标记为挂起”这个状态可能会留下半挂起的帧,处理不当就会造成泄漏。所以我在自定义 awaiter 时,一般会让 await_suspend() 内部不抛异常,把可能的错误存到 awaiter 里,等 await_resume() 再统一抛出或处理。
这里有一个容易忽略的细节:await_suspend() 被调用时,协程已经为“挂起”做好了准备,但不一定已经真正挂起。如果它在抛出异常之前已经把句柄注册到了某个回调容器里,异常抛出后,协程帧可能永远不会被 destroy(),回调仍然持有句柄,等事件触发时调用一个已经无效的句柄,这是非常隐蔽的内存泄漏加悬垂问题。所以我建议:在 await_suspend() 里先完成所有可能失败的操作,最后再注册句柄;或者注册句柄之后不再做任何可能抛异常的代码。
5.4 性能提醒:协程不等同于零开销
不少人把 C++20 协程当成“零开销抽象”。实际上,协程帧大多数时候需要堆分配,单次分配的开销虽然不大,但在高并发、高频创建的场景下,分配器压力不可忽视。编译器有协程帧融合的优化,能把这个分配优化掉,但前提是协程的完整生命周期能被编译器静态分析出来。一旦你把句柄传出了当前编译单元,优化就基本失效了。
我的经验是:协程适合做“长生命周期的异步状态机”,不适合做“高频率的短小任务”。如果你每秒创建几十万个协程对象,即使每次只省几点开销,分配器的锁竞争也会拖垮延迟。遇到这种情况,优先考虑线程池加队列,而不是硬上协程。
另外,resume() 本质上是一个间接跳转,可能会破坏 CPU 分支预测。在极端性能敏感的回调链上,我见过协程版本比手写状态机版本慢 20% 左右的情况。所以协程的价值主要在于代码可维护性和控制流表达力,而不是纯粹的极限性能。
最后再分享一个调协程时的实用技巧:很多编译器支持 -fcoroutines 或对应开关,开启地址消毒器(ASan)进行内存检查。协程帧的堆分配与栈切换对 ASan 来说是可见的,它能帮你抓住很多悬垂引用和生命周期错误。在写自定义 awaiter 时,我强烈建议开着 ASan 跑一遍单元测试,比自己肉眼看代码靠谱得多。
