news 2026/9/16 1:43:23

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++20协程co_await底层机制拆解:挂起、恢复与对称转移

聊到C++20协程,绕不开的关键字就是co_await。我最早读规范的时候,被promise_typecoroutine_handleawaiter这些概念来回绕晕过,单独看每个都懂,放一起就想摔键盘。这篇博文我换个思路,直接站到编译器和运行时两个视角,把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()
  • awaiterco_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在语义上并不是简单调用某个函数。标准定义了一套查找规则:

  1. 如果expr类型有operator co_await(),则调用它,得到awaiter
  2. 否则expr本身必须是awaiter
  3. 得到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_executorawait_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

完整流程长这样:

  1. demo(ex)被调用时,编译器分配协程帧,构造 promise,执行initial_suspend挂起,函数体一行都没跑,返回 Task 对象,里面包着协程句柄。
  2. 主函数把t.handle投入 ready 队列。
  3. ex.run()进入循环,从队列取出 handle,调用resume(),协程第一次真正执行。打印第一段后遇到co_await yield_to_executor{ex},awaiter 挂起并把句柄放回队列,resume()返回。
  4. 调度器继续从队列取出同样的 handle(已经排到队尾),第二次resume(),从上次挂起点继续,打印第二段,再次入队。
  5. 第三次resume()打印第三段,随后co_return,进入final_suspend挂起点并保持挂起。此时handle.done()返回 true。
  6. 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_readyawait_suspendawait_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 协程体系的骨架基本就印在脑子里了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 1:40:02

STM32注射泵电机控制:编码器反馈与PID闭环调参实战

简介&#xff1a;一套面向STM32嵌入式开发与医疗设备控制场景的智能注射控速系统资料&#xff0c;围绕STM32监控、电机控制、脉搏检测与人机交互展开&#xff0c;适合有C语言和单片机基础的学习者用于项目参考或毕业设计。资源共225个文件&#xff0c;以C源码、H头文件、Keil工…

作者头像 李华
网站建设 2026/9/16 1:39:58

CloudCompare点云处理全流程:从配准到三维模型重建

干点云这行的人&#xff0c;电脑里一定躺着一两个离不开的工具。PCL强在算法库&#xff0c;但光编译就够你折腾一晚上&#xff1b;MeshLab上手快&#xff0c;可面对几个G的激光点云就开始吃力&#xff1b;商业软件功能确实全&#xff0c;授权费却不是个人玩家或者小团队愿意随便…

作者头像 李华
网站建设 2026/9/16 1:38:47

DIODE数据集全解析:室内外稠密深度与法线标注

做单目深度估计和表面法线估计的&#xff0c;应该都遇到过一个问题&#xff1a;公开数据集要么只给深度&#xff0c;要么只在室内&#xff0c;要么标注稀疏到没法用。今天要聊的DIODE&#xff0c;是一个“全新RGB-D及平面法线数据集”&#xff0c;把室内、室外、稠密深度、平面…

作者头像 李华
网站建设 2026/9/16 1:38:28

Burp Suite实战:抓包、改包、重放、爆破四大核心模块详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 1:38:24

CentOS停服后迁移实战:Rocky Linux入门到运维高手

CentOS 7正式停服那天&#xff0c;我服务器上还有几十台生产机器没迁移完。算下来从接到通知到全部切换&#xff0c;前后折腾了三个多月&#xff0c;踩过的坑比走过的路还多。这期间我最终选定的替代方案就是Rocky Linux&#xff0c;也是今天这篇内容的主角。这篇内容不是那种泛…

作者头像 李华