【C++高阶系列】C++20 协程底层原理:函数为什么能暂停?从状态机到 Task,彻底串起挂起、恢复与协程调度

🔥 本文专栏:C++高阶

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录:你不需要证明自己注定成功,你只需要证明今天的自己没有停下。


思维导图:先看整条学习链路

text 复制代码
网络 I/O 存在等待时间
    ↓
直接同步等待:线程可能被阻塞
回调 + Reactor:能非阻塞,但业务流程容易被拆散
    ↓
希望:业务逻辑仍然按顺序书写,等待时却能主动让出执行权
    ↓
协程:顺序推进 + 指定挂起点 + 后续恢复
    ↓
C++20 为什么能够做到?
    ↓
编译阶段识别 co_await / co_yield / co_return
    ↓
生成协程初始化、挂起、恢复和销毁相关代码
    ↓
每次调用产生独立协程实例和协程帧
    ├── 执行进度、必要的参数与局部状态
    └── Promise Object(类型为 promise_type)
    ↓
get_return_object() → Task → coroutine_handle
initial_suspend() → 是否先停在起点
    ↓
函数体执行,遇到 co_await
    ├── await_ready():能否直接继续
    ├── await_suspend():挂起、登记等待者、调度/转移控制权
    └── await_resume():等待结束后取得表达式结果
    ↓
co_return → return_value()/return_void()
未捕获异常 → unhandled_exception()
    ↓
final_suspend():最后的挂起点/恢复等待者
    ↓
Task::get() / co_await Task 取出结果
    ↓
RAII + handle.destroy():安全销毁协程帧

一、先不看协程语法:为什么我们需要"能够暂停的函数"?

1. 从熟悉的网络服务器出发

假设我们现在实现一个客户端请求处理函数,最直观的代码可能是:

cpp 复制代码
void handleClient(int fd) {
    auto request = readRequest(fd);
    auto response = process(request);
    writeResponse(fd, response);
}

从业务逻辑上看,这段代码没毛病:先读取请求,再处理请求,最后发送响应。

但这里存在一个问题:如果 readRequest() 使用阻塞 I/O,而客户端迟迟不发送数据,那么调用它的线程就可能一直等待。此时我们只是需要等一个 I/O 条件,却把整个执行线程暂时占住了。

当然,我们之前学习过 Reactor、epoll,知道不必采用"一个连接对应一个阻塞线程"的模型。可以把 socket 设置成非阻塞模式,由 EventLoop 等待事件就绪,之后再执行相关处理逻辑。

例如,可以把原本的流程拆成:

text 复制代码
注册 fd 的可读事件
    ↓
epoll_wait() 检测事件
    ↓
可读回调读取数据
    ↓
若请求完整,执行业务逻辑
    ↓
注册可写事件
    ↓
可写回调发送响应

这种方式解决了许多线程阻塞问题,但也带来另一个现象:业务逻辑原本是一条顺序执行的线,现在却需要按照事件边界拆成多个回调,并额外管理连接状态。

于是我们自然会产生一个想法:

能不能既保留 读取 → 处理 → 写回 这种直观的顺序写法,又不要求线程在等待 I/O 时一直阻塞?

协程便可以帮助我们组织这样的控制流程。

这里要注意,co_await 本身不会把阻塞 I/O 自动变成异步 I/O 。真正的非阻塞能力仍然来自 socket、操作系统 I/O 机制。协程主要解决的是:怎么表达和保存这个可以分阶段继续执行的业务过程。

2. 普通函数和协程:都沿着原本的控制流前进

我们可以先建立一个简单的心智模型:把一次函数调用的执行过程看成一条从起点到终点的线。

cpp 复制代码
void fun() {
    fun1();
    fun2();
    fun3();
}

忽略分支、循环和异常时,其执行顺序就是:

text 复制代码
开始 → fun1 → fun2 → fun3 → 结束

协程同样不会凭空打乱这个顺序。

cpp 复制代码
Task<void> fun() {
    fun1();
    co_await something();
    fun2();
    fun3();
    co_return;
}

其逻辑仍然是 fun1 → 等待点 → fun2 → fun3。区别在于:它可能在等待点暂停,把当前执行权交还给别的代码,等以后恢复,再从这个位置继续。

因此,我们不能把协程理解成"先执行 fun1,然后自动跳过 fun2,直接跑 fun3"。它不改变原有的业务依赖关系;它允许的是分阶段向前执行。

这里要注意:

  • C++20 协程不能在任意机器指令处随意挂起 ,而是在语言允许的挂起点处理挂起,典型形式就是 co_await,以及协程起点和终点的隐式等待。

二、有栈协程:既然线程能够切换,那么协程能不能采用类似的方式?

在正式认识 C++20 无栈协程之前,我们不妨先来思考一个问题:既然线程能够在执行过程中被暂停,然后在之后恢复执行,那么我们能不能借鉴线程上下文切换的思路,在用户态实现协程的挂起与恢复呢?

答案当然是可以的,而这也正是典型的**有栈协程(Stackful Coroutine)**的一种实现思路。

首先,我们知道,对于操作系统线程而言,线程是 CPU 调度的基本单位。当操作系统需要切换线程时,就需要保存当前线程必要的执行上下文,从而保证这个线程之后再次被调度时,能够从之前暂停的位置继续执行。

那么,这里的执行上下文究竟是什么呢?

其实可以简单理解为:CPU 在当前时刻继续执行这段程序所需要的机器状态。

例如,当前执行位置、栈指针,以及部分通用寄存器等信息。

认识了这点之后,我们就可以进一步思考:既然操作系统可以通过保存和恢复执行上下文来切换线程,那么对于同一个线程内部的不同协程,我们是不是也可以采用类似的方式?

这就引出了有栈协程的两个核心组成部分:

  • 协程栈(Coroutine Stack):用于保存协程执行过程中的函数调用栈帧、局部变量等信息。
  • 协程上下文(Coroutine Context):用于保存协程切换时需要恢复的机器执行状态,例如栈指针、恢复执行的位置,以及其他必要的寄存器状态。

这里需要注意,协程栈与协程上下文是两个不同的概念,它们不一定存储在同一块内存区域中。

为什么需要分别维护这两部分呢?

首先,对于普通函数,我们知道,函数调用过程中通常需要利用当前线程的调用栈来保存函数执行所需的信息,例如局部变量、返回地址以及必要的临时数据。

而有栈协程的思路则是:给每个协程准备一块独立的栈空间,使得协程内部的函数调用,可以使用自己的协程栈。

比如协程 A 内部依次调用:

cpp 复制代码
void funA() {
    fun1();
    fun2();
    fun3();
}

假设当前协程 A 执行到了 fun2(),并且 fun2() 内部又调用了其他函数,那么这些尚未返回的函数调用所对应的栈帧,就可以保留在协程 A 自己的协程栈中。

这样,即使协程 A 暂停了,它之前的函数调用关系以及相应的局部变量也不会因为切换到其他协程而丢失。

但是,仅仅保留协程栈还不够。

因为我们还需要知道:协程 A 下一次恢复时,CPU 应该从哪里继续执行?又应该使用哪个栈?

这就需要协程上下文了。

协程上下文保存着恢复执行所必需的机器状态,例如栈指针以及恢复执行所需的控制流信息。

这里还有一个值得注意的细节:我们通常不需要在每次切换时把 CPU 的所有寄存器无条件保存一遍,而是根据具体实现、处理器架构以及调用约定,保存恢复执行所必需的寄存器状态。

那么,用户态程序真的能够操作 CPU 寄存器吗?

读者读到这里,可能会产生一个疑问:

CPU 寄存器不是由 CPU 和操作系统管理的吗?我们编写的用户态程序,怎么能够直接保存和修改这些寄存器的值呢?

其实,这里就存在一个非常容易产生的认知误区,而这也是我最开始理解有栈协程时曾经困惑的地方。

我们平时编写的 C++ 代码,只是在使用高级语言描述程序的业务逻辑。

例如:

cpp 复制代码
int a = 10;
int b = 20;

int c = a + b;

从 C++ 源代码的角度来看,我们并没有显式访问任何 CPU 寄存器。

但是,这不代表程序在执行过程中没有使用寄存器。

要知道,CPU 实际执行的并不是我们编写的 C++ 源代码,而是编译器生成的机器指令。

当我们查看程序经过编译之后的汇编代码时,就会发现,里面存在大量针对 CPU 寄存器的操作。

例如,对于前面的加法运算,编译器在某种情况下可能生成类似这样的指令:

asm 复制代码
mov eax, 10
add eax, 20

这里的 eax 就是 x86 架构中的一个通用寄存器。

当然,实际编译器也可能直接在编译阶段计算出结果,或者采用其他指令形式,这里只是为了说明寄存器操作的基本原理。

因此,我们需要认识到:

虽然高级语言隐藏了很多底层机器指令的实现细节,但用户态程序本身就是通过执行机器指令来操作寄存器、访问内存以及完成计算的。

只不过,CPU 的寄存器和指令也存在权限上的区别。

例如,普通用户态程序可以按照处理器架构及调用约定使用通用寄存器,包括执行正常的栈指针调整和控制流跳转;但是,对于某些特权寄存器以及特权指令,就需要操作系统内核所拥有的相应执行权限。

所以,用户态不能执行特权操作,并不等于用户态不能访问或者修改普通 CPU 寄存器。

认识了这点之后,我们就能理解为什么有栈协程可以在用户态完成上下文切换了。

