C++20 协程机制(Coroutines)

C++20 引入的协程特性,对 C++ 具有极其重要意义。这一特性能够帮助开发者在多任务场景下编写出可读性、可维护性更好的代码,提升复杂系统的开发效率和可维护性。

需要明确的是,协程不是框架,也不是现成的库,更无法自动解决问题。本质上,协程是一种底层的基础编程能力,其真正的价值,完全取决于开发者如何基于这一能力进行上层封装与实际应用。

接下来,我们将从实际开发中的痛点问题切入,逐步了解协程的特点、技术细节、以及如何应用。

1. 问题场景

假设我们要实现一个包含两个任务的程序,我们先看看会遇到什么问题。

任务 A:从网络读取数据
这是一个典型的 IO 操作,需要等待外部网络返回。

任务 B:处理本地业务逻辑
这是纯计算或简单逻辑处理,可以立刻完成。

从代码逻辑上看,这个程序是正确的,但执行效果并不理想。程序在执行任务 A 时,会因为等待网络返回而阻塞当前线程。在这 3 秒内,线程什么也做不了,任务 B 明明已经具备执行条件,却只能被迫等待。

造成这一问题的根本原因在于:普通函数一旦开始执行,就必须一路执行到返回为止。当函数内部需要等待外部事件时,只能通过阻塞线程的方式等待,执行权无法主动让出。

简言之:普通函数无法暂停和恢复。


为解决这类问题,C++20 引入了协程。简单来说,协程可以理解为一种可暂停/恢复的函数。协程在执行过程中,如果遇到需要等待外部条件的地方,可以先暂停当前执行,把执行权交还出去。当外部条件满足后,再从之前暂停的位置继续运行。正因为具备这种能力,协程特别适合用于以下两类场景。

IO 密集型多任务场景:当程序需要频繁等待外部 IO(如网络请求、磁盘读写、数据库查询)时,协程可以在等待期间不阻塞线程,让线程继续执行其他任务,从而提升任务处理效率。

惰性计算场景:当计算过程可以自然拆分为多个阶段,并且结果是按需消费的(如无限序列生成、大数据分批处理、流式计算),协程可以在产出一个阶段性结果后暂停执行,直到外部真正需要下一步时再继续。

2. 语法规则

我们从普通函数开始,将其逐步改造为能够支持暂停/恢复的协程函数。

2.1 coroutine_handle

C++ 协程可支持暂停/恢复,编译器为每一个协程提供了 std::coroutine_handle 工具用于管理协程,例如:创建协程、销毁协程、恢复协程、判断协程状态等等。

当创建一个协程的时候,std::coroutine_handle 会在内部创建一块存储空间,用于保存协程所需要的所有状态,这块存储空间称作协程帧。很显然,当协程销毁时,需要释放这块存储空间。为了避免大家忘记释放,C++ 要求我们要通过 RAII 的方式使用 std::coroutine_handle,即:创建一个 RAII 对象,在构造函数中创建 std::coroutine_handle,在析构函数中销毁 std::coroutine_handle。

注意:我们并不能够在 CoroRAII 直接创建一个有效的 std::coroutine_handle 对象,原因有二:

  • std::coroutine_handle 必须基于 _CoroPromise 构建,而 _CoroPromise 目前不知道是什么。
  • CoroRAII 必须由编译器创建,可编译器无法知道如何构建 CoroRAII 对象。

2.2 promise_type

在 CoroRAII 中,我们需要定义 struct promise_type,它就是构造 std::coroutine_handle 所需要的对象。这个对象作用是什么?

  • 它定义了:协程创建后,是先挂起,还是直接执行?
  • 它定义了:当协程中有未处理的异常时,怎么处理?
  • 它定义了:当协程中返回结果如何处理?
  • 它定义了:当协程逻辑结束是马上销毁,还是稍后由用户销毁?
  • 它定义了: CoroRAII 对象如何构建【重要】

到这里,需要同学们注意下协程的创建流程,编译器会:

  1. 首先,创建 promise_type 对象
  2. 然后,调用 promise_type::get_return_object() 函数返回协程管理对象
  3. 接着,调用 promise_type::initial_suspend() 函数确定协程初始化后是否马上执行
  4. 如果,协程中抛出了未处理的异常,promise_type::unhandled_exception() 函数会被执行处理异常
  5. 最后,协程结束之后,调用 promise_type::final_suspend() 函数来确定马上销毁协程,还是用户手动销毁

从语法角度,编译器还是无法确定 my_coroutine 是普通函数,还是协程函数。所以,编译器又做了一个要求,对于协程函数 my_coroutine,内部必须出现协程关键字:co_return、co_yield、或者 co_await。

我们总结一下,在 C++20 中,一个函数被编译器判定为协程,需同时满足以下两个核心条件:

  • 函数内部要求:函数体内部必须显式使用 co_return、co_yield、co_await 中的至少一个协程关键字。
  • 返回类型要求:返回类型中必须嵌套定义合法的 promise_type 结构体,缺失会编译报错。

2.3 co_return

