C++协程入门实践

C++ 协程入门实践

上一节我们从顶层设计的角度学习了 C++ 的设计理念,而本节则从实践的角度进行 C++ 协程的入门。与网上大部分的 C++ 协程入门博客类似,本文也会讲述 C++20 为协程设计的关键字以及方法并给出示例,但本文会尽力照顾初学者给出十分详细的讲解,由于知识点过于庞大,如有错误及时在 QQ 群内反馈。

知识点

  • C++ 协程之 promise 编程

  • C++ 协程之 awaiter 编程

  • C++ 协程之实战演练

C++ 协程的定义和执行

如果我们在 C++ 的函数体里添加了 co_await,co_return 或者 co_yield 关键字(必须有其一),那么该函数即视为协程。而被视作协程的函数也会受到诸多限制,例如不可以使用 return 语句,构造函数、析构函数、main 函数和 constexpr 函数均不能成为协程。

每一个协程函数都对应着一个协程对象,而协程对象与下述三种类型的数据相关联。

  • promise 对象。注意这里的 promise 仅仅是个概念名词,和 C++ 异步编程里的 std::promise 没有任何关联。协程的构造和运行需要编译器做很多幕后工作,而 promise 是编译器直接暴露给用户的一个对象,其与协程的运行状态相关联,用户可以通过 promise 的预定义方法实现调度协程、获取运行结果和异常捕获。

  • 协程句柄。协程句柄本质是一个指针,通过协程句柄用户可以访问对应的 promise 以及恢复和销毁协程。

  • 协程状态。协程为了实现随时暂停执行并随意恢复的功能,必须在内存空间中保存当前的协程状态,主要涉及协程当前运行位置(便于恢复时继续运行)以及生命周期未结束的局部变量。

上述三种对象可合称为协程帧,内存分布如下图所示,这也很好的解释了为什么协程句柄和 promise 可以互相转换以及可以利用协程句柄执行协程的恢复和销毁。

协程帧

通常激活帧被称作一块保留了函数运行状态的内存,对于普通函数激活帧就是栈帧,而对于协程激活帧由两部分组成:

  • 栈帧。与普通函数栈帧结构类似,在调用协程时产生栈帧,协程结束返回给调用者时释放栈帧。

  • 协程帧。用于记录协程中间状态便于协程暂停和恢复执行,主要包含上述介绍的三种与协程相关的数据对象。关于协程帧有两点需要注意:

    • 在创建协程时由编译器分配内存,但注意该内存需要用户手动释放,否则会造成内存泄漏。

    • 通常采用堆分配方式构建协程帧。c++ 协程提案中有一些规定,如果编译器能够证明协程的生命周期确实严格嵌套在调用者的生命周期内,则允许从调用者的激活帧中分配协程帧的内存。

综上分析,调用者调用协程会执行大概两步:第一步像调用正常函数一样构造栈帧,第二步编译器分配内存构造协程帧。这也解释了为何 C++ 协程的激活帧会分为两部分,第一步操作符合严格嵌套关系所以可被放在栈空间,第二步操作不符合严格嵌套所以利用额外的堆空间存储。

Promise

如果不深究 C++ 协程的技术细节而从用户使用角度来讲的话协程可被划分为 promise 和 awaiter,前者我们有所了解,后者主要是实现协程调度逻辑。本小节我们重点讲述 promise,下面给出一个 C++ 协程的样例程序(注意编译器编译 C++ 协程代码需要添加-fcoroutines -std=c++20 参数)。

该程序演示了除了 awaiter 之外大部分的 C++ 协程知识点,运行结果如下:

下面我们一步步对这个程序进行剖析。

run:化身为协程的函数

样例中的 run 函数因为在函数体包含了协程关键字,所以被当作协程处理,其返回值也较为特殊,并非与 return 语句返回值的类型一致,而是一个用户自定义的类,注意这是 C++ 协程的规定。

UserFacing:面向用户的对象

读者应该注意到样例程序定义了一个 Task 类并且 run 协程返回值也是 Task 类型。读者可以理解为这是 C++ 协程规定用户需要定义的对象(后续用 UserFacing 代指),因为对该类无类方法上的限制,仅需要提供该类的 promise_type,满足该定义后便可以作为协程函数的返回值,注意协程函数必须以满足该定义的类型作为返回值