有栈协程究竟是怎么切换的?

假设当前只有一个线程,而这个线程内部存在两个协程:

  • 协程 A:拥有自己的协程栈 A 和协程上下文 A。
  • 协程 B:拥有自己的协程栈 B 和协程上下文 B。

现在,线程正在执行协程 A。

假设协程 A 执行到某个位置,决定暂时挂起,并将控制权交给协程 B。

那么,整个切换过程就可以理解为:

第一步:保存协程 A 的执行上下文。

协程切换代码保存当前必要的寄存器状态,例如栈指针以及恢复执行所需的信息。

这里的目的就是保证:当我们之后再次恢复协程 A 时,能够继续使用它之前的协程栈,并从原来暂停的位置继续执行。

第二步:定位协程 B 的执行上下文。

既然我们现在希望切换到协程 B,那么就需要找到之前为协程 B 保存的上下文信息。

如果协程 B 还没有运行过,那么这里使用的就是为它初始化好的执行上下文。

第三步:恢复协程 B 的执行上下文。

通过底层切换代码,恢复协程 B 所需的寄存器状态,包括将栈指针切换到协程 B 的栈,并恢复相应的执行位置。

这里的实现通常涉及汇编代码、寄存器恢复以及跳转等操作,具体方式与处理器架构和协程库有关。

当这些操作完成之后,CPU 就会继续执行协程 B 对应的代码。

整个过程可以简单理解为:

text 复制代码
同一个线程
     |
     v
正在执行协程 A
     |
     | 保存 A 的必要寄存器状态
     | 保留 A 的协程栈
     v
恢复协程 B 的执行上下文
     |
     | 切换到 B 的协程栈
     | 恢复 B 的执行位置
     v
继续执行协程 B
     |
     | B 之后也可以挂起
     v
再次恢复协程 A
     |
     v
A 从之前暂停的位置继续执行

注意,这里始终是在同一个操作系统线程内部进行协程之间的切换,并不意味着操作系统在切换两个不同的线程。

也就是说,我们通过用户态的代码,主动完成了协程之间的执行上下文切换。

当然,线程本身仍然可能被操作系统抢占和调度,但这是另外一个层次的事情。

而这里还有一个非常重要的特点:

因为有栈协程保留了独立的调用栈,所以它不仅能够保存某一个函数自身的执行状态,还能够保留整个尚未返回的函数调用链。

例如,协程 A 调用了 fun1(),fun1() 又调用了 fun2(),如果协程库支持在 fun2() 中主动让出执行权,那么整个调用链对应的栈状态都能够随着协程 A 一起被保留下来。

等到协程 A 恢复时,就能够从 fun2() 中之前暂停的位置继续执行,而不是重新调用 fun1() 或者 fun2()。

既然有栈协程能够实现挂起与恢复,为什么还需要无栈协程?

认识了有栈协程的实现原理之后,我们就可以进一步思考,它是否存在一些代价。

首先,就是独立协程栈带来的空间开销。

对于典型的有栈协程实现,每个协程都需要拥有自己的栈空间。

假设程序中存在大量协程,即使其中很多协程当前都处于挂起状态,我们仍然需要考虑这些协程对应的栈空间如何分配、保留以及管理。

所以独立协程栈仍然会带来相应的空间管理成本。

其次,就是协程上下文切换以及协程栈管理的开销。

例如,协程切换时需要保存和恢复必要的寄存器状态,协程创建和销毁时还可能涉及栈空间的申请、释放以及其他管理操作。

不过,这里需要强调:有栈协程并不一定比无栈协程慢,具体性能还取决于实现方式、工作负载以及协程调度模型。

我们真正需要认识的是:

有栈协程为了能够挂起和恢复整个函数调用链,需要保存独立的调用栈以及必要的机器执行上下文。

那么,这里就产生了一个新的问题:

既然我们实现协程的目的,只是希望程序能够在某些位置暂停,然后从之前的位置继续执行,那是不是一定需要为每个协程创建独立的协程栈,并通过保存和恢复机器上下文来完成切换呢?

换句话说,我们能不能采用另外一种思路:

不再为每个协程保存一整套独立调用栈,而是让编译器提前识别协程函数,将其转换成一种支持暂停和恢复的执行结构,只保存恢复执行所必需的状态?

答案当然是可以的。

而这就引出了我们接下来要重点研究的内容:

无栈协程(Stackless Coroutine)。

C++20 标准协程采用的正是这种无栈协程模型。它的核心不再是通过切换独立的协程栈来恢复整个调用链,而是由编译器对协程函数进行控制流转换,并通过协程帧保存必要的执行状态,从而实现协程在挂起点之间的恢复执行。

至此,我们也就从有栈协程的实现原理,自然过渡到了 C++20 无栈协程的底层实现机制。

三、真正的核心:C++20 为什么能够让同一个函数暂停后继续?

1. 先区分有栈协程与无栈协程

我们之前已经接触过线程上下文、函数栈帧等概念。一个很自然的想法是:既然函数执行到一半要暂停,那么能不能把它当时的栈、寄存器、执行地址都保存起来,下次直接恢复?

这可以帮助理解典型的有栈协程(Stackful Coroutine):它通常为协程准备独立的栈,切换时保存与恢复必要的机器上下文,因此能够保留一整条挂起的调用栈。

但 C++20 标准协程属于无栈协程(Stackless Coroutine),其实现思路不同:

不是为每个协程保留一套独立的机器调用栈,而是由编译器提前改造协程函数的控制流,并把跨挂起点仍需保留的状态放进协程帧。

"无栈"不代表协程执行过程中不使用线程栈。协程正在运行时,照样会在当前线程的调用栈上调用普通函数。只是它不靠保留独立的协程调用栈来实现挂起。

2. 编译器什么时候识别协程函数?

例如:

cpp 复制代码
int ordinary() {
    return 20;
}

这是普通函数。

而:

cpp 复制代码
Task<int> coroutine() {
    co_return 20;
}

编译器在编译阶段根据函数体中的协程关键字识别它是协程函数。常见关键字包括:

  • co_await:对一个可等待表达式进行等待。
  • co_yield:产生一个值,并经由 Promise 的相关机制处理挂起;常见于生成器。
  • co_return:结束协程函数体,按 Promise 协议处理结果。

不是因为返回类型名字叫 Task,编译器才把函数识别成协程。 Task 只是我们选择的返回类型名称。

这里还要区分两个时间点:

  • 编译阶段:识别协程,生成协程创建、执行/恢复和销毁等代码。
  • 运行阶段:程序真正调用协程函数时,才建立这次调用对应的协程状态、构造 Promise 对象并执行初始化逻辑。

3. 用状态机理解编译器的转换

我们不妨先写一个协程:

cpp 复制代码
Task<int> calculate() {
    int a = 10;
    fun1();
    co_await something();
    fun2();
    co_return a * 2;
}

从概念上,可以把编译器生成的执行逻辑想象为:

cpp 复制代码
// 仅用于建立心智模型,不是真实的编译器输出
struct CoroutineFrame {
    int state;           // 用于说明恢复位置
    int a;               // 需要跨挂起点保留的变量
    Promise promise;     // 当前协程实例的 Promise 对象
};

void resume(CoroutineFrame& frame) {
    switch (frame.state) {
        case 0:
            frame.a = 10;
            fun1();
            // 此处假设等待器决定确实挂起
            frame.state = 1;
            return;
        case 1:
            // 等待已完成,实际还应执行 await_resume()
            fun2();
            frame.promise.return_value(frame.a * 2);
            return;
    }
}

这个模型的精髓不在于编译器是否真的生成一个 switch,而在于:

执行到挂起点时,协程保存能够让后续执行继续推进的状态;恢复时,使用同一个协程实例的状态,走到对应的恢复分支。

编译器还需要处理等待器协议、异常、参数、对象生命周期和最终挂起等细节。上面的伪代码只用来说明控制流转换。

4. 同一个协程函数调用两次,为什么不会串状态?

cpp 复制代码
Task<int> taskA = calculate();
Task<int> taskB = calculate();

这里的 calculate() 是同一个协程函数,但两次调用对应的是两个独立的协程实例。

每个实例通常有自己的:

text 复制代码
协程帧
├── 执行进度及其他实现所需的信息
├── 参数与需要跨挂起点保留的局部状态
├── 挂起期间仍需存在的等待器等对象
└── Promise Object

因此,编译器不需要针对协程 A、协程 B 分别生成 promise1、promise2 这样的全局变量,更不需要为每个实例重新编译一份代码。

你可以把恢复过程理解为:

cpp 复制代码
resume(frameA);  // 操作 A 的状态与 A.promise
resume(frameB);  // 操作 B 的状态与 B.promise

同一套执行/恢复代码 + 不同实例的协程帧 = 多个互不混淆的协程执行过程。


四、既然每次调用都有协程实例,调用者如何找到它?

1. 先认识协程返回对象 Task

普通函数:

cpp 复制代码
int fun() {
    return 20;
}

int value = fun();  // value 就是 20

但是协程可能还在起点挂起,也可能中途等待读取。因此,调用协程函数时获得的返回对象,不应直接等同于函数体最终计算出来的值。

例如:

cpp 复制代码
Task<int> fun() {
    co_return 20;
}

auto task = fun();  // 得到 Task<int>,不是 int

Task 是我们自定义的类型。一个常见设计是让它保存与协程实例关联的句柄,从而管理或访问这次协程执行。