co_return 可以直接结束协程逻辑、或者返回一个值再结束协程逻辑,当协程逻辑结束后,会自动调用 promise_type::final_suspend 函数 。

注意:协程逻辑结束并不代表协程对象立即销毁,协程对象是否随着协程逻辑结束而销毁是由 final_suspend 的返回值决定。

  • std::suspend_never → 协程立即销毁,资源释放
  • std::suspend_always → 协程挂起,由外部显式调用 destroy() 才销毁

当 co_return 没有返回值时,编译器会调用 `void promise_type::return_void()` 函数来处理返回逻辑,然后执行 final_suspend() 函数,根据其返回值决定协程是否销毁。


当 co_return 有返回值时,它会将返回值传递到 void promise_type::return_value(T value) 函数中处理,然后执行 final_suspend() 函数,根据其返回值决定协程是否销毁。注意:

  • co_return 返回值需要通过 promise_type 的成员变量存储与获取,建议封装返回值获取接口,避免外部直接操作 handle 和 promise 对象。
  • return_value 和 return_void 互斥,即:协程函数要不有返回值、要不没有返回值


当协程函数在执行时,抛出异常,但是并没有处理异常时,编译器会自动调用:void promise_type::unhandled_exception() 来处理。

2.4 co_yield

与 co_return 直接终止协程执行不同,co_yield 并不会结束协程,而是先向调用方生成一个中间值,再将自身挂起。它无需依赖任何外部事件,完全由协程主动触发挂起,后续需等待调用方显式恢复才能继续执行。

具体的执行流程,当协程执行到 co_yield value; 时:会调用 auto promise_type::yield_value(T value) 函数,其返回值决定协程在 co_yield 后是否立即挂起:

  • std::suspend_always:协程挂起,调用者可以从外部通过 resume() 恢复执行
  • std::suspend_never:协程不挂起,继续执行

2.5 co_await

co_yield 挂起的逻辑很简单,就是返回结果后挂起。co_await 需要根据条件进行挂起,挂起的时候,可能还需要执行某些操作。恢复的时候,也会执行某些逻辑。这也决定了 co_await 后面不能是简单的表达式,而是一个可等待对象(实现 awaitable 协议的对象)。

struct Awaiter
{
    // 是否继续执行
    // true 表示继续执行,然后执行 await_resume() 返回结果
    // false 表示挂起,然后执行 await_suspend() 进行挂起前的操作
    bool await_ready();
    
    // 返回 void:执行后挂起
    // 返回 bool:true 表示执行完后挂起,false 表示执行完后不挂起,继续执行 await_resume 函数
    // 返回 handle:表示执行完后,继续恢复执行 handle 关联的协程
    void/bool/handle await_suspend(std::coroutine_handle<> handle);

    // 协程恢复后执行,返回 co_await 的结果
    T await_resume();
};


在 promise_type 中定义 await_transform,可以拦截 co_await 后的表达式,并将其转换为一个满足 awaitable 协议的对象,无论原始表达式本身是否可等待。

3. 协程帧

协程帧是编译器为协程生成的一块独立存储区域,用于保存协程跨 co_await/co_yield 边界时必须保留的全部状态。它承载的内容包括局部变量、promise 对象、当前执行位置以及挂起点信息,是协程实现挂起与恢复执行的核心数据结构。

在 C++20 协程中,当一个协程被创建时,编译器默认会通过 new 为其分配协程帧(coroutine frame)所需的内存;当协程执行完毕并销毁时,则通过 delete 释放该内存。

这种默认行为对于大多数普通应用场景是足够安全且高效的。然而,在某些高性能或高并发场景下(例如每秒需要创建成千上万个短生命周期协程的服务器程序),频繁调用 new/delete 会带来显著的性能开销:不仅增加了内存分配/回收的 CPU 时间,还可能引发内存碎片,进而成为系统瓶颈。

为了解决这个问题,C++ 允许开发者通过自定义协程的内存分配策略(例如重载 operator new 和 operator delete)来接管协程帧的内存管理。这样可以大幅降低分配成本、提升内存访问局部性,并使性能表现更加可预测。

但需要注意的是,自定义内存管理会增加代码复杂度和维护成本。因此,除非你的程序确实面临协程数量庞大、生命周期极短、且内存分配已成为性能瓶颈的情况,否则通常没有必要放弃默认的内存管理机制。

4. 应用案例

4.1 惰性计算

只在结果被真正需要时才执行计算,而不是在定义时立刻执行。

4.2 异步编程

异步编程是一种以避免发起任务的执行线程被阻塞为目标的编程思想。 它通过将等待操作从当前执行路径中剥离,使线程在等待期间仍能继续处理其他任务,从而提升线程的整体利用效率。 在工程实现上,异步编程通常会结合多线程、线程池、多路 I/O 复用、定时器等机制来承载和管理这些等待。

  1. 初始化:将协程任务注册到事件循环中
  2. 开启事件循环:事件循环调度任务1,当发生阻塞时候,任务1挂起。执行权交给事件循环,事件循环调度任务2。任务1在挂起期间如果完成阻塞,通知事件循环,事件循环在合适时机再恢复任务1的执行。