注意在协程中为 UserFacing 指定 promise_type 类型有如下三种方式(所有方式都必须让 promise_type 对外部可见,即访问修饰符为 public):

上述提到的第三种方法即使用 coroutine_traits 的优点有两种:

  • UserFacing 是一个我们无法修改的类。比如我们可以指定一个 C++ 标准库的类为 UserFacing,甚至是 int,这只需要我们特化 coroutine_traits 模板为该类型指定 promise_type 即可。

  • coroutine_traits 可以提供更细致的 promise 类型指定

上述第二点需要从协程参数来理解(注意 C++ 协程的参数与普通函数的参数没有差别),假设我们有四个 run 协程参数不一致,如下:

我们现在有个需求,为 1 号协程指定关联的 promise_type 为 promise1,2 号协程关联 promise2,其余协程关联 promise3,这个需求显然无法通过在 UserFacing 类里指定 promise_type 来解决,但可以使用 coroutine_traits 轻松应对,如下:

约束 1 表示任何返回值为 UserFacing 且参数为空的协程关联的 promise_type 为 promise1,约束 2 表示任何返回值为 UserFacing 且参数按顺序为 int 和 float 的协程关联的 promise_type 为 promise2,约束 3 使用变参模板表示任何返回值为 UserFacing 且参数任意或者为空的协程关联的 promise_type 为 promise3。关于上述 coroutine_traits 的使用有两点需要注意:

  • 如果协程与某一个 coroutine_traits 约束相匹配,那么该协程返回类型内部即使定义了 promise_type 也不再起作用。

  • 依据 C++ 模板的匹配规则变参模板的匹配优先级靠后。例如协程 2 既满足约束 2 又满足约束 3,但约束 2 会被优先匹配到。

💡为何 C++ 协程需要单独定义一个面向用户的对象? 因为编译器对 promise 做了诸多限制,且 promise 持有协程运行的数据,而面向用户的对象可以让用户自定义如何去操作 promise 的数据,数据与处理逻辑分离开来算是设计上的解耦。 从后续学习中我们也可以看到协程本身是需要存储状态以及数据的,UserFacing 像是为调用者提供了一个入口来操纵协程。

coroutine_handle:外部用以操作协程的句柄

coroutine_handle 本质上是一个指针,一般是通过调用 promise 的方法获得,通过上节的分析读者应该理解了为何 coroutine_handle 可以和 promise 互相转化,当用户拿到协程句柄后可以使用下述方法操作协程:

  • handle.promise()。通过该方法可以从协程句柄获取 promise。

  • handle.done()。该方法用于判定协程是否执行结束。

  • handle.resume()。该方法可以使暂停的协程继续运行,注意如果此时 handle 关联的协程执行结束,调用该方法会产生 core dump。

  • handle.destroy()。该方法负责协程帧内存的回收,用户需要避免重复调用。

在使用上 coroutine_handle 是一个类模板,定义如下,类模板参数为 promise_type。

用户也可直接使用 coroutine_handle<>即模板参数默认为空,这类似于 void*指针,可用于存储任意类型的 promise,但此时无法调用 handle.promise() 方法,用户若想获取存储的 promise 需要使用类型转换。

promise:协程的核心

promise 作为 C++ 协程的核心数据结构,用户不仅可以通过 promise 访问到编译器为协程分配的内存,还可以用 promise 存储协程运行时的临时值。

promise 的构造

当用户调用一个协程时,编译器会寻找该协程绑定的 promise_type,然后分配协程帧并将 promise_type 对应的 promise 对象构造在协程帧上,此过程涉及到 promise 构造函数的选取。

实际上 C++ 协程为构造 promise 对象提供了很大的灵活性,我们可以为 promise 类指定多个构造函数,而编译器会按照如下顺序选择构造函数:

  1. 如果协程的参数列表与 promise 的某一构造函数的参数列表相匹配(不用完全匹配,相同位置不同类型只要可转换即可),则选择该构造函数。

  2. 步骤 1 匹配失败,此时如果 promise 存在参数为空的构造函数,则选择该构造函数。

  3. 步骤 2 匹配失败,此时编译报错。