2. coroutine_handle 到底是什么?

C++20 在 <coroutine> 中提供类模板:

cpp 复制代码
std::coroutine_handle<PromiseType>

例如:

cpp 复制代码
using Handle = std::coroutine_handle<promise_type>;
Handle handle;

和 std::vector<int> 一样,std::coroutine_handle 是类模板;std::coroutine_handle<promise_type> 是具体类型;handle 才是句柄对象。

这里模板参数是 Promise 对象的类型 ,不是协程函数的名称,也不是 Task 类型。

常用成员有:

接口 含义
handle.resume() 让一个合法的、当前处于可恢复挂起状态的协程继续执行
handle.done() 判断协程是否处于最终挂起点
handle.promise() 访问当前句柄对应的 Promise 对象(要求是带 Promise 类型的句柄)
handle.destroy() 销毁关联的挂起协程实例及其协程帧

std::coroutine_handle<> 是不指定具体 Promise 类型的通用句柄,也能用于恢复和销毁,但没有 promise() 成员接口。

特别注意:句柄不是协程帧本身,也不是自动管理协程帧生命周期的智能指针。 它更像一个可以定位并操作协程实例的轻量句柄。

3. Promise:类型和对象不要混淆

为了让编译器能够与我们定义的协程返回类型合作,通常在 Task 内嵌一个名为 promise_type 的类型:

cpp 复制代码
struct Task {
    struct promise_type {
        // 供编译器按照 C++20 协程协议调用的成员
    };
};

这里:

  • promise_type:类型定义。
  • Promise Object:每次协程调用建立的协程帧中的具体对象。

两个协程实例可以使用同一种 promise_type,却各自拥有自己的 Promise 对象。

Promise 在协程协议中提供创建返回对象、初始/最终挂起、处理结果和异常等接口,但不意味着它单独包办协程的所有分配、调度与销毁工作。

4. get_return_object():从当前 Promise 建立返回对象

例如:

cpp 复制代码
struct Task {
    struct promise_type;
    using Handle = std::coroutine_handle<promise_type>;

    Handle handle;

    struct promise_type {
        Task get_return_object() {
            auto h = Handle::from_promise(*this);
            return Task{h};
        }
        // 其他必需成员省略
    };
};

这里的 get_return_object() 是 Promise 对象的普通成员函数,因此 *this 表示当前协程实例的 Promise 对象。

而 Handle::from_promise(*this) 是标准库句柄类型提供的静态成员函数 :从一个已经存在的 Promise 对象,取得其所属协程实例的句柄。它不会重新创建协程帧或 Promise 对象。

于是,我们得到了双向关系:

cpp 复制代码
Handle::from_promise(promise);  // Promise 对象 → 对应协程句柄
handle.promise();               // 带类型的句柄 → Promise 对象引用

这两个接口后面都会用到。


等待器(Awaiter):协程究竟如何知道什么时候应该挂起,以及什么时候恢复?

OK,认识了 C++20 无栈协程通过协程帧保存执行状态、通过编译器生成的恢复代码继续执行之后,这里我们再来思考一个问题:

我们已经知道协程可以在执行过程中暂停,然后从之前暂停的位置恢复执行,但是协程究竟怎么知道自己应该在什么时候挂起,又应该在什么时候恢复呢?

这里我们不妨结合之前学习过的 Reactor、epoll 等事件驱动机制来理解。

假设现在有一个协程函数:

cpp 复制代码
Task<int> task(int fd) {
    fun1();

    int result = co_await asyncRead(fd);

    fun2(result);

    co_return result;
}

这里的 asyncRead(fd) 假设表示一个异步读取操作。

我们知道,网络 I/O 操作存在一个特点:当我们发起读取时,数据不一定已经准备好了。

例如,当前 fd 对应的接收缓冲区中没有可读取的数据。

如果这里采用普通的阻塞式读取,那么当前线程就可能被阻塞,直到读取条件满足。

但是,我们学习协程的目的之一,就是希望在等待 I/O 的过程中,只暂停当前协程,而不是让整个线程一直阻塞在这里。

这样,同一个线程就可以先去执行其他协程或者处理其他事件,等当前 I/O 操作满足继续执行的条件之后,再恢复之前挂起的协程。

那么问题来了:

编译器虽然能够为协程生成挂起与恢复代码,但是它怎么知道这个异步操作究竟有没有完成呢?

要知道,编译器并不知道我们这里等待的究竟是网络 I/O、定时器、文件操作,还是某个异步计算任务。

更不知道具体的业务完成条件是什么。

因此,我们就需要向编译器提供一套统一的接口,让编译器能够通过这些接口与我们定义的等待逻辑进行交互。

而这就引出了 C++20 协程中的一个重要概念:

等待器(Awaiter)。

五、等待器究竟是什么?

首先,我们可以将等待器理解为:一个用于描述当前等待条件,以及协程应该如何挂起、恢复和取得等待结果的对象。

对于前面讨论的异步读取场景,等待器可以关联当前异步读取操作的状态以及最终结果。

例如,我们可以关心两个问题:

  • 当前异步读取操作是否已经完成?
  • 如果没有完成,那么当前协程应该怎样挂起,并在操作完成后得到恢复?

注意,这里的等待器并不是 C++20 的一个固定类名,而是我们可以自定义的类型。

不过,自定义等待器需要满足 C++20 规定的等待器协议。

其中最核心的就是三个接口:

cpp 复制代码
await_ready();
await_suspend();
await_resume();

我们下面分别来认识。

await_ready():判断是否可以跳过挂起

首先,当协程执行到:

cpp 复制代码
int result = co_await asyncRead(fd);

编译器会根据 co_await 操作数得到对应的等待器。

假设 asyncRead(fd) 本身是一个普通函数,直接返回我们自定义的 AsyncReadAwaiter 对象,那么这个返回对象就可以直接作为等待器使用。

接下来,编译器会调用:

cpp 复制代码
awaiter.await_ready();

这个接口通常返回 bool。

对于当前异步 I/O 场景,我们可以将它设计成:

cpp 复制代码
bool await_ready() const noexcept {
    return operation.completed();
}

其中 operation 表示等待器关联的异步操作。

那么,这里就存在两种情况。

第一种:返回 true。

表示当前等待条件已经满足,可以跳过挂起。

例如,异步读取操作已经完成,其最终结果也已经准备好了。

此时就没有必要再将当前协程挂起,可以直接进入等待表达式的完成阶段。

第二种:返回 false。

表示不能直接跳过挂起,需要进一步进入挂起处理流程。

例如,当前异步读取操作尚未完成。

这里需要注意:

await_ready() 返回 false,并不代表当前协程一定会持续挂起,而是表示编译器需要继续执行后面的挂起协议。

至于当前协程是否最终保持挂起,还与 await_suspend() 的处理方式有关。

await_suspend():当前协程应该如何挂起?

现在假设:

cpp 复制代码
awaiter.await_ready();  // false

那么编译器就会进入挂起处理流程。

而这里我们之前已经认识到,C++20 编译器生成的协程执行代码,本身就具有保存和恢复执行状态的能力。

因此,在真正调用等待器的 await_suspend() 之前,当前协程已经在语言机制上进入了挂起状态。

接下来,编译器会调用:

cpp 复制代码
awaiter.await_suspend(currentHandle);

这里的 currentHandle 是编译器传入的当前协程实例的句柄,其类型通常可以是:

cpp 复制代码
std::coroutine_handle<>

为什么需要将当前协程的句柄交给等待器呢?

这里就能联系到我们之前学习的协程帧。

我们知道,每一次协程函数调用都会对应一个独立的协程实例,而协程帧中保存着该实例恢复执行所必需的状态。

那么,如果我们希望以后能够恢复当前协程,就需要有一种方式关联到这个协程实例。

协程句柄恰好提供了这种能力。

对于我们熟悉的 Reactor 模型,可以设计这样的逻辑:

cpp 复制代码
void await_suspend(std::coroutine_handle<> current) {
    // 将当前协程句柄登记到事件管理器
    eventLoop.registerWaiter(fd, current);
}

这是一段用于说明机制的伪代码,registerWaiter() 并不是 C++20 标准库提供的接口。

它的核心意思就是:

当前协程需要等待 fd 对应的事件,那么我们就将当前协程句柄与等待事件建立关联。

之后,当前协程保持挂起,线程就可以继续执行其他任务。

等到 epoll 检测到对应 fd 的就绪事件时,EventLoop 就可以根据之前保存的关联关系,找到对应的协程句柄。

例如:

cpp 复制代码
auto handle = eventLoop.findWaiter(fd);

// 在确认恢复条件满足、句柄有效后
handle.resume();

这样,就可以利用 C++20 提供的恢复机制,让之前挂起的协程继续执行。

注意,handle.resume() 本身并不会判断 fd 是否就绪,也不会自动完成异步 I/O。

它只负责恢复对应的协程实例。

至于什么时候恢复,则由我们的 EventLoop 或其他调度机制来决定。

另外,fd 就绪与一次完整的异步读取操作完成,也不一定是同一回事。实际实现中可能还需要执行非阻塞读取,并正确处理 EAGAIN、部分读取等情况。

这里我们还需要补充一个非常重要的知识点:

await_suspend() 并不是只能返回 void。

