33、C++20 核心特性:协程(Coroutines),异步编程的新范式
协程这个话题,说实话,我盼了很久。
C++20 之前,我们写异步代码基本就两条路:回调地狱,或者自己撸状态机。回调嵌套多了,代码读起来像洋葱,一层剥完还有一层。我自己维护过一个网络库,里面有个连接重试的逻辑,回调嵌套了七八层,后来新同事接手,直接跟我说「这代码我不敢改」。
协程的出现,说白了就是让异步代码能像同步代码一样写。你不需要手动管理状态,不需要拆函数,不需要搞什么「完成回调」——挂起、恢复,编译器帮你搞定。
什么是协程?
协程是一个可以挂起(suspend)和恢复(resume)的函数。普通函数要么执行完,要么没执行完,没有中间状态。协程不一样,它可以在中间某个点停下来,把控制权交出去,等条件满足了再回来接着跑。
嗯,这里要注意:协程不是线程。线程是操作系统调度的,协程是程序自己控制的。你可以把协程理解成「用户态线程」,切换开销极小,几乎没有。
核心区别:
- 线程:抢占式,内核调度,切换成本高(微秒级)
- 协程:协作式,用户调度,切换成本极低(纳秒级)
C++20 协程的三个关键字
C++20 没有引入新的语法关键字,而是用了三个新的运算符:
co_await—— 挂起当前协程,等待某个操作完成co_return—— 从协程返回一个值co_yield—— 产生一个值(类似生成器)
我刚开始学的时候,觉得这三个东西很简单。后来发现,真正难的是协程背后的「基础设施」——Promise 类型、Awaitable 类型、协程帧……这些才是让你头疼的地方。
一个最简单的协程例子
先看个能跑的代码,感受一下:
#include <coroutine>
#include <iostream>
struct SimpleTask {
struct promise_type {
SimpleTask get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
SimpleTask hello() {
std::cout << "Hello, ";
co_await std::suspend_always{};
std::cout << "World!" << std::endl;
}
int main() {
hello();
return 0;
}
这段代码输出什么?「Hello, World!」。因为 initial_suspend 返回的是 suspend_never,协程创建后立即执行,没有挂起。但如果你把 initial_suspend 改成 suspend_always,那协程创建后就会挂起,直到你手动 resume 它。
我在项目中遇到过一个问题:有人把 initial_suspend 和 final_suspend 都设成了 suspend_never,结果协程跑完就销毁了,根本没法获取返回值。后来查了半天文档才意识到,final_suspend 如果返回 suspend_never,协程帧会在 co_return 后立即销毁,你连读结果的机会都没有。
避坑指南: 我曾经在写一个异步读取器时,把 final_suspend 设成了 suspend_never,导致协程帧被提前释放,调用方拿到的全是野指针。后来我养成了一个习惯:除非你非常清楚自己在做什么,否则 final_suspend 永远返回 suspend_always。
协程的生命周期
一个协程从创建到销毁,大致经历这几个阶段:
- 分配协程帧(堆上分配,除非你自定义分配器)
- 构造 Promise 对象
- 调用
initial_suspend,决定是否立即挂起 - 执行协程体,遇到
co_await或co_return时挂起/返回 - 调用
final_suspend,决定是否在结束时挂起 - 销毁协程帧
你想想看,这其实就是一个有限状态机。编译器帮你把每个挂起点变成了状态机的状态,每次恢复就是跳转到下一个状态。这也是为什么协程性能好的原因——没有系统调用,没有上下文切换,就是一次简单的跳转。
Awaitable 与 Awaiter
co_await 后面跟的东西,必须是一个 Awaitable。Awaitable 可以是:
- 一个实现了
await_ready、await_suspend、await_resume三个方法的对象(Awaiter) - 或者通过
promise_type::await_transform转换后的 Awaiter
这三个方法的作用:
| 方法 | 作用 |
|---|---|
await_ready |
返回 bool,true 表示不需要挂起,直接继续执行 |
await_suspend |
如果 await_ready 返回 false,则调用此方法,传入当前协程句柄 |
await_resume |
协程恢复后,此方法的返回值就是 co_await 表达式的结果 |
我个人的习惯是,把 await_ready 当作快速路径检查。比如检查一个 I/O 操作是否已经完成,如果完成了就直接返回数据,省掉一次挂起恢复的开销。这在高频场景下能省不少时间。
协程的典型应用场景
协程最擅长处理的就是「等待 I/O」的场景。比如:
- 网络库:等待 socket 可读/可写
- 文件系统:异步读取文件
- 定时器:等待一段时间
- 生成器:按需产生序列
我去年重构过一个 RPC 客户端,原来用回调加状态机写的,一个请求要拆成三个回调函数,逻辑散得到处都是。改成协程后,整个请求流程写在一个函数里,co_await 等待响应,代码量减少了 40%,可读性提升了一个档次。
协程的性能考量
协程不是银弹。它也有代价:
- 堆分配: 每个协程帧默认在堆上分配。高频创建销毁协程会有压力。可以用自定义分配器优化。
- 无法取消: C++20 协程没有内置的取消机制。你需要自己实现,比如通过一个标志位检查。
- 调试困难: 协程的调用栈是「断裂」的,挂起时栈帧被保存,恢复时重建,调试器不一定能正确显示。
我记得有一次线上问题,协程挂起后某个资源被释放了,恢复时访问了野指针。gdb 里看调用栈,只能看到当前恢复点,根本看不到是谁触发的挂起。后来我加了一堆日志才定位到问题。
小技巧: 调试协程时,可以在 await_suspend 里打印当前协程句柄的地址,配合 co_await 前后的日志,就能串起整个生命周期。
协程与异步编程的新范式
协程改变了我们组织异步代码的方式。以前是「回调驱动」,现在是「协程驱动」。你写代码时不需要考虑「谁在等我」,只需要考虑「我在等谁」。
这种思维转变,其实比语法本身更重要。我见过有人把协程当线程用,开一堆协程然后手动调度,结果性能还不如线程池。协程的正确用法是:用同步的写法,写异步的逻辑。不要试图用协程去模拟线程调度,那是操作系统该干的事。
SVG 流程图:协程生命周期
总结
C++20 协程给异步编程带来了全新的范式。它让代码更线性、更易读,但也带来了新的复杂性——Promise、Awaitable、协程帧管理,这些概念需要花时间去消化。
我个人建议,初学者先从简单的生成器入手,理解 co_yield 和 co_return 的用法,再逐步深入到 co_await 和自定义 Awaiter。不要一上来就搞复杂的异步框架,容易劝退。
协程不是万能的,但用对地方,效果立竿见影。