样例程序的 run 协程参数列表为空,按规则应该匹配参数为空的 promise 构造函数,而编译器生成的默认构造函数符合要求。

💡C++ 协程设计为何要将协程的参数列表与 promise 构造函数关联起来? 因为协程不仅可以是普通函数,还可以是类的成员函数。读者应该了解 C++ 中对象调用成员函数都会隐式的传递 this 指针,而在编译器视角看成员函数的第一个参数也是类指针,用户不需要显式的添加。

同理,协程作为成员函数时参数列表也不需要添加类指针,但此时编译器构造协程对象会传递 this 指针,所以 promise 的构造函数参数需要带有类指针,这样协程运行过程中才可以通过类指针访问类成员和方法。示例代码如下:

上述 promise_type 的构造函数也可以写成下述形式接收任意参数类型:

get_return_object

用户调用协程时获取的 UserFacing 对象是编译器通过 promise 的 get_return_object 函数构造出来的,该函数参数为空,返回类型需要与协程的返回类型一致。

注意样例程序中该函数调用了标准库提供的一个方法:

通过调用 std::coroutine_handle<promise_type>::from_promise(promise_type&) 方法并传入 promise_type 对象的引用(引用类型需要与方法的模板参数保持一致)可以获得 promise 对象对应的 coroutine_handle,通常的做法会将 coroutine_handle 作为 UserFacingd 构造函数的参数,这样 UserFacing 便可以访问 promise 持有的数据(注意设置 promise 数据和方法对 UserFacing 的访问可见性)。

initial_suspend

前文我们提到 C++ 为协程设计了多个调度点,第一个调度点便是在协程创建时,由 initial_suspend 方法实现调度逻辑。

当用户调用协程并构造完协程帧后,编译器会调用协程关联的 promise 对象的 initial_suspend 方法通过返回的 awaiter 来决定是直接运行协程还是暂停执行转移控制权,比如用户想在创建协程后做一些异步的准备工作,此时可以暂停执行等准备工作完成后再回复协程的执行。awaiter 的具体细节暂未讲到,但读者只需要知道 C++ 官方提供了默认的 awaiter 实现:

  • std::suspend_always。暂停协程执行,执行权返回给调用者。

  • std::suspend_never。协程继续执行。

样例程序返回 std::suspend_alaalways 协程创建后不会立刻执行。

💡为何样例程序的 initial_suspend 函数前要添加 constexpr? std::suspend_always 和 std::suspend_never 都是非常简单且含义十分固定明确的 awaiter,所以成员函数均有 constexpr 限制,如果 initial_suspend 函数直接返回二者其一的话也可以添加 constexpr 方便编译器做优化,其他函数也同理。

final_suspend

与 initial_suspend 类似,final_suspend 函数负责协程执行结束后的调度点逻辑,返回值同样是 awaiter 类型,用户可以通过自定义 awaiter 来转移执行权,也可以直接返回 std::suspend_alaways 或者 std::suspend_never,调用 final_suspend 函数时会执行下列伪代码:

换句话说,如果 final_suspend 返回了 suspend_never,那么编译器会接着执行后续的资源清理操作,如果 UserFacing 在析构函数中再次执行 handle.destroy,那么会出现 core dump,所以一般建议不要返回 suspend_never,因为资源的释放最好在用户侧来做。

co_return & return_value

协程的 co_return 就像普通函数的 return 一样,用于终止协程并选择性的返回值。根据 co_return 是否返回值,编译器会做出不同的处理:

  • 不返回值。此时 co_return 仅用于终止协程执行,编译器随后调用 promise.return_void 方法,此函数可实现为空,在某些情况下也可以执行协程结束后的清理工作,但用户必须为 promise 定义 return_void 方法。

  • 返回值。假设 co_return 返回值的类型为 T,此时编译器调用 promise.return_value 方法,并将 co_return 的返回值作为参数传入,用户可以自定义 return_value 函数的参数类型,就像调用正常函数一样,只要 T 可以转换为该参数类型即可。样例程序中因为 co_return 返回了值,所以 promise_type 也增添了一个成员函数用于存储该值,在 return_value 函数体内完成赋值。