按照 C++20 的等待器协议,它还可以返回:

  • void:当前协程保持挂起,除非有其他恢复操作发生。
  • bool:返回 true 表示保持挂起,返回 false 表示立即恢复当前协程。
  • std::coroutine_handle<>:将控制权转移给返回句柄对应的协程实例,也就是我们后面会认识的对称转移。

这里尤其需要注意 bool 返回值的用途。

因为实际异步场景中可能存在这样的时序:

我们在 await_ready() 中发现操作尚未完成,但在进入 await_suspend() 的过程中,异步操作恰好完成了。

那么,在正确同步和处理等待者注册的前提下,我们就可以通过返回 false,让当前协程不再保持挂起,避免已经完成的操作还要等待一个可能不会再次到来的通知。

这也说明:

await_ready() 负责初步判断是否需要挂起,而 await_suspend() 负责参与真正的挂起处理与后续恢复安排。

await_resume():恢复之后,如何获取异步操作的结果?

现在我们已经认识了:

如果异步操作没有完成,协程可以通过 await_suspend() 建立等待关系。

当异步操作完成后,外部调度机制可以通过协程句柄恢复当前协程。

那么,恢复以后又会发生什么呢?

我们再来看之前的代码:

cpp 复制代码
int result = co_await asyncRead(fd);

这里最终需要得到一个 int 类型的计算结果。

但我们知道,asyncRead(fd) 返回的是等待器,而不是直接返回最终读取结果。

那么,整个 co_await 表达式的结果应该从哪里获得呢?

答案就是:

cpp 复制代码
awaiter.await_resume();

await_resume() 的核心作用,是完成当前等待表达式,并决定整个 co_await 表达式的返回结果。

例如,我们可以这样设计:

cpp 复制代码
int await_resume() {
    return operation.result();
}

这里假设 operation.result() 可以获取已经完成的异步读取结果,并且会按照设计处理相关错误。

于是:

cpp 复制代码
int result = co_await asyncRead(fd);

就可以在等待完成之后,通过 await_resume() 获取最终的 int 结果。

注意,这里有一个非常重要的细节:

无论协程之前有没有真正挂起,只要整个等待表达式正常完成,编译器最终都会调用 await_resume()。

换句话说,对于 co_await,我们可以按照之前学习的状态机思想,将它抽象成下面的控制流程:

cpp 复制代码
// 概念性伪代码,并非能够代替 co_await 的普通 C++ 代码

auto awaiter = asyncRead(fd);

if (awaiter.await_ready()) {
    // 等待条件已经满足
    // 不需要挂起
} else {
    // 编译器进入挂起处理流程
    // 调用 await_suspend()

    // 如果当前协程确实保持挂起,
    // 则等待以后被恢复
}

// 两条正常完成路径最终汇合

int result = awaiter.await_resume();

这里的核心就是:

await_ready() 判断是否可以跳过挂起,await_suspend() 参与挂起处理,而 await_resume() 负责完成等待表达式并取得结果。

这三个接口组合起来,就构成了 C++20 等待器的基本协议。

将这三个接口组合起来

现在,我们可以自己定义一个概念性的异步等待器:

cpp 复制代码
struct AsyncReadAwaiter {
    AsyncOperation& operation;

    // 1. 判断是否可以跳过挂起
    bool await_ready() const noexcept {
        return operation.completed();
    }

    // 2. 如果需要挂起,登记当前协程
    void await_suspend(std::coroutine_handle<> current) {
        operation.onComplete([current]() {
            // 由调度器安排恢复
            schedule(current);
        });
    }

    // 3. 等待完成后获取结果
    int await_resume() {
        return operation.result();
    }
};

这里的 AsyncOperation 和 schedule() 是用于说明异步操作与协程调度关系的抽象接口,并非标准库类型或可直接编译运行的完整实现。

现在,假设我们有一个普通函数:

cpp 复制代码
AsyncReadAwaiter asyncRead(AsyncOperation& operation) {
    return AsyncReadAwaiter{operation};
}

然后在协程函数中执行:

cpp 复制代码
Task<int> task(AsyncOperation& operation) {
    fun1();

    int result = co_await asyncRead(operation);

    fun2(result);

    co_return result;
}

这里就需要特别注意:

asyncRead() 本身仍然是一个普通函数,它负责返回等待器;真正可能被挂起的是执行 co_await 的 task() 协程。

因此,不能认为 asyncRead() 这个普通函数内部会因为 co_await 而暂停,然后将来重新进入其函数体执行。

当 asyncRead() 返回等待器时,这个普通函数的调用就已经结束了。

之后由编译器按照等待器协议,控制当前协程的挂起与恢复。

而且,从 C++20 的通用机制来说,co_await 操作数也不一定必须直接返回等待器。它可以返回一个可等待对象(Awaitable),再通过 operator co_await() 等机制得到真正的等待器。

这也是我们之后会在自定义 Task 中遇到的设计。

最后,等待器并不一定代表异步 I/O

到这里,我们是从异步 I/O 场景出发,认识了等待器的三个接口。

但是这里还需要进一步强调:

C++20 并没有规定等待器必须关联某个异步 I/O 操作,也没有要求等待器内部一定要保存异步计算结果。

例如,C++20 标准库提供了:

cpp 复制代码
std::suspend_always
std::suspend_never

它们本身就是等待器。

其中:

  • std::suspend_always 的 await_ready() 返回 false,使协程进入挂起处理。
  • std::suspend_never 的 await_ready() 返回 true,使协程跳过挂起。

这两个等待器并不代表某个异步读取操作,也没有需要返回的异步计算结果。

它们只是利用统一的等待器协议,实现不同的挂起控制行为。

因此,等待器的本质并不是保存异步 I/O 状态,而是通过一套标准协议,告诉编译器当前等待表达式应该如何处理挂起、恢复和结果获取。

对于异步 I/O,我们可以自己实现等待器;对于单纯控制挂起的场景,我们也可以直接使用标准库提供的等待器。

认识了这点之后,后面我们再学习 promise_type 中的 initial_suspend()、final_suspend(),以及通过 operator co_await() 让 Task 支持协程之间的等待,就不会觉得这些机制是彼此独立的了。

因为它们最终都是建立在我们现在认识的这套 C++20 等待器协议之上。

六、协程函数体还没执行,为什么 Task 就能返回?------initial_suspend()

1. 先理清协程初始化的顺序

为了便于理解,我们先忽略参数复制、分配失败、异常细节等,关注主干:

text 复制代码
运行时调用协程函数
    ↓
建立协程帧、初始化必要状态,构造 Promise 对象
    ↓
promise.get_return_object():构造 Task 返回对象
    ↓
co_await promise.initial_suspend()
    ↓
若初始挂起:把控制权返回给调用者,函数体尚未开始
若不挂起:直接开始执行函数体,直到下一次挂起或完成

这就解释了为什么 Task 可能已经创建,但协程最终结果尚未产生。

2. initial_suspend() 返回的为什么又是等待器?

我们之前学习过,co_await 的挂起机制由等待器协议决定。因此,编译器对协程起点同样采用可等待对象来描述挂起行为。

最常见的两种写法:

cpp 复制代码
std::suspend_always initial_suspend() noexcept {
    return {};
}

它让协程先停在函数体开始之前。我们通常称这种方式为 Lazy Task(惰性启动)。

cpp 复制代码
std::suspend_never initial_suspend() noexcept {
    return {};
}

它让协程跳过初始挂起,立即进入函数体。可以将其理解为一种立即启动的设计。

注意:std::suspend_always 和 std::suspend_never 是标准库提供的等待器,不需要对应一个异步网络操作。等待器并不一定代表 I/O;它也可以单纯用来控制是否挂起。


七、到这里有一个必须吃透的机制:co_await 到底做了什么?

1. 先分清 Awaitable 和 Awaiter

我们最开始可能写过这样的普通函数:

cpp 复制代码
MyAwaiter asyncRead() {
    return MyAwaiter{/* 异步操作相关信息 */};
}

这里 asyncRead() 是普通函数 ,直接返回等待器对象。函数本身已经返回,后续挂起的是调用它的协程,而不是让 asyncRead() 这个普通函数继续在内部暂停。

但有时我们又想这样写:

cpp 复制代码
Task<int> readData() {
    int n = co_await asyncRead();
    co_return n;
}

readData() 自己也是协程,有独立的协程帧,并且可以在内部真正挂起。调用它得到的是 Task<int>,而不是 int。

那么如果要让另一个协程执行:

cpp 复制代码
int value = co_await readData();

Task<int> 必须满足 C++20 的**可等待对象(Awaitable)**要求。

这里有两种常见方式:

  • 对象本身就是 Awaiter :直接定义 await_ready()、await_suspend() 和 await_resume()。
  • 对象通过 operator co_await() 提供 Awaiter :例如 Task::operator co_await() 返回一个专门的等待器。

第二种有利于把 Task 的所有权管理,与"等待它完成"的行为拆开。但 operator co_await() 并非协程返回类型强制要求的接口 。只有希望 Task 可以被 co_await 时,才需要让它可等待。

2. 等待器的三个接口与调用次序

cpp 复制代码
struct Awaiter {
    bool await_ready();
    void await_suspend(std::coroutine_handle<> current);
    int await_resume();
};

首先调用 await_ready():这个等待表达式能否跳过挂起?

  • 返回 true:跳过挂起处理,直接调用 await_resume()。
  • 返回 false:进入挂起协议,之后调用 await_suspend()。

await_ready() == false 不意味着最终一定要长期挂起 ,因为 await_suspend() 还可能允许立即继续执行。

