聊到C++20协程,绕不开的关键字就是co_await。我最早读规范的时候,被promise_type、coroutine_handle、awaiter这些概念来回绕晕过,单独看每个都懂,放一起就想摔键盘。这篇博文我换个思路,直接站到编译器和运行时两个视角,把co_await背后那套隐藏机制完整扒一遍,重点讲清楚它到底帮你生成了什么代码、挂起点是怎么保存的、恢复执行又是怎么跳回去的。适合已经能写模板和现代C++、但看到协程源码就头大的同学,也适合打算自己实现一个最小协程执行器的实践党。
先说结论:co_await不是普通表达式,它是C++里少见的、能够直接改变函数控制流的语言设施。一套完整理解下来,你对异步框架、线程调度、生成器实现的理解都会通一层,后面去读 cppcoro、asio 那些协程代码,也不会再觉得是天书。
1. 先搞清楚C++20协程是为了解决什么
1.1 异步代码的老大难问题
在没有协程的年代,写一个异步读取操作,最常见的做法是回调。伪代码大概长这样:
void read_async(conn_t conn, buffer_t buf, callback_t cb) { submit_io(conn, buf, [cb, buf](io_result result) { if (result.failed()) { cb(result.error()); return; } process(buf, [cb, buf](parse_result parsed) { cb(parse_result_to_response(parsed)); }); }); }嵌套回调一多,控制流就散落到各个 lambda 里,错误处理路径也和主路径交错在一起。想了解这段代码什么时候执行完,只能靠回调触发的时机去推断;想中途取消,又是另一套状态管理。更麻烦的是,逻辑一旦需要根据中间结果做分支,回调写法基本就是手写状态机,每一层分支都要小心保存变量。
C++20 协程的出现,就是想让异步代码写起来像同步代码:
conn_result do_read(conn_t conn, buffer_t buf) { io_result result = co_await async_read(conn, buf); if (result.failed()) { co_return make_error(result); } parse_result parsed = co_await async_parse(buf); co_return make_response(parsed); }这个co_await做的事情,相当于告诉编译器:这个地方可能还没准备好结果,先把当前函数挂起来,等异步操作完成再回来继续。重要的是,挂起期间不阻塞线程,线程可以去跑别的任务。
一句话理解:回调是“事件驱动+闭包”,协程是“可暂停/可恢复的函数”。
1.2 C++20协程的三个核心抽象
要理解co_await,先得记住三样东西:
promise_type:协程与调用者之间的协议对象。它负责协程生命周期管理、结果存取、初始挂起/最终挂起的行为控制。coroutine_handle:协程帧的轻量句柄,不拥有内存,但可以显式resume()或destroy()。awaiter:co_await操作的对象,定义了“挂不挂”“挂起后干什么”“恢复后返回值是什么”。
一句话概括三者的关系:编译器生成协程帧,帧里放着promise_type;调用者通过coroutine_handle操作它;co_await的具体行为由awaiter决定。
1.3 协程和线程不是一回事
很多新手把 C++20 协程和线程混在一起。其实协程是控制流结构,线程是系统调度实体。线程有自己的内核栈和调度器,协程没有自己的栈,它是把当前函数的栈帧状态保存到堆上的协程帧里,切换成本远低于线程切换。
可以把线程想象成多条独立电话线,每条都能并行通话;协程则是同一条电话线上挂起多个通话,A 说一半放下听筒,B 接起来说几句,再换回 A。同一时刻只有一个协程在某个线程上实际运行,但任务切换非常快,而且不涉及内核态切换。
这个特性决定了协程特别适合 I/O 密集型场景:读文件、写 socket、等待网络响应的时候,协程挂起后线程立刻去调度其他协程,CPU 利用率能拉满。
2. 编译器把你的函数改成了什么:协程帧与状态机
2.1 协程帧(coroutine frame)为什么在堆上
普通函数执行完后,栈上的局部变量随栈帧一起销毁。但协程涉及挂起和恢复——挂起时希望保留局部变量,等恢复后继续用;恢复时栈帧却可能已经完全退出了。所以C++标准规定,协程的局部变量和上下文保存在协程帧(coroutine frame)里,这个帧通常分配在堆上。
协程帧里至少包含这些内容:
- 协程参数(按值拷入,避免引用失效)
- 当前的局部变量
- 当前执行到的状态(挂起点编号)
promise_type对象- 当前挂起的
awaiter(某些实现会临时构造)
编译器在调用协程函数时会先分配这个帧。一步到位解释就是:你的协程函数调用点,其实只负责创建一个帧,函数体未必立刻执行。
标准没有把帧分配方式写死。如果编译器能分析出 handle 不会逃逸出当前作用域,有可能会优化成栈上分配,这被称为 HALO(Heap Allocation elision Optimization),MSVC 在这方面做得比较激进。但作为使用者,我们仍应默认它可能分配堆内存,并以此设计生命周期管理。
2.2 co_await 实际上被改写成了状态机
这个环节是理解co_await的分水岭。
编译器会把包含co_await的协程函数改造成一个类状态机的函数。每次co_await处,就是一个挂起点(suspension point),编译器会为它生成一个隐藏 label,然后根据状态号跳转。
举一个极度简化的等价模型:
// 源代码 task foo() { int a = 1; co_await A{}; ++a; co_await B{}; co_return; } // 编译器处理后的伪代码(极度简化) void foo_frame(frame* f) { switch (f->state) { case 0: goto state_0; case 1: goto state_1; } state_0: f->a = 1; f->awaiter = A{}; if (!f->awaiter.await_ready()) { f->state = 1; // 记录挂起点 f->awaiter.await_suspend(f->handle); return; // 控制权回到调用者/恢复者 } f->a++; state_1: f->awaiter = B{}; if (!f->awaiter.await_ready()) { f->state = 2; f->awaiter.await_suspend(f->handle); return; } co_return_impl(f); }实际编译器生成的代码比这个复杂得多(还需要异常处理表、final_suspend处理等),但骨架就是这套:函数体内联进状态机,每次挂起用状态号记录位置,恢复时直接跳转到对应 label。
所以co_await的真实效果,等于是把函数“拆开”了——前半段在这个调用周期跑完,后半段留到下次resume()再跑。
2.3 谁负责销毁协程帧
协程帧的销毁责任非常容易踩坑。
- 协程正常执行到
co_return,会调用promise_type::return_void()或return_value(),然后进入final_suspend挂起点。 - 如果
final_suspend()返回std::suspend_always,协程会停在那里,必须由外部调用handle.destroy()主动释放帧。 - 如果
final_suspend()返回std::suspend_never,协程结束时会自动销毁帧。
我在写基础Task类时,默认使用std::suspend_always作为final_suspend,因为我要保证在协程真正结束前,调用方还能安全地访问一些结果状态。但代价是显式管理destroy()变成了责任,一旦忘记,就泄漏一个堆帧。
提示:协程帧生命周期管理是 C++20 协程和大多数语言协程最不一样的地方。Python 协程不需要手动释放,C++ 的协程帧却需要明确的
destroy(),设计接口时一定要把回收责任放进 RAII 类里。
3. co_await 的实现机制:从表达式到调用序列
3.1 awaiter 协议拆解
co_await expr在语义上并不是简单调用某个函数。标准定义了一套查找规则:
- 如果
expr类型有operator co_await(),则调用它,得到awaiter。 - 否则
expr本身必须是awaiter。 - 得到
awaiter后,执行顺序是:- 先调用
awaiter.await_ready() - 若返回
true,表示结果已经就绪,跳过挂起,直接调用await_resume()拿结果 - 若返回
false,调用awaiter.await_suspend(handler),这里决定具体挂起行为 - 恢复执行后,调用
awaiter.await_resume(),其返回值作为co_await表达式的结果
- 先调用
标准库提供了两个现成的 awaiter:
struct suspend_always { bool await_ready() const noexcept { return false; } void await_suspend(coroutine_handle<>) const noexcept {} void await_resume() const noexcept {} }; struct suspend_never { bool await_ready() const noexcept { return true; } void await_suspend(coroutine_handle<>) const noexcept {} void await_resume() const noexcept {} };std::suspend_never虽然名字带有 suspend,其实它await_ready()返回true,永远不挂起。把它用作initial_suspend,协程创建后就会直接开始执行函数体;而用std::suspend_always,协程创建后需要先执行一次resume()才会进入函数体。
3.2 await_suspend 三种返回值分别代表什么
这是co_await机制里最值得展开的细节,因为await_suspend的返回类型会直接改变执行流语义。
第一种,返回void:
void await_suspend(coroutine_handle<> h) noexcept { // 当前协程会被挂起,控制权立即回到调用 resume()/启动协程 的代码 }这是最常规的写法。比如把自己重新塞回事件循环队列里,事件循环下次从队列取出 handle 再resume()时,协程恢复执行。
第二种,返回bool:
bool await_suspend(coroutine_handle<> h) noexcept { if (可以直接继续) { return false; // 相当于不挂起,立即继续执行 } // 否则返回 true,真正挂起 return true; }返回false时,协程不会真正挂起,co_await 后面的代码会直接继续执行。这个设计常用于“结果已就绪但还在 awaiter 里”的优化分支。
第三种,返回coroutine_handle<>:
coroutine_handle<> await_suspend(coroutine_handle<> current) noexcept { return other_coroutine; }这是最微妙的一种。它表示:当前协程挂起,但控制权不回到原来的resume()调用点,而是直接切换到返回的另一个协程上。这就是所谓的对称转移(symmetric transfer)。
3.3 对称转移:避免递归爆栈的关键
为什么需要对称转移?考虑一下:如果你在协程 A 里resume()协程 B,B 里又resume()协程 C,当 C 挂起时,控制权会一层层回到 B 的resume()调用点,再回到 A 的resume()调用点,最后回到最初启动处。链条越长,栈上停留的调用层数就越深。
如果协程链有几千个节点,这种嵌套 resume 很可能把栈打爆。
对称转移的解决办法是:在await_suspend返回coroutine_handle<>时,编译器生成的是tail call,当前协程的栈帧被替换为目标协程的栈帧。这样无论链多长,实际的调用栈深度都不会持续增长。
我最早看这个设计时觉得像魔术,后来亲手写了一个生成器才理解:这其实就是把“协程切换”下沉到了语言层面,避免了在用户代码里手动做“返回到顶层再恢复下一个协程”的繁琐逻辑。
一个极简可用的对称转移 awaiter 可以这么写:
struct handoff { coroutine_handle<> next; bool await_ready() noexcept { return false; } coroutine_handle<> await_suspend(coroutine_handle<>) noexcept { return next; } void await_resume() noexcept {} };当当前协程执行到这个 awaiter 时,控制权不会回到resume()调用点,而是直接跳到next对应的协程继续执行。
3.4 co_yield 其实是 co_await 的语法糖
co_yield expr等价于:
co_await promise.yield_value(expr);所以co_yield并不是另一套机制,它只是把co_await的操作对象变成了 promise 提供的yield_value对象。
写一个生成器通常要用到这一点。比如一个每次生成下一个斐波那契数的协程:
template <typename T> struct Generator { struct promise_type { T current; Generator get_return_object() noexcept { return Generator{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } std::suspend_always yield_value(T value) noexcept { current = value; return {}; } void return_void() noexcept {} void unhandled_exception() noexcept { std::terminate(); } }; using Handle = std::coroutine_handle<promise_type>; Handle handle; Generator(Handle h) noexcept : handle(h) {} Generator(const Generator&) = delete; Generator(Generator&& other) noexcept : handle(other.handle) { other.handle = nullptr; } ~Generator() { if (handle) handle.destroy(); } bool next() { if (!handle || handle.done()) return false; handle.resume(); return !handle.done(); } T current() const { return handle.promise().current; } }; Generator<int> fib() { int a = 0, b = 1; while (true) { co_yield a; int tmp = a; a = b; b = tmp + b; } }这里co_yield a展开后,等价于调用co_await promise.yield_value(a),yield_value返回suspend_always,所以每次yield都导致协程挂起。主循环里next()每调用一次,就生成一个数。生成器内部的状态(a、b)全保存在协程帧里,不需要额外维护一个迭代器结构。
4. 手写最小执行器:把原理真正跑起来
4.1 定义一个可复用的 Task 类型
理解了co_await的底层逻辑后,自己封装一个最简协程类型就比较顺了。我一般这样写:
#include <coroutine> #include <cstdio> #include <deque> #include <exception> struct Task { struct promise_type { Task get_return_object() noexcept { return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() noexcept {} void unhandled_exception() noexcept { std::terminate(); } }; using Handle = std::coroutine_handle<promise_type>; Handle handle; Task(Handle h) noexcept : handle(h) {} Task(Task&& other) noexcept : handle(other.handle) { other.handle = nullptr; } Task(const Task&) = delete; ~Task() { if (handle) handle.destroy(); } void resume() { if (handle && !handle.done()) handle.resume(); } };这里有几个关键点:
initial_suspend返回suspend_always,协程创建后不会立刻执行,方便交给调度器决定何时启动。final_suspend也返回suspend_always,协程结束后停在最终挂起点,由 Task 析构统一调用destroy(),避免帧泄漏。- Task 只允许移动,不允许拷贝,防止同一个句柄被多处持有造成重复释放。
- 析构函数里先判空再
destroy(),move 后源对象把 handle 置空,不会二次释放。
4.2 单线程事件循环与调度器
co_await本身没有调度能力,只是挂起和恢复。真正驱动协程执行的,还是外部的事件循环。我做一个最简单的单线程 ready 队列调度器:
struct Executor { std::deque<std::coroutine_handle<>> ready; void schedule(std::coroutine_handle<> h) { ready.push_back(h); } void run() { while (!ready.empty()) { auto h = ready.front(); ready.pop_front(); h.resume(); } } }; struct yield_to_executor { Executor& ex; bool await_ready() const noexcept { return false; } void await_suspend(std::coroutine_handle<> h) const noexcept { ex.schedule(h); } void await_resume() const noexcept {} };yield_to_executor的await_ready()返回false,强制挂起;await_suspend把当前协程 handle 塞回 ready 队列尾部。也就是说,当前协程每次遇到co_await yield_to_executor{ex},都会主动让出执行权,排到队列末尾等待下一轮resume()。
这个结构就是很多轻量协程调度器的原型:挂起的是任务,循环的是调度器,协程恢复完全由事件驱动。
4.3 运行流程逐步拆解
写一个 demo 协程,然后看主函数怎么驱动它:
Task demo(Executor& ex) { std::puts("1: first section"); co_await yield_to_executor{ex}; std::puts("2: second section"); co_await yield_to_executor{ex}; std::puts("3: third section"); } int main() { Executor ex; Task t = demo(ex); ex.schedule(t.handle); ex.run(); return 0; }运行输出:
1: first section 2: second section 3: third section完整流程长这样:
demo(ex)被调用时,编译器分配协程帧,构造 promise,执行initial_suspend挂起,函数体一行都没跑,返回 Task 对象,里面包着协程句柄。- 主函数把
t.handle投入 ready 队列。 ex.run()进入循环,从队列取出 handle,调用resume(),协程第一次真正执行。打印第一段后遇到co_await yield_to_executor{ex},awaiter 挂起并把句柄放回队列,resume()返回。- 调度器继续从队列取出同样的 handle(已经排到队尾),第二次
resume(),从上次挂起点继续,打印第二段,再次入队。 - 第三次
resume()打印第三段,随后co_return,进入final_suspend挂起点并保持挂起。此时handle.done()返回 true。 ex.run()队列空了,循环结束。Task 析构调用destroy(),协程帧释放。
这个流程里最关键的一环是:co_await之后的代码并不是立刻执行,而是“等下一次 resume 再从头调度回来”。所有局部变量因为存储在协程帧里,中间不管隔了多久,状态依然完整。
我实测下来的结论是:这种写法比手动把resume嵌套来嵌套去要安全得多,也更容易设计取消、超时、优先级等机制。要加优先级,只需把单队列换成优先队列;要支持定时唤醒,就再维护一个按时间排序的 sleep 队列,到点后再把 handle 转移进 ready 队列。
5. 常见问题与排查技巧实录
5.1 协程创建后为什么不执行
这是最经典的新手问题:我调用了一个返回Task的协程函数,怎么打印一句都没有?
原因就是第 2 节说的:协程函数体不会立即执行。调用协程函数时,编译器只是创建协程帧,执行initial_suspend()。如果initial_suspend()返回suspend_always,函数体就停在最开始的挂起点,必须外部resume()一次才会真正进入函数体。
排查思路:
- 检查
initial_suspend()返回的是suspend_always还是suspend_never。 - 检查是否有外部代码对协程句柄调用过
resume()。 - 检查句柄是否被正确投入调度队列。
我的默认实践是:库代码统一用suspend_always,交给调度器去预热;业务代码如果想“调用即执行”,就在构造函数里立刻resume(),但要保证帧内有完整的资源管理。
5.2 协程帧泄漏和悬垂引用
协程帧泄漏最常见的场景是final_suspend返回suspend_always,但协程结束之后没人调用handle.destroy()。
我一开始写Task时偷懒,把final_suspend改成suspend_never,觉得协程自己销毁帧就不用管了。但后面用handle.done()检查状态时发现会有问题——协程已经结束,却可能拿不到最终结果,而且某些实现中需要promise里存放的结果在帧释放后还能被读取,suspend_never会让帧很快销毁,调用方再读结果就是未定义行为。
解决方法是把生命周期收进 RAII 类里。上面的Task析构函数统一调用destroy(),配合 move 语义置空句柄,就杜绝了大多数泄漏。
另一个容易踩的坑是悬垂引用。假设你在await_suspend里捕获了一个对象的指针:
struct bad_awaiter { SomeObject* obj; void await_suspend(std::coroutine_handle<> h) { obj->on_ready([h] { h.resume(); }); } };如果obj在协程恢复前析构了,回调触发时会访问已释放内存。排查时重点看await_suspend中捕获的生命周期,确保恢复前所有资源都有效。
5.3 编译环境与工具链选择
C++20 协程支持需要较新的编译器版本:
- GCC 11+,需要
-std=c++20 - Clang 14+,需要
-std=c++20 -fcoroutines-ts(旧版本需要单独库) - MSVC VS2022 16.10+,项目设置中把 C++ 语言标准改为
/std:c++20
如果你在 Windows 上用 Visual Studio 但没装 VS2022,或者装的 Build Tools 版本太旧,经常会碰到error: microsoft visual c++ 14.0 or greater is required这类提示。解决方法一般是安装匹配版本的 Visual C++ Build Tools 或 Visual Studio 组件,同时确认编译器确实支持/std:c++20标准选项。这里的报错和协程代码本身没关系,通常是环境里缺了编译工具链或运行时组件。
如果在 VSCode 里配环境,需要注意 tasks.json 和 c_cpp_properties.json 两处保持一致:编译器路径指向带 C++20 支持的版本,cppStandard设为c++20。否则编辑器提示和实际编译结果可能不一致,排查起来很迷惑。
注意:不要拿远古的 Visual C++ 6.0 或者太老的 MinGW 来编译协程代码,标准库
<coroutine>头文件很可能都不存在。
5.4 和 Python 协程的一点小对比
热词里有不少人在搜“python协程”,这里用 C++ 和 Python 对一下,能加深理解。
Pyhton 的async/await基于内建的 asyncio 事件循环,协程对象创建后由事件循环调度,生命周期由框架管理。你很少需要关心“协程帧什么时候释放”,也不会有悬垂 resumption 这类问题。
C++20 协程更底层:
- Python 的
await后面跟的可等待对象协议简单,C++ 则要自己实现await_ready、await_suspend、await_resume。 - Python 自带
asyncio.sleep等调度原语,C++ 里没有内置调度器,要么手写,要么接 cppcoro、asio 这些库。 - Python 协程对象生命周期由 GC 管理,C++ 协程帧则必须显式释放,设计不好就泄漏。
- C++ 的优势是零开销抽象,平台相关异步事件可以直接嵌入自定义 awaiter,性能天花板明显更高。
所以如果你用 C++ 写协程,别拿 Python 那套思维来套。C++ 给你的是语言层机制,调度器、生命周期、取消、错误处理都要自己建模,这也是本文想重点强调的部分。
6. 最后补几点实操体会
我踩过几次协程帧泄漏和 resume 调用点混乱的坑之后,现在的默认写法基本稳定了:start 用suspend_always,结束用suspend_always,句柄必须包进 RAII 对象里,移动语义记好置空源对象。await_suspend里填的是回调注册逻辑时,始终问自己一句:回调触发后,当前协程帧和它引用的资源还活着吗?
对称转移那部分建议自己写一遍生成器链,把几个协程通过coroutine_handle<>串起来跑一遍,观察控制权跳转顺序,比单纯看文档有效得多。等你能把co_await的展开过程手动画出来,C++20 协程体系的骨架基本就印在脑子里了。