需要注意的是 C++ 标准规定 return_value 和 return_void 函数不能同时存在,并且当协程不存在 co_return 关键字时用户也需要定义 return_void 方法,因为协程执行结束后编译器会隐式调用该函数。

通过上述分析我们发现协程返回值与普通函数返回值的处理不一致。普通函数返回值时调用方可以直接拿到值,但调用协程时调用方拿到的是 UserFacing 对象,那怎么拿到协程 co_return 的值呢?一般的逻辑是在 promise 中增加一个成员变量并在 return_value 函数中为其赋值,co_return 后协程执行确实结束了,但协程帧并不会自动回收,promise 对象依然存在,用户可以在 UserFacing 中添加获取该值的方法,UserFacing 一般存储了 promise 的 coroutine_handle,通过该 handle 访问 promise 的成员变量。

co_yield & yield_value

co_yield 用于协程在运行过程中向外输出值。与 co_return 类似,我们也需要在 promise 中为其新增成员变量,当执行到 co_yield 语句时,编译器调用 yield_value 方法,co_yield 的值作为参数,函数体内将该值赋予给 promise 成员变量。外部访问该 co_yield 的值的流程与 co_return 类似。

与 co_return 不同的是,co_yield 之后协程的运行并不一定结束,所以 yield_value 通过返回 awaiter 类型来决定协程的执行权如何处理,一般返回 std::suspend_alaways 转移控制权到调用者,用户也可返回自定义的 awaiter,但通常不要返回 std::suspend_never 等让协程继续运行的 awaiter,因为此时协程继续运行的话如果再次碰到 co_yield 那么上次 yield 的值就会被覆盖。

协程体内通常会执行多次 co_yield,为了让用户能像使用迭代器一样获取 co_yield 的值,样例程序为 Task 类定义的 next 接口实现稍微复杂一些,流程图如下所示:

co_yield 流程图

使用 std::optional 来存储 co_yield 的值便于协程调用方判断迭代循环是否结束,当 value 是 std::nullopt 时,则表明协程 co_yield 部分结束。

注意为了让迭代器运行正常,promise 的 initial_suspend 函数返回了 std::suspend_alaways,always 果返回 std::suspend_never 会出现什么问题。

unhandled_exception

如果协程在运行过程中抛出了异常且没有捕获,那么协程的运行会提前终止,且无法通过 coroutine_handle 恢复协程。此时编译器调用 promise 的 unhandled_exception 方法,该方法没有参数,我们通常实现该函数为利用标准库提供的 std::current_exception 方法获取当前发生的异常,并将异常作为变量存储,注意异常不会再向上传播。此时控制权转移到协程调用者,用户可以在 UserFacing 的方法中获取存储的异常,并再次抛出异常,如样例程序中 Task 的 next 方法所示。

💡为何普通函数在抛出异常未捕获后异常会一直向上传递直到被捕获,而协程抛出异常未捕获却并不会向上传递? C++ 协程关于异常处理的流程如下所示,编译器为我们隐式的添加了 try/catch 语句,因此异常并不会传播到调用者。综合来看 C++ 协程的设计者通过 unhandled_exception 使得协程的异常处理更加灵活。

Awaiter

前文我们提到 C++ 协程设计了多种类型的调度点,而这些调度点的具体逻辑均在 awaiter 内实现,C++ 协程标准要求 awaiter 必须实现下列三个方法:

  • await_ready

  • await_suspend

  • await_resume

编译器会根据上述前两种方法的返回值来选择不同的执行逻辑,而第三种方法则是获取协程返回值。与 promise 不同,awaiter 属于概念较为简单但实操踩坑很多,因此本节将会先讲基本概念根据实战程序精讲。

co_await: awaiter 执行的触发器

co_await 属于 C++ 协程三大关键字之一,我们知道 awaiter 负责实现协程调度逻辑,但逻辑只是实现了,需要 co_await awaiter 才能执行此调度逻辑。