当等待表达式正常完成,无论之前是否真的挂起,都会执行 await_resume()。它的返回值(如果有)就是整个 co_await 表达式的结果,也可以在这里重新抛出已保存的异常。

我们可以用伪代码理解:

cpp 复制代码
// 注意:只表示逻辑分支,普通 if/else 不能自行实现协程挂起
Awaiter awaiter = /* 从 co_await 操作数获得等待器 */;

if (!awaiter.await_ready()) {
    // 编译器进入挂起协议
    // 调用 awaiter.await_suspend(currentCoroutineHandle)
    // 可能真正挂起,也可能立即继续或转移到其他协程
}

int result = awaiter.await_resume();

这里有一件事要牢牢记住:

handle.resume() 是恢复某个协程的操作;awaiter.await_resume() 是等待表达式完成时产生结果的协议接口。两者名字相似,但不是一回事。

3. await_suspend() 的参数:拿到的是谁的句柄?

cpp 复制代码
await_suspend(std::coroutine_handle<> current)

current 是当前执行 co_await 的协程实例句柄,由编译器交给等待器。

例如 A 正在执行 co_await B(),那么 A 的等待器收到的 current 就是 A 的句柄 ,而不是 B 的句柄。至于 B 的句柄,则可以来自 B() 返回的 Task。

4. await_suspend() 的三种返回形式

返回形式 代表的控制流含义
void 挂起处理完成;没有通过这个返回值要求立即继续;恢复由外部机制安排
bool true 保持挂起;false 立即继续当前协程
std::coroutine_handle<> 或有类型句柄 将控制权转移给返回句柄关联的协程,即对称转移

比如:

cpp 复制代码
std::coroutine_handle<> await_suspend(
    std::coroutine_handle<> current) noexcept {
    // current 为 A;target 是此前已经得到的 B 句柄
    return target;  // A -> B
}

这里的 target 不是编译器自动生成并传入的 B,而是我们在等待器中保存、或者通过其他合法方式获得的一个有效目标句柄。

返回 B 的句柄,只负责把执行权交给 B,并不会自动建立"B 完成后恢复 A"的关系;这个关联还需要我们自己维护。

5. 为什么 await_ready() 判断完,还可能需要二次确认?

联系真实异步操作就能理解:await_ready() 检查时可能发现"还没完成",但在进入 await_suspend() 的短暂间隔内,操作可能恰好完成。

因此,对并发异步等待器来说,通常需要通过同步、原子状态或安全的注册协议,解决完成通知与挂起登记之间的竞态,否则可能出现"事件早就来了,但协程再也没人恢复"的丢失唤醒问题。

await_suspend() 返回 bool 的 false,就可以参与某些"已经完成,不必再挂起"的设计。当然,真正正确的竞态处理不能只靠随便多判断一次,仍然需要严谨的同步机制。


八、最终计算结果保存在哪里?------co_return 与 return_value()

1. 普通函数 return 和协程 co_return,不要当成同一层返回

cpp 复制代码
int ordinary() {
    return 20;
}

普通函数调用者直接拿到 20。

但协程函数:

cpp 复制代码
Task<int> funB() {
    co_return 20;
}

调用 funB() 时先获得 Task<int>。这个 Task 甚至可能在 B 的函数体开始执行之前就已被创建,所以它不可能天然就是最终的 int 结果。

那么,等到 B 真正执行到 co_return 20,这个结果怎么办?

2. return_value():让 Promise 处理最终结果

例如在 Promise 中定义:

cpp 复制代码
struct promise_type {
    std::optional<int> result;

    void return_value(int value) {
        result = value;
    }
};

这里仅展示与结果有关的成员,并非完整 Promise。

当协程执行:

cpp 复制代码
co_return 20;

编译器按照协程协议调用当前实例的:

cpp 复制代码
promise.return_value(20);

于是结果保存在当前协程帧内的 Promise 对象中。

而编译器为什么知道要访问哪一个 Promise?这就回到了前面状态机的理解:执行/恢复代码本来就能够访问当前协程帧,因此自然能够访问帧中的 Promise 对象。 不存在通过全局变量名猜测 promise1 还是 promise2 的过程。

另外,return_value() 也可以采用共享状态等设计;并不是标准规定它必须把结果存在名为 result 的成员中。

对于不需要返回值的协程,可以使用:

cpp 复制代码
void return_void() noexcept {}

对应:

cpp 复制代码
co_return;

一个 Promise 类型不能同时声明 return_value 和 return_void 供同一个协程使用;要实现 Task<void>,通常单独处理这一情况。

3. 既然写进去了,怎么从外面读出来?

我们之前已经认识了:

cpp 复制代码
handle.promise();

所以可以在 Task 中自定义:

cpp 复制代码
int get() const {
    return handle.promise().result.value();
}

但是,这里立刻又遇到问题:调用 get() 时,协程真的已经执行到 co_return 了吗?

如果还没有,结果根本就不存在。因此,实际接口不能仅仅无条件读取成员。对于教学用的"只读取已完成结果"的 get(),可以先检查:

cpp 复制代码
if (!handle || !handle.done()) {
    throw std::logic_error("result not ready");
}

这只是接口契约的一种选择。更适合非阻塞查询的设计可以使用 try_get(),通过 std::optional 或显式状态表达"暂时没有结果"。协程未完成是正常运行状态,不应默认被当成执行失败。


九、函数体结束了,为什么协程帧还要保留?------final_suspend()

1. initial_suspend 与 final_suspend 在起点、终点呼应

我们已经知道 initial_suspend() 是函数体执行前的初始挂起点。那么与它相对应,协程函数体结束后还有一个:

cpp 复制代码
final_suspend()

例如 B 执行:

cpp 复制代码
co_return 20;

编译器先让 Promise 处理返回结果,之后进行必要的局部对象清理,再进入最终挂起处理(概念上相当于 co_await promise.final_suspend())。

这里需要分清两个概念:

  • 协程函数体执行完成:已经产生正常结果,或者结束于未捕获异常。
  • 协程实例真正被销毁:协程帧及其中的 Promise 对象被销毁,相关存储被释放。

二者不一定发生在同一个时刻。

2. 为什么 Task 常常返回 suspend_always?

cpp 复制代码
std::suspend_always final_suspend() noexcept {
    return {};
}

这个设计使协程执行完函数体后,停留在最终挂起点,继续保留协程帧。

好处非常直接:假设结果存在 Promise 中,外部还需要通过句柄读取。如果 B 刚执行完就销毁协程帧,那么先前得到的句柄就悬空了,handle.promise() 也无法再安全访问那个 Promise 对象。

使用 std::suspend_always 后,在有效句柄和正确同步的前提下,调用者可以看到:

cpp 复制代码
handle.done() == true;

但要注意:done() 判断的是是否停在最终挂起点,并不是一个通用的"正在运行 / 等待 I/O / 已取消"等状态枚举。 它要求句柄关联有效、协程处于挂起状态,不能把它当作任意线程都能无同步查询的原子状态。

另外,最终挂起的协程不能再次 resume()。

3. final_suspend() 能不能返回 suspend_never?

可以,例如:

cpp 复制代码
std::suspend_never final_suspend() noexcept {
    return {};
}

这意味着协程不保留在最终挂起点,而会继续完成清理、销毁协程帧。在某些不需要外部通过旧句柄读取 Promise 的设计中,它是可以使用的。

但对我们目前这个"Task 持有句柄,外部在完成后访问 Promise"的模型来说,简单地返回 suspend_never 会让句柄悬空,不能照搬。

最终挂起还有另一个重要用途:当 B 完成时,通知或直接恢复等待 B 的协程 A。 这就需要我们把前面的接口真正拼起来了。


十、两个协程怎么相互等待?从 A → B,再从 B → A

现在回到最开始的业务场景:协程 A 后面的计算需要依赖协程 B 的结果。

cpp 复制代码
Task<int> funB() {
    co_return 20;
}

Task<int> funA() {
    int value = co_await funB();
    co_return value + 10;
}

这里 funA() 返回的是 Task<int>,funB() 返回的也是 Task<int>。只有 co_await funB() 正常完成之后,A 才能取得 B 的最终 int 结果并继续运算。

如果 B 尚未完成,A 应该可以挂起自己,而不是把整个线程堵在原地。

1. 第一个问题:Task 本来不是等待器,为什么能 co_await?

我们可以让 Task 提供 operator co_await():

cpp 复制代码
struct Awaiter {
    bool await_ready();
    std::coroutine_handle<> await_suspend(
        std::coroutine_handle<> current);
    int await_resume();
};

Awaiter operator co_await() {
    return Awaiter{/* 对应协程的句柄 */};
}

这段代码是接口结构示意。编译器从 Task 获得 Awaiter 后,按照之前认识的三个等待器接口处理 co_await Task。

Task 负责关联协程、管理所有权;Awaiter 负责让另一个协程等待它。二者职责可以分开。

2. 第二个问题:A 挂起后,B 怎么知道要恢复 A?

假设 A 执行:

cpp 复制代码
int value = co_await funB();

funB() 先创建并返回一个关联 B 的 Task。A 的等待器因此能够持有 B 的句柄。

如果 await_ready() 返回 false,编译器会把A 的句柄 传给 await_suspend():

cpp 复制代码
std::coroutine_handle<> await_suspend(
    std::coroutine_handle<> handleA) noexcept {

    handleB.promise().continuation = handleA;
    return handleB;
}

