1. 项目动机:从一坨回调地狱说起
大概半年前,我接手了一个内部工具服务的维护工作。这个服务本身不复杂,核心职责就是转发请求、聚合结果、做一点超时控制。但代码读起来非常痛苦:三层嵌套的异步回调、到处散落的状态标志位、手动维护的请求生命周期。改一个超时逻辑,可能牵连到七八个分支。最要命的是排查问题——线上日志打出来后,你根本不知道当前这条日志是从哪个回调上下文里打出来的,函数调用栈是断的,上下文全靠脑补。
后来我花了两周时间,用C++20协程把这条异步链路重写了一遍。重写完之后,代码行数从八百多行降到三百多行不说,最关键的是日志变得“可读了”:trivial的读取日志、超时日志、异常日志全部落在同一个函数体里,像同步代码一样直观。这篇文章就是想通过我记录的实战日志,把C++20协程这套东西讲清楚——它到底解决了什么问题、核心机制到底是什么、你在自己项目里落地的时候会遇到哪些坑。
C++20协程不是一个黑魔法,它本质上是一套编译器帮你生成的有限状态机。你写出来的协程函数,会被编译器改造成一个状态对象,每次挂起点就是一个状态编号,恢复执行就是跳到对应的状态继续跑。搞懂了这个模型,你才能真正用好它,而不是停留在“会用co_await”的层面。
这篇文章适合三类人看:被异步回调折磨到想换语言的C++开发者、已经在用协程但说不清原理的“API使用者”、以及正在评估要不要把项目切到C++20协程的架构决策者。
2. 协程机制拆解:编译器在背后搞了什么鬼
2.1 三个关键对象:promise_type、awaiter、coroutine_handle
很多人刚看C++20协程规范的时候,会被promise_type、awaiter、coroutine_handle这几个名词绕晕。我当初也是。后来我自己的理解是,把协程想象成一个“可以暂停的任务”,那这三个东西各管一摊:
promise_type负责协程的“生命周期会议”:协程开始时干什么、返回什么给外部、协程结束前干什么。你可以把它理解成协程和外部世界之间唯一合法的“交接员”。awaiter负责回答“要不要停”和“停了以后怎么恢复”:await_ready()返回true就继续跑,返回false就挂起;await_suspend()决定挂起后的动作,await_resume()决定恢复后co_await表达式的结果。coroutine_handle就是协程的“遥控器”,外部代码可以通过它请求恢复协程、查询状态、销毁协程。它是连接协程内部世界和外部调度器的唯一桥梁。
我用一个生活化的类比帮团队新人理解:协程函数是一个跑长途的司机,promise_type是司机所在车队的调度台,负责登记出车和收车;awaiter是路上的每个服务区,司机到了服务区决定要不要休息一下;coroutine_handle则是调度台手里那部对讲机,能直接喊“司机,继续跑”。
#include <coroutine> #include <iostream> #include <optional> struct Task { struct promise_type { std::optional<int> result; Task get_return_object() { return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_never initial_suspend() { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_value(int v) { result = v; } void unhandled_exception() { std::terminate(); } }; std::coroutine_handle<promise_type> h; explicit Task(std::coroutine_handle<promise_type> h) : h(h) {} ~Task() { if (h) h.destroy(); } }; Task demo() { co_return 42; }注意上面这段代码里,initial_suspend()返回std::suspend_never,意思是协程函数体一开始就立刻执行,不停顿。这个看起来不重要,但实际影响很大:它决定了协程是“懒执行”还是“饿执行”。绝大多数业务场景希望协程函数体先跑起来,到第一个真正的耗时操作再挂起,所以用suspend_never是合理的默认选择。反过来,如果你实现的是惰性计算或者任务编排系统,可能希望initial_suspend()返回std::suspend_always,让协程创建之后不立即执行,等调度器手动恢复。
2.2 co_await 到底做了什么事
co_await是协程里最重要的操作符,没有之一。它做的事说白了就是:检查可不可以继续,不能继续就打包好当前状态、跳出去,等条件满足后跳回来接着跑。
有一件事很多人一开始没意识到:co_await 表达式里的表达式,会被编译器自动转换成对operator co_await的调用,或者直接当awaiter对象处理。如果表达式既不是awaiter又没有定义operator co_await,会尝试调用 promise_type 里的await_transform()。这个机制导致一个实战中的常见现象:你写co_await async_read(...),实际上 async_read 返回的东西是awaiter对象,整数、普通对象是不能直接co_await的。
我们用最常用的std::suspend_always来理解awaiter的协议:
struct suspend_always { bool await_ready() const noexcept { return false; } void await_suspend(std::coroutine_handle<>) const noexcept {} void await_resume() const noexcept {} };await_ready()返回false,告诉编译器“现在没法继续,给我挂起”。await_suspend()被调用,协程此刻已经挂起,这里可以干两件事:要么把coroutine_handle存到某个线程池队列里,要么直接用handle.resume()同步恢复。await_resume()在协程恢复后执行,返回的表达式值就是整个co_await表达式的值。
关于await_suspend的返回值,标准里有三档:返回void,就是无条件挂起;返回bool,true挂起false不挂起;返回另一个coroutine_handle,会立即切换到那个协程。第三档最常见的用法是实现对称转移(symmetric transfer),在不消耗额外栈空间的情况下完成协程间切换,异步框架里很多都是这么干的。
2.3 状态机的视角:协程就是一串编号的代码段
我调试协程代码时有个习惯,就是把协程函数想象成编译器生成的那些switch-case状态。每一个co_await都是一个分割点,编译器在背后维护一个状态机:
- 协程启动,进入状态0。
- 执行到第一个
co_await,如果条件不满足,保存局部变量、保存恢复点,函数返回一个“挂起”信号给调用方。 - 外部调度器拿到
coroutine_handle,在合适的时机调用resume()。 - 恢复时从状态1继续,读回之前保存的局部变量。
这个视角特别有用,因为它解释了为什么协程局部变量的生命周期跟普通函数不一样:普通函数的局部变量就是栈上的,函数一退出就失效;协程挂起的时候,局部变量全部在堆上的协程帧(coroutine frame)里。协程帧是operator new分配的(除非做了分配器定制),所以协程对象通常不能像普通值一样拷贝,只能移动或者管理指针。
我踩过的最典型的一个坑就是在协程函数里存了引用类型的局部变量,然后协程挂起之后外部容器重新分配了内存,导致引用失效,程序偶发崩溃。这个问题的本质就是:协程帧在堆上,生命周期不跟函数栈绑定,所以引用安全性只能靠你自己保证。C++20协程没有借用检查器,这一点跟Rust异步不同,必须谨记。
3. 实战日志解读:一个异步任务调度系统是怎么跑起来的
3.1 场景设定:并发拉取配置并聚合结果
为了让讨论不悬空,我设定一个具体的场景:服务启动时需要从三个不同配置中心拉取配置,每个配置中心响应时间在50ms到500ms之间波动,要求全部拿到之后合并成一份配置,整体耗时上限1秒。用传统同步代码就是串行,总耗时为三者之和;用回调写并行虽然不难,但超时处理和错误聚合会很别扭。我最后选择用C++20协程实现并行拉取,再用一个简单调度器统一管理协程的恢复时机。
系统结构大致是这样:
- 三个
fetch_config协程,各自负责一个配置中心的HTTP请求。 - 每个协程内部封装真实异步IO,返回一个awaiter。
- 一个
when_all辅助函数,负责等三个协程全部完成。 - 一个单线程事件循环,负责在IO就绪时调用对应协程的
resume()。
我把这个系统的名字记作“模拟项目X”,代码不复杂,但日志逻辑非常考验人。下面是我从真实运行日志中摘出来的关键片段。
3.2 日志片段:为什么协程恢复顺序不等于创建顺序
[10:00:00.001] [scheduler] coro_a created [10:00:00.001] [scheduler] coro_b created [10:00:00.001] [scheduler] coro_c created [10:00:00.002] [scheduler] coro_a suspended at fetch_config [10:00:00.003] [scheduler] coro_c suspended at fetch_config [10:00:00.051] [scheduler] io_ready coro_a [10:00:00.051] [scheduler] coro_a resumed [10:00:00.052] [scheduler] coro_a finished, emit result_a [10:00:00.189] [scheduler] io_ready coro_c [10:00:00.189] [scheduler] coro_c resumed [10:00:00.190] [scheduler] coro_c finished, emit result_c [10:00:00.502] [scheduler] io_ready coro_b [10:00:00.502] [scheduler] coro_b resumed [10:00:00.503] [scheduler] coro_b finished, emit result_b [10:00:00.503] [scheduler] when_all complete, start merge注意coro_b最先被创建,但它挂起得最晚,恢复得也最晚。这个现象很正常:协程创建不等于协程执行,更不等于IO请求发出。coro_a创建后立即执行到fetch_config,进入挂起状态,这次挂起的时候才真正发起了HTTP请求;coro_b因为某些原因(比如DNS解析慢了一点)比coro_a晚发起IO。所以日志里“创建顺序”只是对象构造顺序,跟“挂起顺序”“完成顺序”完全是三回事。
从排查问题的角度看,这种日志如果只打“创建顺序”和“完成顺序”,中间没打“挂起顺序”,会非常难查:你看到c先创建却最后完成,第一反应是代码写错了,其实只是IO发起得晚。我的经验是,在awaiter的await_suspend里打日志,记录真正发起IO的时刻,这是排查此类并发问题的关键。
3.3 when_all 聚合逻辑:第一个完成的不能急着返回
when_all的实现看起来简单,但细节很多。我踩过的坑主要有三个:
第一,必须等待所有协程进入挂起状态之后,聚合器才能安全地持有它们的coroutine_handle。因为如果第一个协程同步完成了,没有挂起,那它可能已经被析构了,你手里的handle就是悬空的。解决办法是让协程一开始就挂起(initial_suspend返回suspend_always),或者用计数原子变量保证安全。
第二,必须在最后一个协程完成时触发外层协程的恢复。不能用计数减到零就急着恢复,要考虑到异常传导。如果某个协程抛异常而你没接住,整个聚合可能卡死或者直接终止。
第三,final_suspend()必须返回suspend_always,否则协程在co_return之后会自动销毁协程帧,外部还想通过handle拿结果就是访问已释放的内存。这是一个新手最容易踩的坑。
我当时实现的when_all核心逻辑简化后长这样:
template<typename... Tasks> struct WhenAllAwaiter { std::tuple<Tasks...> tasks; std::coroutine_handle<> continuation; std::atomic<int> remaining; std::exception_ptr eptr; bool await_ready() const noexcept { return false; } void await_suspend(std::coroutine_handle<> outer) { continuation = outer; remaining = sizeof...(Tasks); // 依次启动子协程 std::apply([&](auto&... t) { ([](auto& task, WhenAllAwaiter* self) { task.start(self->on_child_done); }(t, this), ...); }, tasks); } void on_child_done() { if (--remaining == 0) { if (eptr) std::rethrow_exception(eptr); // 简化示意 continuation.resume(); } } void await_resume() {} };这段代码有一个关键细节:remaining必须是原子变量,因为子协程可能跑在不同线程上。虽然我这个系统最终是单线程事件循环,但协程库在泛化使用时,没人能保证谁在哪个线程跑。另外on_child_done里的--remaining == 0要注意内存序问题,最稳妥的是用std::memory_order_acq_rel,计数归零才需要同步恢复操作。
从日志上看,when_all complete这条日志打印的时机是remaining归零时,正好对应最后一个协程完成。这里有个跨线程坑:如果子协程在另一个线程完成,remaining归零后另一个线程继续执行continuation.resume(),当前线程也在继续往下走,就可能出现两段协程代码并发执行的情况。单线程事件循环掩盖了这个问题,但在通用线程池调度器里,这就是数据竞争的温床。我给的建议是:生产级when_all应当把恢复动作调度回统一的执行器里,而不是直接在子协程完成处恢复。
3.4 超时处理:日志里那串诡异的 timestamp
下面这段日志是加了超时控制之后跑出来的:
[10:00:05.001] [coro_a] fetch_config wait 300ms timeout [10:00:05.301] [coro_a] timeout fired, cancelling request [10:00:05.302] [coro_a] cancelled, resume with timeout_error [10:00:05.302] [scheduler] exception captured, when_all abort这里有个微妙点:co_await超时器返回之后,协程并没有立即恢复,而是先走一遍取消流程(cancel request),然后才通过异常把控制权交回聚合器。为什么不能直接抛超时异常然后不取消请求?因为底层HTTP请求还在占用连接资源,如果不主动取消,后续复用连接池的时候会出脏数据甚至崩溃。这就是协程化异步系统里最常见的资源泄漏问题——只处理了协程控制流,没处理底层资源的生命周期。
超时实现本身我推荐用“时间轮 + 回调”而不是每个协程开一个线程。之前有个版本用std::async跑定时器,短时间创建大量协程时线程数直接飙升,服务所在环境直接报警。换成时间轮之后,所有超时事件挂在同一个循环上,O(1) 复杂度处理到期事件,线程开销零增长。具体实现不展开,核心思路是:每个协程在挂起时注册一个超时回调到时间轮,事件循环每次迭代检查头部节点是否到期,到期则触发协程恢复并传入超时标记。
4. 实操落地:从零实现一个最小可用的协程调度器
4.1 调度器的选型思路:要不要自己造轮子
聊协程就绕不开一个问题:直接用现成库还是自己写调度器?我当时的判断是:如果项目允许依赖第三方库,优先考虑成熟的协程库,比如带协程支持的异步IO库、或者支持协程的网络库;如果项目对依赖管理很敏感,或者你想深入理解机制,自己写一个最小调度器是非常好的学习路径,也是验证协程理解的好方法。
这篇文章里我分享自己写的最小调度器,主要是为了让读者看清协程和事件循环怎么衔接。它不是完整的工业级方案,但足够支撑中小型工具服务的异步场景。生产环境中我的建议是:要么严格评估第三方库的线程模型和异常安全性,要么在自研调度器上做好并发原语的封装,别直接裸用协程API到处撒。
4.2 最小调度器架构:单线程事件循环 + 就绪队列
我的最小调度器由三部分组成:
- 事件循环:一个线程跑
while (!stop) { poll(ivents); run_ready(); run_timers(); } - 就绪队列:存放待恢复的
coroutine_handle,用FIFO保证公平性。 - 定时器:用优先队列实现的最小堆,键为唤醒时间。
对应到代码层面,调度器暴露几个核心接口:spawn(创建协程)、resume_later(挂起后登记恢复)、sleep_for(协程睡眠)、when_all(聚合等待)。
事件循环里最关键的一个经验是:resume()可能触发协程继续执行,而协程执行过程中又可能往就绪队列里push新的协程、或者触发了定时器的增删。如果你在遍历就绪队列的时候直接resume(),协程内部往同一个队列push数据,你的迭代器会失效。我当时的做法是用一个swap双缓冲:当前迭代的队列快照为一个std::vector,协程恢复期间新增的就绪项进入另一个std::vector,一轮结束后合并。
void Scheduler::run_ready() { std::vector<std::coroutine_handle<>> current; current.swap(ready_queue_); for (auto h : current) { if (h && !h.done()) { h.resume(); } } if (!ready_queue_.empty()) { run_ready(); } }这段代码有个细节:递归调用run_ready()而不是while循环,因为协程恢复可能新产生大量就绪项,双缓冲方案下递归处理更直观。当然递归深度在恶意场景下可能过深,不过就绪项数量一般受协程数量限制,实际压力不大。我在日志里统计过,单次事件循环迭代新增就绪项超过五层嵌套的情况极其罕见,所以递归可行。
4.3 实战:如何接入真实的异步IO
最小调度器只解决协程恢复时机问题,真正的IO还是得靠系统底层的非阻塞接口。服务里我封了一批awaiter对象,比如SocketReadAwaiter,它持有文件描述符、读缓冲区、注册回调。大致流程:
- 协程执行
co_await socket.read(buf, len)。 await_ready()尝试非阻塞读一次;如果数据已经就绪,直接读并返回true。- 如果EAGAIN,返回false进入挂起。
await_suspend(handle)里把handle连同fd一起注册到事件循环的IO监视器。- 事件循环检测到fd可读时,调用注册的回调,把对应协程的handle塞进就绪队列。
- 协程恢复后,
await_resume()再次读fd,此时数据必定就绪,或者处理错误情况。
这个流程看起来顺理成章,但有一个容易忽略的点:挂起前不是所有数据都读完了。假设用户请求读1024字节,但IO只到了512字节,如果你在await_ready()阶段就读到512并且决定返回true,那协程拿到的 buffer 就是半满的,调用者需要自己判断是否继续读。生产上我一般封装一个read_until或者read_full,把“一次读满”的需求做成awaiter内部的循环逻辑,避免调用方重复写半包处理。
日志统计显示,加上IO注册和IO回调和解析,协程版本的并发连接处理能力相比以前回调版本不但没下降,反而因为有明确的超时管理和资源回收,整体稳定性更高。最直观的对比是:回调版本有个老bug——如果某个请求在回调链中间失败,之前已经发出去的子请求没有统一取消,连接卡住;协程版本用异常传导和when_all传播取消,这种情况被系统性解决。
4.4 编译期陷阱:裸协程函数不能当普通函数调
落地过程中,团队遇到的第一道坎是编译错误:为什么我写了个协程函数,调用它的时候没执行函数体?原因很简单:协程函数不是普通函数,它的调用不会执行函数体,而是构造协程状态对象并返回。如果initial_suspend返回suspend_always,协程体压根不会执行,直到有人调用handle.resume()。
这个设计导致一个常见误用:用户期望foo()同步完成逻辑再返回,结果发现foo()返回得飞快,里面的打印也没出现。排查方式是在协程入口打印日志,确认执行时机。我见过不止一个项目在这里翻车,他们把协程函数跟普通函数混用,结果整个初始化顺序全变了。
诊断方式:看编译产物里有没有生成对应的 coroutine frame destroy/resume 函数,或者运行时在协程体第一行插入日志观察打印时机。这个特性不是缺陷,而是协程灵活性的代价。你需要一个明确的“入口约定”:所有协程都必须通过统一的spawn入口执行,不要裸调协程函数。
4.5 工具链要求与标准库头文件变化
C++20协程不是所有编译器都完整支持。我在项目里用的编译器是较新的GCC与Clang,MSVC那边相对滞后一点但也能用。标准方面,<coroutine>头文件引入之后,std::coroutine_handle才有标准定义。历史原因,早期实验版本用的是std::experimental::coroutine_handle,如果你的老代码里有#include <experimental/coroutine>,赶紧迁移。
另外一个隐性要求:协程帧的分配默认走operator new,如果应用是内存受限场景,可以通过重载promise_type::operator new或者提供分配器来自定义分配。实测下来,协程帧分配的取消优化(也就是编译器可以在不分配的情况下直接内联执行协程)只在某些情况下生效,不要依赖它,分配器定制更可控。
我记录过一个教训:在嵌入式交叉编译环境里,协程帧默认分配可能导致堆碎片增多,千次协程创建后内存碎片率上升明显。后来给promise_type定制了池化分配器,问题才解决。这类问题在日志上不会直接报错,只会表现为性能缓慢劣化,排查周期很长。
5. 常见问题与排查心得:从失败到稳定
5.1 协程悬空、重复恢复和状态泄漏
我在开发模拟项目X的过程中,处理了三个非常典型的问题,这里逐一分享排查思路。
悬空 handle:最典型的错误是把coroutine_handle存起来之后,协程已经结束并销毁,然后某个IO回调又resume()了这个handle。这是未定义行为,程序表现为偶发崩溃或死锁。排查技巧:在IO回调里持有handle的同时,检查handle.done(),虽然这个检查不是万能的,但能挡掉大部分已结束协程的误恢复。更可靠的方案是引入一个“令牌校验”:每次创建协程时生成唯一ID,handle存储时绑定ID,恢复时校验ID是否匹配。
重复恢复:有的调用方逻辑没想清楚,在IO回调和超时回调里都触发了resume(),导致一个协程被恢复两次。第二次恢复会把已经执行完的协程再跑一次,或者直接崩溃。解决方案是在协程状态对象里加一个bool resumed_标志,恢复前用原子交换置位,保证只恢复一次。
状态泄漏:协程创建后如果一直不恢复,或者恢复后抛异常没人处理,协程帧就永久驻留。这在日志上表现为协程计数只增不减。排查时我在每次创建和销毁时打日志,统计差值,很快就定位到某个分支没有走析构路径。
5.2 实战踩坑速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 协程没执行函数体 | initial_suspend返回suspend_always | 检查是否有人调用handle.resume() |
| 偶发崩溃指向协程行号 | 悬空 handle 或重复恢复 | 在恢复点打日志,记录 handle 地址和协程 ID |
| 协程永不完成 | 底层IO没有回调,或者回调没触发恢复 | 检查IO事件注册和取消逻辑 |
| 内存持续增长 | 协程帧未析构 | 统计创建/销毁次数,检查异常路径 |
| 跨线程数据错乱 | 子协程在不同线程恢复 | 确认执行器约束,统一调度线程 |
这张表我打印出来贴在工位上,后来带新人的时候也直接发给他们。经验就是:排查协程问题,先看日志确认“状态迁移”是否符合预期,再看数据竞争,不要一上来就怀疑编译器生成代码有问题。编译器生成的协程状态机大部分时候是对的,错的往往是你的生命周期管理。
5.3 关于调试器的几点实战建议
协程调试比普通函数难,因为调用栈在挂起点是断裂的。GDB对协程的支持在较新版本里能看到coroutine相关命令,但体验一般。我的经验是把日志打得更细,用“事件流”思维去追踪协程:每个协程的创建、挂起、恢复、完成都打一条唯一标识日志,然后把日志按时间排序就能还原整个生命周期。
日志格式建议统一为[时间] [协程ID] [阶段] 描述。协程ID我一般直接从handle.address()转成整数打印,虽然不够友好但足够唯一。如果你嫌地址不直观,可以在spawn入口维护一个自增计数器分配逻辑ID,日志里用逻辑ID追踪。
调试复杂异步逻辑时,还有一个“降级验证”技巧:把问题场景改成单线程同步执行,用相同的输入跑一遍,对比结果。如果同步执行正确但异步执行出错,那大概率是生命周期管理问题而非算法问题。这个技巧帮我在一个嵌套when_all的bug里节省了一整天时间。
5.4 性能测试结果与调优思路
重写完成后我做了一轮对比测试:同样一台机器上,模拟1000个并发请求,每个请求需要拉取3个子配置再聚合。对比旧回调整实现和新协程实现的内存占用、CPU占用和延迟分布。
结果是这样的:协程版本的内存峰值比回调版本低约18%,主要原因是协程帧大小远小于手动维护的回调上下文结构体;CPU占用基本持平;P99延迟协程版本低约20%,因为超时管理和错误路径更紧凑,丢失了大量“无谓的等待”。我强调这个数据不是要证明协程一定比回调快,而是要说明:协程的真正优势在于结构化的控制流,它省掉的是心智负担和维护成本,性能只是附属收益。
调优过程中一个真正有效的优化是:减少协程帧的堆分配。当协程函数在热路径上频繁创建时,分配器开销不可忽略。我给promise_type定制了operator new的本地缓存分配器,P99延迟直接降了8%左右。这种优化不改变逻辑,只改变内存分配路径,收益稳定可预期。
6. 写在最后:我建议你也动手写一个最小协程调度器
聊到这儿,C++20协程从“看得懂”到“用得上”之间最主要的差距就在于你是否亲手写过协程状态机。我强烈建议找个小项目练手,从最简单的单协程开始,到when_all,再到超时取消,逐步积累手感。
我个人实际操作中的体会是:第一次写协程框架代码时,你会反复被suspend_always和suspend_never的语义绕晕,但只要把日志打起来、把状态迁移图画出来,这些概念会很快内化。之后再去读开源协程库的源码,你会发现那些看起来高深的设计不过是这些基础机制的组合运用。
最后分享一个小技巧:新写的协程调度器在起步阶段,别急着上多线程,先用单线程事件循环把逻辑跑通,再考虑线程池。单线程环境下所有竞态问题都消失,协程恢复顺序变得可预测,排查逻辑bug事半功倍。等你确认状态机逻辑没问题,再引入多线程,这时剩下的问题基本都是并发同步问题,定位起来会清晰很多。这个顺序我屡试不爽。