任何 awaiter 的执行一定附带 co_await,用户可以在协程体内显示执行 co_await awaiter 语句,而像一些内置方法,如 promise 的 initial_suspend 函数返回的 awaiter,会由编译器隐式生成 co_await initial_suspend 语句。

那 co_await 后面只能跟 awaiter 吗?答案是否定的,可以被 co_await 的还有 awaitable 对象——可被转换为 awaiter 的对象。换句话说,co_await 原本只能对 awaiter 对象操作,但如果一个对象可以通过特殊的转换变为 awaiter,那么该对象也可以被 co_await,此时 co_awaiter 作用的是转换后的 awaiter,该对象也被称为 awaitable 对象。

C++ 提供了两种手段来定义 awaitable 对象:

  • awaiter operator co_await()。co_await 本身也是一个操作符,当对象 A 非 awaiter 且当前可见作用域内存在将 A 转换为 awaiter 的 co_await 运算符重载函数时,co_await A 会被转换为 co_await operator.co_await(A),重载函数可以是类成员函数,也可以是非成员函数。

  • awaiter promise::await_transform(T&)。如果是协程体内 co_await 对象 A 且协程关联的 promise 类存在 await_transform 函数且入参为 A,那么 co_await A 将被转换为 co_await promise.transform(A)。

如果上述两种条件均满足,那么 co_await A 将被转换为 co_await operator co_await(promise.await_transform(A))。

⚠️ 一旦协程关联的 promise 为某个类型定义了await_transform方法,那么协程体内所有的 co_await 表达式后跟的类型必须均存在对应的await_transform重载,这意味着此时co_await std::suspend_always会出现编译器报错:即不存在参数类型为 std::suspend_always 的await_transform方法

await_ready

用户代码执行 co_await awaiter 时,编译器首先执行 awaiter.await_ready 方法,该方法返回 bool 类型,如果是 true,如同字面意思 ready 一样,代表当前协程已就绪,当前协程选择继续运行而非暂停,并且 await_suspend 方法不会被调用。

await_suspend

如果 await_ready 方法返回 false,此时编译器会调用 awaiter.await_suspend 方法。

await_suspend 参数为当前协程的 coroutine_handle,返回值有三种形式:

  • void。当前协程暂停,执行权返回给当前协程的调用者。

  • bool。如果值为 true 则协程暂停,执行权返回给当前协程的调用者,否则协程继续运行。

  • coroutine_handle。返回的协程句柄会被编译器隐式调用 resume 函数,即该句柄关联的协程会继续运行,也可直接返回参数中的协程句柄,这意味着当前协程会继续运行。

注意返回值为 coroutine_handle 时,如果想转移协程执行权,C++ 内置了 std::noop_coroutine 类,返回该类代表使协程处于 suspend 状态。

await_resume

在讲解 promise 一节中我们提到协程通过 co_return 返回值,协程的调用者通过 UserFacing 的方法获取该返回值,但获取返回值的过程不够优雅。如果协程返回的 UserFacing 可以被转换为 awaiter 且调用者也是协程的话可以有更简洁的写法:

写法 2 更像是调用普通函数的写法,之所以能获取到该值是因为编译器会在子协程第一次处于 suspend 状态时,在调用 await_ready 和 await_suspend 后调用 await_resume 方法,该方法无参数,但 awaiter 通常在被构造时可选择持有协程句柄,通过协程句柄将子协程 co_return 的值返回,具体过程与 promise 样例程序中 UserFacing 获取协程返回值类似。

需要特别注意的是如果子协程转换后的 awaiter 使得父协程处于 suspend 状态,那么父协程恢复后会立刻执行 await_resume。

💡当 co_await 子协程时,子协程在中途被 suspend 来不及返回值,那么编译器还会调用 await_resume 吗? 会的,此时用户需要自行解决这种问题,比如使用 optional 存储返回值来方便判断调用者拿到的返回值是否有效。

awaiter 的生命周期

如果在执行了 co_await 操作后产生了临时的 awaiter 对象,那么在执行完 await_resume 后编译器会立刻执行 awaiter 的析构,对于非临时 awaiter 就是随着作用域结束析构。

协程间的状态转移

读者应该已知晓 await_suspend 函数可以控制协程执行权的转移,但关于执行权转移的细节还是要重点强调一下。