这里:

  1. handleA:当前正在等待的协程 A,由编译器传入。
  2. handleB:等待器已经保存的目标协程 B 句柄。
  3. handleB.promise().continuation = handleA:把"谁在等 B"记录到 B 的 Promise 对象中。
  4. return handleB:利用对称转移(Symmetric Transfer),让执行权从 A 交给 B。

continuation 是我们自己添加的 Promise 成员,不是标准强制提供的字段。

现在,A → B 已经建立起来了。

需要注意:这里假设 B 是一个还没有启动、正停在初始挂起点的惰性协程。 如果 B 已经运行或有并发等待者,就不能不加判断地用这个简化方案直接再次启动它。

3. 第三个问题:B 完成后怎么恢复 A?

B 完成时会进入 final_suspend()。

如果这个接口只是返回:

cpp 复制代码
std::suspend_never final_suspend() noexcept { return {}; }

那么它既不会自动读取 continuation,也不会自动恢复 A,而且可能直接清理掉 B 的协程帧,导致 A 无法安全读取结果。

于是,我们可以为 B 的最终挂起点设计一个自定义等待器:

cpp 复制代码
struct FinalAwaiter {
    bool await_ready() const noexcept {
        return false;
    }

    std::coroutine_handle<> await_suspend(
        Handle handleB) const noexcept {
        auto handleA = handleB.promise().continuation;
        return handleA ? handleA : std::noop_coroutine();
    }

    void await_resume() const noexcept {}
};

FinalAwaiter final_suspend() noexcept {
    return {};
}

这里有一个很有意思的地方:final_suspend() 返回的仍然是可等待对象,所以依然遵循 await_ready → await_suspend → await_resume 的一般协议。

不过,进入最终挂起后,不能再通过 resume() 使这个协程继续前进。因此对于这个 FinalAwaiter,正常情况下并不会真的通过恢复 B 来执行它的 await_resume()。

在 await_suspend(handleB) 中,首先找到 B 的 Promise 对象,再取出之前记录的 A 的句柄并返回。于是:

text 复制代码
A 的 await_suspend() 返回 B 的句柄
        ↓
对称转移:A → B
        ↓
B 的函数体完成,保存结果
        ↓
B 的 final_suspend() 返回自定义等待器
        ↓
FinalAwaiter.await_suspend() 返回 A 的句柄
        ↓
对称转移:B → A

这里的关键结论是:B 已经执行完函数体,但仍然挂起在最终挂起点;A 重新获得执行权。 B 的协程帧还在,因此 A 仍然能读取 B 的 Promise 中保存的结果。

4. 第四个问题:A 恢复了,int value 究竟从哪里来?

这正是我们之前学习的 await_resume() 的用途。

在 A 的 co_await funB() 中,等待器可以这样写:

cpp 复制代码
int await_resume() {
    return handleB.promise().result.value();
}

因此:

cpp 复制代码
int value = co_await funB();

最终可以理解为:等待表达式正常完成后,编译器调用等待器的 await_resume(),并把它返回的值赋给 value。

前面我们专门讨论过一个很容易产生疑惑的地方:明明之前还有 Task::get(),现在为什么又出现 await_resume()?

实际上,在我们目前的调用链中可以这样区分:

text 复制代码
普通函数 main
    ↓
创建并启动 Task A
    ↓
A 内部 co_await Task B
    ↓
A 的 await_resume() 读取 B 的结果(A 的中间计算依据)
    ↓
A 继续计算,co_return 最终结果
    ↓
main 中 taskA.get() 读取 A 的最终结果

这只是当前示例的使用分工,而不是语言规定 get() 只能读最外层、await_resume() 只能读中间值。它们都可以访问同一类 Promise 结果,区别在于:

  • get() 是我们自己定义的普通成员函数,调用时机和行为由接口约定决定。
  • await_resume() 是编译器根据 co_await 协议调用的接口 ,其返回值成为 co_await 表达式的结果。

到这里我们便把前面零散学习的 Task、Awaiter、Promise、协程句柄以及 final_suspend() 串成了一条真正的协程协作链。


十一、协程抛异常怎么办?------unhandled_exception()

我们之前只考虑 B 正常执行:

cpp 复制代码
Task<int> funB() {
    co_return 20;
}

但如果 B 在中途出错:

cpp 复制代码
Task<int> funB() {
    throw std::runtime_error("read failed");
    co_return 20;
}

那么 B 不会正常执行到 co_return 20,也就没有可以读取的正常结果。

1. 编译器如何处理协程中未捕获的异常?

我们仍然沿用之前的状态机心智模型:编译器在协程函数体的执行逻辑中安排异常处理。当异常未被协程函数体内部捕获时,会调用当前 Promise 对象提供的:

cpp 复制代码
void unhandled_exception() noexcept {
    exception = std::current_exception();
}

其中:

cpp 复制代码
std::exception_ptr exception;

这里的 std::exception_ptr 是一种用于保存和以后重新抛出异常的标准库类型 ,不是普通的 std::exception* 原始指针。

需要强调:如果 B 内部已经通过 try-catch 处理了异常,就不会因为这个异常去调用 unhandled_exception()。

2. A 怎么知道 B 失败了?

之前我们在 A 的等待器中写过:

cpp 复制代码
int await_resume() {
    return handleB.promise().result.value();
}

现在需要增加异常处理:

cpp 复制代码
int await_resume() {
    auto& promise = handleB.promise();

    if (promise.exception) {
        std::rethrow_exception(promise.exception);
    }

    return promise.result.value();
}

于是 A 可以这样使用:

cpp 复制代码
Task<int> funA() {
    try {
        int value = co_await funB();
        co_return value * 2;
    } catch (const std::exception& e) {
        std::cout << e.what() << '\n';
        co_return -1;
    }
}

这里可能又出现一个疑问:为什么前面保存的是 std::exception_ptr,后面 catch 却使用 const std::exception&?

因为:

text 复制代码
B 抛出 std::runtime_error
    ↓
std::current_exception() 保存当前异常 → exception_ptr
    ↓
B 完成异常处理流程并进入 final_suspend()
    ↓
A 恢复,在 await_resume() 中调用 std::rethrow_exception()
    ↓
原异常重新进入正常的 C++ 异常传播与匹配机制
    ↓
A 通过 catch (const std::exception& e) 捕获

std::runtime_error 是 std::exception 的派生类,因此可以被 const std::exception& 捕获。catch 捕获的是重新抛出的异常对象 ,不是 exception_ptr 这个保存异常的对象。

所以我们可以归纳:

return_value() 处理正常结果,unhandled_exception() 处理未捕获异常,而等待方的 await_resume() 可以统一接收正常结果或者重新抛出异常。


十二、协程真正是被谁恢复的?------handle.resume() 与 EventLoop

1. resume() 并不是重头再运行一遍函数

到这里,我们一直在说"协程恢复",那么具体怎么恢复?

C++20 提供:

cpp 复制代码
handle.resume();

它会使这个句柄关联的合法、处于可恢复挂起状态的协程实例继续执行。按照之前的状态机模型,就是用这个实例的协程帧继续执行对应的恢复逻辑。

这里并不是:

cpp 复制代码
fun(); // 再次调用、重新创建协程实例

而是:

text 复制代码
同一个协程实例
    ↓
第一次 resume → 执行到下一个真正的挂起点
    ↓
之后再次 resume → 从原来的挂起点继续执行
    ↓
最终进入 final_suspend → 不能再 resume

例如:

cpp 复制代码
Task<int> manual() {
    std::cout << "第一阶段\n";
    co_await std::suspend_always{};
    std::cout << "第二阶段\n";
    co_return 7;
}

如果它的 initial_suspend() 返回 std::suspend_always,那么:

cpp 复制代码
auto task = manual();
// 此时连"第一阶段"都尚未打印

task.start();
// 从初始挂起点开始执行;打印"第一阶段"后再次挂起

task.resume();
// 从中途挂起点继续;打印"第二阶段",最终完成

这里的 start() / resume() 是我们后面要实现的 Task 普通成员函数 ,内部最终调用句柄的 resume()。不是标准强制规定 Task 必须提供的接口。

2. 实际网络程序通常不会硬编码"再 resume 一次"

上面的例子只是为了演示两个挂起点。

对于异步 I/O,协程到底什么时候可以继续,通常取决于外部事件。我们之前学习过 Reactor,所以很容易将它联系起来:

cpp 复制代码
Task<int> readData(int fd) {
    int n = co_await asyncRead(fd);
    co_return n;
}

假设 asyncRead(fd) 的等待器在 await_suspend() 中,把当前协程句柄与 fd 的等待事件关联起来:

text 复制代码
协程执行到 co_await asyncRead(fd)
    ↓
发现当前不能直接完成读取
    ↓
await_suspend(handleA)
    ↓
把 fd 与 handleA 的等待关系登记到 EventLoop
    ↓
协程 A 挂起;EventLoop 线程处理其他事件
    ↓
epoll_wait() 返回相应就绪事件
    ↓
EventLoop 找到 handleA
    ↓
handleA.resume()
    ↓
协程 A 继续,执行 await_resume() 和后续逻辑

概念代码可能是:

cpp 复制代码
// EventLoop 的处理逻辑示意,并非完整的 epoll 实现
for (const auto& event : readyEvents) {
    auto handle = findWaitingCoroutine(event.fd);
    if (handle) {
        handle.resume();
    }
}

