C++20协程co_await底层机制拆解:挂起、恢复与对称转移

发布时间:2026/9/16 1:43:26
C++20协程co_await底层机制拆解:挂起、恢复与对称转移
聊到C20协程绕不开的关键字就是co_await。我最早读规范的时候被promise_type、coroutine_handle、awaiter这些概念来回绕晕过单独看每个都懂放一起就想摔键盘。这篇博文我换个思路直接站到编译器和运行时两个视角把co_await背后那套隐藏机制完整扒一遍重点讲清楚它到底帮你生成了什么代码、挂起点是怎么保存的、恢复执行又是怎么跳回去的。适合已经能写模板和现代C、但看到协程源码就头大的同学也适合打算自己实现一个最小协程执行器的实践党。先说结论co_await不是普通表达式它是C里少见的、能够直接改变函数控制流的语言设施。一套完整理解下来你对异步框架、线程调度、生成器实现的理解都会通一层后面去读 cppcoro、asio 那些协程代码也不会再觉得是天书。1. 先搞清楚C20协程是为了解决什么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 里错误处理路径也和主路径交错在一起。想了解这段代码什么时候执行完只能靠回调触发的时机去推断想中途取消又是另一套状态管理。更麻烦的是逻辑一旦需要根据中间结果做分支回调写法基本就是手写状态机每一层分支都要小心保存变量。C20 协程的出现就是想让异步代码写起来像同步代码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 C20协程的三个核心抽象要理解co_await先得记住三样东西promise_type协程与调用者之间的协议对象。它负责协程生命周期管理、结果存取、初始挂起/最终挂起的行为控制。coroutine_handle协程帧的轻量句柄不拥有内存但可以显式resume()或destroy()。awaiterco_await操作的对象定义了“挂不挂”“挂起后干什么”“恢复后返回值是什么”。一句话概括三者的关系编译器生成协程帧帧里放着promise_type调用者通过coroutine_handle操作它co_await的具体行为由awaiter决定。1.3 协程和线程不是一回事很多新手把 C20 协程和线程混在一起。其实协程是控制流结构线程是系统调度实体。线程有自己的内核栈和调度器协程没有自己的栈它是把当前函数的栈帧状态保存到堆上的协程帧里切换成本远低于线程切换。可以把线程想象成多条独立电话线每条都能并行通话协程则是同一条电话线上挂起多个通话A 说一半放下听筒B 接起来说几句再换回 A。同一时刻只有一个协程在某个线程上实际运行但任务切换非常快而且不涉及内核态切换。这个特性决定了协程特别适合 I/O 密集型场景读文件、写 socket、等待网络响应的时候协程挂起后线程立刻去调度其他协程CPU 利用率能拉满。2. 编译器把你的函数改成了什么协程帧与状态机2.1 协程帧coroutine frame为什么在堆上普通函数执行完后栈上的局部变量随栈帧一起销毁。但协程涉及挂起和恢复——挂起时希望保留局部变量等恢复后继续用恢复时栈帧却可能已经完全退出了。所以C标准规定协程的局部变量和上下文保存在协程帧coroutine frame里这个帧通常分配在堆上。协程帧里至少包含这些内容协程参数按值拷入避免引用失效当前的局部变量当前执行到的状态挂起点编号promise_type对象当前挂起的awaiter某些实现会临时构造编译器在调用协程函数时会先分配这个帧。一步到位解释就是你的协程函数调用点其实只负责创建一个帧函数体未必立刻执行。标准没有把帧分配方式写死。如果编译器能分析出 handle 不会逃逸出当前作用域有可能会优化成栈上分配这被称为 HALOHeap Allocation elision OptimizationMSVC 在这方面做得比较激进。但作为使用者我们仍应默认它可能分配堆内存并以此设计生命周期管理。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()变成了责任一旦忘记就泄漏一个堆帧。提示协程帧生命周期管理是 C20 协程和大多数语言协程最不一样的地方。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表达式的结果标准库提供了两个现成的 awaiterstruct 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的返回类型会直接改变执行流语义。第一种返回voidvoid await_suspend(coroutine_handle h) noexcept { // 当前协程会被挂起控制权立即回到调用 resume()/启动协程 的代码 }这是最常规的写法。比如把自己重新塞回事件循环队列里事件循环下次从队列取出 handle 再resume()时协程恢复执行。第二种返回boolbool await_suspend(coroutine_handle h) noexcept { if (可以直接继续) { return false; // 相当于不挂起立即继续执行 } // 否则返回 true真正挂起 return true; }返回false时协程不会真正挂起co_await 后面的代码会直接继续执行。这个设计常用于“结果已就绪但还在 awaiter 里”的优化分支。第三种返回coroutine_handlecoroutine_handle await_suspend(coroutine_handle current) noexcept { return other_coroutine; }这是最微妙的一种。它表示当前协程挂起但控制权不回到原来的resume()调用点而是直接切换到返回的另一个协程上。这就是所谓的对称转移symmetric transfer。3.3 对称转移避免递归爆栈的关键为什么需要对称转移考虑一下如果你在协程 A 里resume()协程 BB 里又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_handlepromise_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_handlepromise_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; } }; Generatorint 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_handlepromise_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_handlepromise_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::dequestd::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 编译环境与工具链选择C20 协程支持需要较新的编译器版本GCC 11需要-stdc20Clang 14需要-stdc20 -fcoroutines-ts旧版本需要单独库MSVC VS2022 16.10项目设置中把 C 语言标准改为/std:c20如果你在 Windows 上用 Visual Studio 但没装 VS2022或者装的 Build Tools 版本太旧经常会碰到error: microsoft visual c 14.0 or greater is required这类提示。解决方法一般是安装匹配版本的 Visual C Build Tools 或 Visual Studio 组件同时确认编译器确实支持/std:c20标准选项。这里的报错和协程代码本身没关系通常是环境里缺了编译工具链或运行时组件。如果在 VSCode 里配环境需要注意 tasks.json 和 c_cpp_properties.json 两处保持一致编译器路径指向带 C20 支持的版本cppStandard设为c20。否则编辑器提示和实际编译结果可能不一致排查起来很迷惑。注意不要拿远古的 Visual C 6.0 或者太老的 MinGW 来编译协程代码标准库coroutine头文件很可能都不存在。5.4 和 Python 协程的一点小对比热词里有不少人在搜“python协程”这里用 C 和 Python 对一下能加深理解。Pyhton 的async/await基于内建的 asyncio 事件循环协程对象创建后由事件循环调度生命周期由框架管理。你很少需要关心“协程帧什么时候释放”也不会有悬垂 resumption 这类问题。C20 协程更底层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的展开过程手动画出来C20 协程体系的骨架基本就印在脑子里了。