C++ 对协程的设计是基于状态机的,我们先分析一下各种 case 下协程状态的变化:

  • await_suspend 返回 void: 此时协程陷入 suspend 状态。

  • await_suspend 返回 bool: 如果为 true 则协程陷入 suspend 状态,否则恢复运行。

  • await_suspend 返回协程句柄: 如果返回自身句柄,则恢复运行,如果返回其他句柄,那么当前协程陷入 suspend 状态,执行权转移至返回句柄对应的协程,即使返回 noop_coroutine 当前协程也会陷入 suspend 状态。

对于上述使得协程陷入 suspend 状态但没有明确后续调用协程的情况读者能清楚的说出接下来执行权该如何转移吗?比如 await_suspend 返回 void,那么接下来程序怎么执行?像普通函数调用一样回到调用者吗?答案并不是。

其实答案很简单,如果协程陷入 suspend 状态那么协程内部信息会记录该状态,然后根据协程嵌套调用产生的栈逐层向上直到遇到普通函数或者非 suspend 状态的协程,所以如果父级调用者是协程但也处于 suspend 状态那么父协程是不会恢复执行的。

⚠️协程执行状态的变更只有通过陷入 suspend 和 resume 才会变更,在执行行过程中只要未出现协程调度点那么任何协程状态都不会变化

awaiter 实战案例精讲

关于 awaiter 的基础概念部分,读者可参考程序co_demo.awaiters.cpparrow-up-right加深理解。

而标准库预置的 std::suspend_always 和 std::suspend_never 读者应该也能明白其实现原理。

本节案例精讲我为读者编写了一个特别的协程程序,该程序重点关注 awaiter 知识点,简化了无关的 promise,并且添加了多处日志便于分析,代码如下:

该程序实现了下图的运行效果,读者从运行图可以更直观地感受协程与普通函数的不同。

corocallstack

读者可以观察程序得出一个输出结果,然后与下列真实输出结果相对照:

关于上述程序涉及到的知识点我们一一讲解:

  1. UserFacing 的构造时机:在调用协程时 UserFacing 对象通过 promise.get_return_object 函数被构造,先于协程运行。

  2. awaiter 逻辑的执行时机:如果 co_await 操作的是一个子协程(awaitable 对象),而非直接的 awaiter,那么会先执行子协程的调用逻辑,当子协程第一次 suspend 时,awaiter 才会被构造(使用分配好的协程帧内存),然后调用 await_ready,await_suspend(选择性地)和 await_resume,随后 awaiter 析构,由该 co_await 产生的调度点结束。

  3. UserFacing 的析构时机:读者可以注意到“deconstruct task 1”在“event deconstruct”之后输出,因为执行 co_await run(1) 并未直接获取其 UserFacing 对象,只是编译器需要其转换后的 awaiter,awaiter 生命周期结束后 UserFaing 对象也会被编译器执行析构操作,但此时 run(1) 协程执行并未结束,所以样例移除了 UserFacing 析构函数里 handle.destroy 操作,这样会造成内存泄漏(该样例中的内存泄漏读者可忽略),所以有一种做法保留析构函数的 destroy 操作,将 UserFacing 移动到 awaiter 里。

  4. 协程 suspend 时执行权的转移:run(1) 协程一共发生了两次 suspend,第一次 suspend 后执行权返回给了 run(0) 协程,第二次是返回给了 main 函数,部分读者可能会觉得是 run(0) 协程调用了 run(1) 协程,所以 run(1) suspend 应该总是回到 run(0),这种理解是错误的,实际上执行权是回到最近调用/恢复协程的一方。

最后,关于协程嵌套调用的处理实际上十分复杂,想要写出普通函数那样递归调用的逻辑是需要用户做很多额外工作的,本节的示例仅仅是展示了冰山一角,后续会给出更复杂的示例和讲解。

💡思考题:如果将 Event 的 await_suspend 方法返回值改成 global_handle,那么示例的运行时图需要怎样修改?

协程实例讲解

线程间调度协程

这个例子来自于 cppreference 官网给出的样例,目的是演示协程如何跨线程执行,代码如下:

运行结果如下:

该程序在 await_suspend 中使用了 jthread 来恢复协程,jthread 在析构时会自动 join,所以程序一定可以等到协程执行完毕再结束。在实际应用中可以选择构造一个线程池通过添加未执行完毕协程的句柄,并在获取句柄的线程中恢复协程运行。

协程的栈式调用

前文提到过如果想让协程像普通函数一样拥有正确的嵌套调用执行顺序是有一定难度的。那什么叫做正确的嵌套执行调用顺序

下图给出了普通函数与协程的对比:

coro_stack

读者不难看出协程可以在子协程暂停后继续执行父协程,当然这个执行逻辑是由用户自定义的,问题在于这种执行逻辑下,协程 A 在子协程 B 只执行一部分后继续执行,与我们的目标——模拟普通函数调用不符,我们期望的是哪怕协程嵌套调用了 100 层,如果最底层协程处于 suspend 状态,那么上层的所有协程均不可以继续执行,若要继续执行只能选择恢复执行最底层的协程,要实现这些需要我们在代码中记录协程调用栈信息。

本节代码co_stack.cpparrow-up-right摘自博客Writing custom C++20 coroutine systemsarrow-up-right,代码较长读者可去网页端查看。

代码的其他实现细节不再细讲,本文主要关注其核心实现模拟栈式调用的部分,示意图如下:

coro_stack_structure

在 promise 添加了 3 个指针:

  • outer:指向最上层协程关联的 promise。

  • parent:指向当前协程的父协程关联的 promise,如果没有父协程则为 nullptr。

  • current:指向最底层也就是正在运行的协程关联的 promise。

通过在 promise 中添加上述三种指针,当协程 A co_await 协程 B 时,由于协程 B 在 initial_suspend 函数中返回了 suspend_always,所以协程 B 处于 suspend 状态,此时构造 awaiter,在 awaiter 的 await_suspend 函数中完成 promise 三个指针的赋值,然后返回协程 B 的句柄,协程 B 继续执行。

如果协程 B 在返回前再次陷入 suspend 状态,比如调用 co_yield,此时会在 yield_value 函数中设置 outer 的 yield 的值,并返回 suspend_always,此时执行权会转移到 outer 的上层(请读者思考为什么),即一个普通函数,此时普通函数持有 outer 关联的 UserFacing 对象,通过该对象获取 yield 的值,所以样例程序中嵌套调用的协程 yield 的值均是传递到上层处理的。

outer 的调用者在获取 yield 的值后会调用 outer 关联的 UserFacing 对象的 next 方法,这时候 promise 持有的 current 指针发挥作用了,next 方法调用了 handle.resume,只是该 handle 不是 outer 的 handle,而是 current 的 handle。

协程 B 执行完毕后编译器调用 final_suspend 函数,该函数先修改了 promise 的 current 指针(因为 current 执行结束了),然后返回了一个特殊的 awaiter,在 awaiter 在 await_suspend 函数中返回了 parent 关联的协程句柄,即恢复协程 A 的运行,如果该 awaiter 的 await_suspend 函数返回 suspend_always,那么执行权会返回至 outer 的上层调用者,这也是为什么 promise 需要 parent 成员变量,可以让协程调用栈保持正确的执行顺序。

协程实践总结

从编译器视角看,协程执行的伪代码为如下,此时读者应该能理解 promise 各个成员函数的作用。

再来回顾第二章提到的协程状态切换图,这其中的各个状态转移读者应能了如指掌。

协程状态切换

参考文献

在撰写本文前作者本人立下了一个目标,那就是本文要尽力照顾初学者,仅此一文便使读者彻底入门 C++ 协程。可惜的是在不断地查资料后发现若要深究 C++ 协程,写个 10 篇都讲不完,因此在学罢本文后,我列出下列参考文献供读者学习。

优先推荐系列

选看系列

实验总结

本文从实践的角度对 C++ 协程两大基础概念——promise 和 awaiter 做了细致的讲解,并给出了样例程序讲解,本课程关于 C++ 协程的学习到此结束,后续将会讲解本课程第二部分——异步 io 技术之 io_uring。

Last updated