这里,resume() 负责恢复,EventLoop 负责决定什么时候恢复。

需要特别注意:epoll 报告某个 fd 可读,意味着某种可读条件已经满足,不保证整个业务读取操作已经完全完成 。实际等待器仍然要正确处理非阻塞读取、部分读取、EAGAIN、错误以及再次等待等问题。

更不能因为协程挂起后还没结果,就让线程忙等:

cpp 复制代码
while (!handle.done()) {
    // 不停轮询,通常不是事件驱动等待的正确实现
}

如果协程需要同一个线程的 EventLoop 来恢复,这种忙等甚至可能阻止它取得继续运行的机会。

3. 也不是所有恢复都必须显式调用 resume()

我们之前讨论了对称转移:

cpp 复制代码
std::coroutine_handle<> await_suspend(
    std::coroutine_handle<> current) noexcept {
    return target;
}

这里返回另一个有效协程句柄,本身就是一种把控制权交给目标协程的协议行为。编译器负责完成相应转移,不需要我们再写一次 target.resume()。

因此,典型的恢复来源可以是:

  • 显式恢复 :例如 EventLoop 处理事件后调用 handle.resume()。
  • 等待器的对称转移:例如 A 的等待器返回 B,或 B 的最终等待器返回 A。

二者都让目标协程沿着原来的控制流继续推进,只是控制权交接的方式不同。


十三、最后一个容易遗漏的部分:谁来销毁协程帧?

1. 单纯销毁句柄变量,不等于销毁协程实例

之前我们已经认识了:

cpp 复制代码
std::coroutine_handle<> h;

它只是句柄对象。标准句柄本身不具备自动销毁协程帧的独占所有权语义。

一个句柄还可能被复制:

cpp 复制代码
auto h2 = h;

因此,某个句柄变量离开作用域,不意味着关联的协程帧就被自动销毁。真正销毁协程帧的是:

cpp 复制代码
h.destroy();

该操作必须满足句柄有效、目标协程处于允许销毁的挂起状态等前提。

2. 为什么 Task 通常采用 RAII 独占所有权?

既然 Task 本来就保存协程句柄,我们自然会联想到熟悉的 std::unique_ptr:

让一个明确的对象拥有资源,并且由这个对象在适当时候完成释放。

比如:

cpp 复制代码
~Task() {
    if (handle_) {
        handle_.destroy();
    }
}

于是,我们必须保证不能有两个 Task 都认为自己拥有同一个协程帧的销毁权 ,否则先后析构会重复 destroy()。

所以,常见的独占式 Task 要禁止拷贝:

cpp 复制代码
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;

同时支持移动,转移所有权后清空原来的句柄:

cpp 复制代码
Task(Task&& other) noexcept
    : handle_(std::exchange(other.handle_, {})) {}

这里的 std::exchange(other.handle_, {}) 可以理解为:取走原句柄,同时把源对象句柄置空。

注意:句柄可以复制,不代表拥有句柄的 Task 也应该复制。 关键在于我们赋予 Task 的所有权语义。

3. 一个更容易被忽略的问题:co_await 临时 Task 的所有权

考虑:

cpp 复制代码
int value = co_await funB();

这里 funB() 返回一个临时 Task<int>。如果我们把它的句柄随便复制到一个 Awaiter 中,再让两个对象都负责销毁,就会造成重复释放。

因此,后面的完整代码会采用一种更明确的设计:

operator co_await() && 从右值 Task 中移走句柄,把被等待协程帧的独占所有权转移给 Awaiter;等待结束后,Awaiter 析构并释放被等待协程帧。

cpp 复制代码
Awaiter operator co_await() && noexcept {
    return Awaiter{std::exchange(handle_, {})};
}

这里的 && 表示它只用于右值 Task。因此我们可以写 co_await funB();若持有一个左值 Task,则需要显式设计对应的等待和所有权接口,而不是假装所有情况都已经支持。

这种设计把两个所有权层次区分清楚了:

text 复制代码
main 持有 Task A → 拥有协程帧 A
A 的 co_await 表达式持有 Awaiter B → 拥有协程帧 B
B 最终挂起后,恢复 A
A 取出 B 的结果,Awaiter B 被销毁 → 释放协程帧 B
A 最终挂起;main 读取 A 的结果
Task A 析构 → 释放协程帧 A

4. RAII 不是"任何时候都可以随便销毁"

如果协程还挂在某个异步 I/O 上,EventLoop 仍然保存着它的句柄,那么直接让 Task 析构、销毁协程帧,而不取消或解除注册,就可能在下一次事件到来时恢复一个已经释放的协程。

因此,生产环境还需要严谨的取消、注销、同步和所有权设计。

RAII 解决的是"由谁负责释放",但"什么时候安全释放",仍然是协程生命周期必须处理的问题。


十四、把所有零件拼起来:一份真正能够运行的 Task

到这里,我们已经认识了各个接口为什么存在。现在再看完整代码,它们就不再是一堆相互孤立的函数名。

我们希望验证三个场景:

  1. A 等待 B :B 返回 20,A 取得结果后再加 10,最终得到 30。
  2. 中途手动挂起与恢复 :直观感受 handle.resume() 的作用。
  3. 异常传递 :B 的异常保存到 Promise,外层再通过 get() 捕获。

示例范围: 单线程、惰性 Task、右值 Task 的单次等待、没有并发事件注册和取消。顶层任务采用显式 start();get() 只读取已经完成的结果,不承担自动调度职责。完整代码在本博客附带的 full_task_example.cpp 中也有单独副本。

cpp 复制代码
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <utility>

template <typename T>
class Task {
public:
    struct promise_type;
    using Handle = std::coroutine_handle<promise_type>;

private:
    Handle handle_{};
    bool started_ = false;

public:
    explicit Task(Handle handle) noexcept : handle_(handle) {}
    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;

    Task(Task&& other) noexcept
        : handle_(std::exchange(other.handle_, {})),
          started_(std::exchange(other.started_, false)) {}

    Task& operator=(Task&& other) noexcept {
        if (this != &other) {
            if (handle_) handle_.destroy(); // 前提:旧协程可被安全销毁
            handle_ = std::exchange(other.handle_, {});
            started_ = std::exchange(other.started_, false);
        }
        return *this;
    }

    ~Task() {
        if (handle_) handle_.destroy();
    }

    void start() {
        if (!handle_ || started_) {
            throw std::logic_error("Task cannot be started");
        }
        started_ = true;
        handle_.resume();
    }

    // 演示手动挂起的恢复;实际异步操作应在等待条件满足后恢复
    void resume() {
        if (!handle_ || !started_ || handle_.done()) {
            throw std::logic_error("Task cannot be resumed");
        }
        handle_.resume();
    }

    bool done() const {
        if (!handle_) return false;
        // 前提:此时协程是挂起的,而不是正在别的线程上运行
        return handle_.done();
    }

    T get() {
        if (!handle_ || !handle_.done()) {
            throw std::logic_error("Task result is not ready");
        }
        auto& p = handle_.promise();
        if (p.exception_) std::rethrow_exception(p.exception_);
        return std::move(p.result_.value());
    }

    struct Awaiter {
        Handle child{}; // 独占被等待协程帧
        explicit Awaiter(Handle h) noexcept : child(h) {}
        Awaiter(const Awaiter&) = delete;
        Awaiter& operator=(const Awaiter&) = delete;
        Awaiter(Awaiter&& other) noexcept
            : child(std::exchange(other.child, {})) {}
        Awaiter& operator=(Awaiter&&) = delete;
        ~Awaiter() {
            if (child) child.destroy();
        }

        bool await_ready() const noexcept {
            return child.done();
        }

        std::coroutine_handle<> await_suspend(
            std::coroutine_handle<> parent) const noexcept {
            child.promise().continuation_ = parent;
            return child; // A -> B
        }

        T await_resume() {
            auto& p = child.promise();
            if (p.exception_) std::rethrow_exception(p.exception_);
            return std::move(p.result_.value());
        }
    };

    // 只能等待右值 Task:将独占句柄转移到 Awaiter
    Awaiter operator co_await() && noexcept {
        return Awaiter{std::exchange(handle_, {})};
    }

    struct promise_type {
        std::optional<T> result_;
        std::exception_ptr exception_;
        std::coroutine_handle<> continuation_{};

        Task get_return_object() noexcept {
            return Task{Handle::from_promise(*this)};
        }

        std::suspend_always initial_suspend() const noexcept { return {}; }

        void return_value(T value) { result_.emplace(std::move(value)); }

        void unhandled_exception() noexcept {
            exception_ = std::current_exception();
        }

        struct FinalAwaiter {
            bool await_ready() const noexcept { return false; }
            std::coroutine_handle<> await_suspend(Handle child) const noexcept {
                auto parent = child.promise().continuation_;
                return parent ? parent : std::noop_coroutine(); // B -> A
            }
            void await_resume() const noexcept {}
        };

        FinalAwaiter final_suspend() const noexcept { return {}; }
    };
};

Task<int> child() {
    std::cout << "B: calculate 20\n";
    co_return 20;
}

Task<int> parent() {
    std::cout << "A: start\n";
    int value = co_await child();
    std::cout << "A: received " << value << '\n';
    co_return value + 10;
}

Task<int> manual() {
    std::cout << "manual: stage 1\n";
    co_await std::suspend_always{};
    std::cout << "manual: stage 2\n";
    co_return 7;
}

Task<int> fail() {
    throw std::runtime_error("B failed");
    co_return 0;
}

