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_suspendfinal_suspend 都设成了 suspend_never,结果协程跑完就销毁了,根本没法获取返回值。后来查了半天文档才意识到,final_suspend 如果返回 suspend_never,协程帧会在 co_return 后立即销毁,你连读结果的机会都没有。

避坑指南: 我曾经在写一个异步读取器时,把 final_suspend 设成了 suspend_never,导致协程帧被提前释放,调用方拿到的全是野指针。后来我养成了一个习惯:除非你非常清楚自己在做什么,否则 final_suspend 永远返回 suspend_always

协程的生命周期

一个协程从创建到销毁,大致经历这几个阶段:

  1. 分配协程帧(堆上分配,除非你自定义分配器)
  2. 构造 Promise 对象
  3. 调用 initial_suspend,决定是否立即挂起
  4. 执行协程体,遇到 co_awaitco_return 时挂起/返回
  5. 调用 final_suspend,决定是否在结束时挂起
  6. 销毁协程帧

你想想看,这其实就是一个有限状态机。编译器帮你把每个挂起点变成了状态机的状态,每次恢复就是跳转到下一个状态。这也是为什么协程性能好的原因——没有系统调用,没有上下文切换,就是一次简单的跳转。

Awaitable 与 Awaiter

co_await 后面跟的东西,必须是一个 Awaitable。Awaitable 可以是:

  • 一个实现了 await_readyawait_suspendawait_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 流程图:协程生命周期

协程生命周期 调用协程 分配协程帧 + 构造 Promise initial_suspend suspend_never suspend_always 执行协程体 挂起(等待恢复) co_await 恢复执行 co_return final_suspend 销毁协程帧

总结

C++20 协程给异步编程带来了全新的范式。它让代码更线性、更易读,但也带来了新的复杂性——Promise、Awaitable、协程帧管理,这些概念需要花时间去消化。

我个人建议,初学者先从简单的生成器入手,理解 co_yieldco_return 的用法,再逐步深入到 co_await 和自定义 Awaiter。不要一上来就搞复杂的异步框架,容易劝退。

协程不是万能的,但用对地方,效果立竿见影。

专注资料整理