Task<int> propagate() {
    int value = co_await fail();
    co_return value;
}

int main() {
    auto a = parent();
    std::cout << "main: A created\n";
    a.start();
    std::cout << "main: A result = " << a.get() << '\n';

    auto m = manual();
    m.start();
    std::cout << "main: manual done? " << m.done() << '\n';
    m.resume();
    std::cout << "main: manual result = " << m.get() << '\n';

    auto e = propagate();
    e.start();
    try {
        (void)e.get();
    } catch (const std::exception& ex) {
        std::cout << "main: caught " << ex.what() << '\n';
    }
}

编译与运行

bash 复制代码
g++ -std=c++20 -Wall -Wextra -pedantic full_task_example.cpp -o task_demo
./task_demo

预期输出(已经通过 C++20 编译及运行检查):

text 复制代码
main: A created
A: start
B: calculate 20
A: received 20
main: A result = 30
manual: stage 1
main: manual done? 0
manual: stage 2
main: manual result = 7
main: caught B failed

1. 先从 main() 看完整调用过程

cpp 复制代码
auto a = parent();

这里创建协程实例 A:协程帧 A → Promise A → get_return_object() → Task A → 初始挂起 。因此 main: A created 会先打印,而 A: start 尚未执行。

接着:

cpp 复制代码
a.start();

start() 内部调用 handle_.resume()。协程 A 开始执行,打印 A: start。

接下来:

cpp 复制代码
int value = co_await child();

创建协程 B,它也首先停在初始挂起点。A 从 B 的右值 Task 转移独占所有权到 Awaiter B;await_ready() 为 false,await_suspend() 记录 A 的句柄并返回 B 的句柄,于是 A → B。

B 执行:

cpp 复制代码
co_return 20;

通过 return_value(20) 保存结果,在 final_suspend() 中返回 A 的句柄,于是 B → A。B 自己停留在最终挂起点,协程帧仍然有效。

A 的等待器随后执行 await_resume(),从 B 的 Promise 中读出 20。等待表达式结束后,Awaiter B 析构,调用 destroy() 销毁 B 的协程帧。A 继续执行:

cpp 复制代码
co_return value + 10;  // 30

A 的 Promise 保存 30,A 进入最终挂起。由于 A 没有上层等待协程,最终等待器返回 std::noop_coroutine(),控制权最终返回先前调用 start() 的执行上下文。

然后 main() 调用:

cpp 复制代码
a.get();  // 30

从 A 的 Promise 中获取最终结果。等 a 离开作用域,Task A 析构并销毁 A 的协程帧。

2. 再看手动挂起:为什么不是重新调用整个函数?

cpp 复制代码
auto m = manual();
m.start();
std::cout << m.done() << '\n';
m.resume();

由于函数体中存在:

cpp 复制代码
co_await std::suspend_always{};

所以 m.start() 只会执行到第一次中途挂起,done() 此时返回 false。之后 m.resume() 使用同一个协程帧,从该挂起点继续执行,而不是把函数从开头再执行一遍。

这个例子只是为了验证底层机制。真正等待网络 I/O 时,恢复时机应由 EventLoop 等待条件决定,而不是不看事件就随意调用 resume()。

3. 最后看异常路径:为什么 Promise 还要保存 exception_ptr?

fail() 抛出异常后,执行当前 Promise 的 unhandled_exception(),保存异常指针;再通过最终挂起机制恢复等待它的 propagate()。

propagate() 的等待器执行 await_resume() 时调用 std::rethrow_exception()。如果 propagate() 自己也没有捕获,那么它同样会进入自己的 unhandled_exception(),将异常保存到自己的 Promise 中。

最后:

cpp 复制代码
try {
    (void)e.get();
} catch (const std::exception& ex) {
    std::cout << ex.what() << '\n';
}

外部调用者通过 get() 重新接收到异常。因此,正常结果和异常都可以沿协程依赖链向上传递。


十五、再看一次完整协程生命周期

现在我们可以把完整的知识点压缩为下面这张表。

我们遇到的问题 对应机制 核心认识
编译器怎么知道它是协程? co_await、co_yield、co_return 编译阶段识别并改造控制流
多次调用如何维护独立进度? Coroutine Frame 每次调用有自己的协程状态
编译器如何与我们自定义类型合作? promise_type 通过一套约定接口管理协程执行协议
调用协程函数先拿到什么? get_return_object() 返回自定义对象,不是立即拿到最终计算结果
Task 怎么关联协程实例? coroutine_handle、from_promise() 用句柄关联具体实例
函数体是否马上执行? initial_suspend() 控制初始挂起
中途需不需要停? await_ready() 能否跳过挂起处理
停下以后谁负责安排? await_suspend() 建立等待关系、注册事件、对称转移等
等待完成后结果从哪来? await_resume() 给出 co_await 表达式结果或抛出异常
最终结果如何保存? co_return、return_value() 由当前 Promise 处理结果
没有返回值怎么办? return_void() 处理 co_return;
发生未捕获异常怎么办? unhandled_exception() 保存或处理异常
函数体结束后帧要保留吗? final_suspend() 决定最终挂起行为,也可安排恢复等待者
协程是否完成? handle.done() 仅判断是否处于最终挂起点
协程怎样恢复? handle.resume() 恢复有效、可恢复的挂起协程
协程帧怎样释放? handle.destroy()、RAII 区分句柄、所有权与实际资源销毁

最后,几个特别容易混淆的地方

第一,协程暂停的是协程的执行过程,不是让整个线程自动暂停。 co_await 不负责凭空创造并行计算或者异步 I/O。

第二,Task 与协程帧不是同一个东西。 Task 可以持有句柄并负责生命周期;协程帧才保存这次协程实例执行所需的状态。

第三,get_return_object() 与 co_return 不是一次"返回"。 前者创建调用者收到的返回对象;后者触发最终结果处理。

第四,await_resume() 和 handle.resume() 不是同一个接口。 一个负责等待表达式的结果,一个负责恢复协程的执行。

第五,await_ready() == false 不等于协程一定会长期挂起。 具体还要结合 await_suspend() 的返回类型与执行行为。

第六,done() == true 不代表协程帧已经销毁。 在采用最终挂起保留协程帧的设计中,函数体已经完成,帧却仍然存在;反过来,帧被销毁后更不能继续拿旧句柄查询 done()。

第七,final_suspend() 返回 suspend_never 并不会自动恢复等待者。 要恢复 A,需要我们显式安排通知,或者在自定义最终等待器中返回 A 的句柄等。

第八,"能够编译运行的最小 Task"不等于生产级异步运行库。 真正用于 Reactor / epoll 时,还需要继续解决启动策略、取消注册、并发完成、丢失唤醒、多等待者、异常与结果的消费语义、协程帧的所有权边界等问题。


结语:理解协程,核心不是背接口,而是看懂执行权如何流动

回顾整条学习过程,我们最开始只是希望:在等待 I/O 时,不必让线程始终阻塞,同时又能像编写普通函数一样,把依赖关系按顺序表达出来。

C++20 提供的答案,并不是让普通函数突然拥有神奇的"任意暂停"能力,而是:

编译器把协程函数转换成能够分阶段继续执行的代码;协程帧保存每个实例独立的执行状态;等待器决定挂起和恢复时的控制权交接;Promise 对象参与处理结果、异常与生命周期;Task 则把这些底层机制组织成可用的协程抽象。

最后我们真正得到的,不只是一个 Task<int> 类,更重要的是一条完整的逻辑链:

text 复制代码
协程函数的定义
    ↓
编译器控制流转换
    ↓
运行时创建独立协程实例
    ↓
Task + coroutine_handle 定位实例
    ↓
initial_suspend 控制初始执行
    ↓
co_await + Awaiter 管理等待
    ↓
resume / 对称转移继续执行
    ↓
co_return / unhandled_exception 处理结果
    ↓
final_suspend 交接最终控制权
    ↓
Task / Awaiter 通过 RAII 安全释放协程帧

理解到这一步,再回头阅读第三方协程库,或者尝试给自己熟悉的 Reactor 网络库增加 C++20 协程支持,就不再是单纯地记住几个接口名,而是能够沿着"协程实例在哪里、执行状态由谁保存、当前为什么挂起、谁能恢复它、结果在哪里、最后谁释放资源"这一整条线,逐步推导整个设计。


相关推荐
咩咩啃树皮1 小时前
JavaScript 中 this 指向为什么总在变?
开发语言·前端·javascript
程序员清风1 小时前
算法题中的边界条件、测试与调试
开发语言·算法·kotlin
NoteStream1 小时前
【C语言入门10】指针(1)
1024程序员节
晚风吹北巷1 小时前
Linux复习篇(自用)
linux·运维·服务器·1024程序员节
划破黑暗的第一缕曙光1 小时前
[C++]:4.模板
c++·template·模板·函数模版·类模版
砚底藏山河1 小时前
python量化实操:冷热分层存储
java·开发语言·python·金融
十五年专注C++开发1 小时前
pybind11:一个跨C++和Python平台的编程库
开发语言·c++·python·pybind11
艾络迅Acceleronix2 小时前
环境监测终端:STM32+EG800Z AT+ 艾络迅平台实战|多传感器采集联动全流程
c语言·stm32·单片机·嵌入式硬件·物联网·1024程序员节
CodeYp20192 小时前
第11讲:stack 和 queue
开发语言·c++·queue·stack