第14章 协程
# 第14章 协程
C++20引入了对协程(coroutines)的支持。协程(1958年由梅尔·康威发明)是一种可以暂停的函数。本章将解释协程的基本概念和最重要的细节。
C++20通过引入一些核心语言特性和基本库特性来提供对协程的基础支持。然而,要使用协程,必须编写大量的衔接代码,这使得协程的使用非常灵活,但即使在简单的情况下也需要付出一定的努力。C++标准库计划在C++23及更高版本中为协程的典型应用提供更多的标准类型和函数。
# 14.1 什么是协程?
普通函数(或过程)被调用后会一直运行,直到结束(或者遇到返回语句或抛出异常),而协程是可以分多个步骤运行的函数(见图14.1)。在某些时刻,你可以暂停协程,这意味着函数会暂停计算,直到被恢复。你可能会因为函数需要等待某些条件、有其他(更重要的)事情要做,或者有中间结果要返回给调用者而暂停它。
因此,启动一个协程意味着启动另一个函数,直到完成其中一部分。调用函数和协程在它们的两条执行路径之间来回切换运行。请注意,这两个函数并不是并行运行的。相反,我们在控制流之间进行某种“乒乓操作”:
- 函数可以决定通过启动或继续执行协程的语句来启动或恢复其当前控制流。
- 当协程运行时,协程可以决定暂停或结束其执行,这意味着启动或恢复该协程的函数将继续其控制流。
在最简单的协程形式中,主控制流和协程的控制流在同一线程中运行。我们不需要使用多线程,也不需要处理并发访问。不过,在不同线程中运行协程是可行的。你甚至可以在与之前暂停位置不同的线程上恢复协程。协程是一个正交特性,不过它可以与多线程一起使用。
实际上,使用协程就像是在后台有一个函数,你可以不时地启动和继续它。然而,由于协程的生命周期跨越嵌套作用域,协程也是一个在内存中存储其状态并提供用于处理它的API的对象。
在C++中,关于协程有几个基本要点:
- 只要在函数中使用以下关键字之一,就会隐式定义一个协程:
co_await
co_yield
co_return
如果在协程内部不需要这些关键字中的任何一个,那么必须在末尾显式编写co_return;
语句。
- 协程通常会返回一个对象,该对象作为调用者与协程交互的接口。根据协程的目的和用途,该对象可以表示一个不时暂停或切换上下文的正在运行的任务、一个不时产生值的生成器,或者一个按需延迟返回一个或多个值的工厂。
- 协程是无栈的。你不能在不暂停外部协程的情况下暂停在外部协程中调用的内部协程。你只能整体暂停外部协程。
当协程被暂停时,协程的整体状态会存储在一个与栈分离的对象中,这样它就可以在完全不同的上下文(不同的调用栈、另一个线程等)中被恢复。
# 14.2 第一个协程示例
假设我们希望下面这个函数是一个协程:
void coro(int max) {
std::cout << "CORO " << max << " start\n";
for (int val = 1; val <= max; ++val) {
std::cout << "CORO " << val << "/" << max << "\n";
}
std::cout << "CORO " << max << " end\n";
}
2
3
4
5
6
7
这个函数有一个表示最大值的参数,我们首先打印这个参数。然后,它从1循环到这个最大值,并打印每个值。最后,在末尾有一个打印语句。
当使用coro(3)
调用这个函数时,输出如下:
CORO 3 start
CORO 1/3
CORO 2/3
CORO 3/3
CORO 3 end
2
3
4
5
然而,我们希望将其编写为一个协程,每次执行循环内的打印语句时暂停。这样,函数就会被中断,协程的使用者可以通过恢复操作触发下一次输出。
# 14.2.1 定义协程
下面是定义该协程的完整代码:
// coro/coro.hpp
#include <iostream>
#include "corotask.hpp" // 用于CoroTask
CoroTask coro(int max) {
std::cout << " CORO " << max << " start\n";
for (int val = 1; val <= max; ++val) {
// 打印下一个值:
std::cout << " CORO " << val << "/" << max << "\n";
co_await std::suspend_always{}; // 暂停
}
std::cout << " CORO " << max << " end\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
我们仍然有一个类似的函数,它会循环遍历直到参数max
指定的值。然而,与普通函数有两点不同:
- 在循环内的打印语句之后,有一个
co_await
表达式,它会暂停协程并阻塞,直到协程被恢复。这被称为暂停点。
暂停调用的具体行为由co_await
后面的表达式定义。它使程序员能够控制暂停的具体行为。
目前,我们使用std::suspend_always
的默认构造对象,它接受暂停并将控制权交回给调用者。不过,你可以通过向co_await
传递特殊操作数来拒绝暂停或恢复另一个协程。
- 尽管这个协程没有返回语句,但它有一个返回类型
CoroTask
。这个类型作为协程调用者的协程接口。请注意,我们不能将返回类型声明为auto
。
返回类型是必要的,因为调用者需要一个接口来处理协程(例如恢复它)。在C++20中,协程接口类型必须由程序员(或第三方库)提供。我们稍后会看到它是如何实现的。未来的C++标准计划在其库中提供一些标准的协程接口类型。
# 14.2.2 使用协程
我们可以如下使用这个协程:
// coro/coro.cpp
#include <iostream>
#include "coro.hpp"
int main() {
// 启动协程:
auto coroTask = coro(3);
std::cout << "coro() started\n";
// 循环恢复协程,直到它结束:
while (coroTask.resume()) {
std::cout << "coro() suspended\n";
}
std::cout << "coro() done\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
初始化协程后,会得到协程接口coroTask
,然后我们启动一个循环,在协程被暂停后不断恢复它:
auto coroTask = coro(3); // 初始化协程
while (coroTask.resume()) { // 恢复协程运行
...
}
2
3
4
通过调用coro(3)
,我们像调用函数一样调用协程。然而,与函数调用不同的是,我们不会等待协程结束。相反,调用会返回协程接口,以便在协程初始化后(在开始处有一个隐式的挂起点)对其进行处理。
这里我们使用auto
来表示协程接口类型;不过,我们也可以使用它的类型,即协程的返回类型:
CoroTask coroTask = coro(3); // 初始化协程
CoroTask
类提供的API中有一个成员函数resume()
,用于恢复协程。每次调用resume()
都会使协程继续运行,直到下一个挂起点或协程结束。注意,挂起不会离开协程中的任何作用域。恢复时,我们会在挂起的状态下继续执行协程。
其效果是,在main()
函数的循环中,我们调用协程中的下一组语句,直到到达挂起点或协程结束。也就是说,我们会调用以下内容:
- 首先,是初始输出、
val
的初始化以及循环内的第一个输出:
std::cout << " CORO " << max << " start\n";
for (int val = 1; val <= max; ...) {
std::cout << " CORO " << val << "/" << max << "\n";
...
}
2
3
4
5
- 然后,是循环中的下两次迭代:
for (...; val <= max; ++val) {
std::cout << " CORO " << val << "/" << max << "\n";
...
}
2
3
4
- 最后,在循环的最后一次迭代之后,协程执行最后的打印语句:
for (...; val <= max; ++val) {
...
}
std::cout << " CORO " << max << " end\n";
2
3
4
该程序的输出如下:
coro() started
CORO 3 start
CORO 1/3
coro() suspended
CORO 2/3
coro() suspended
CORO 3/3
coro() suspended
CORO 3 end
coro() done
2
3
4
5
6
7
8
9
10
图14.2 协程示例
结合即将介绍的接口类型CoroTask
,我们得到以下控制流(见图14.2):
a) 首先,我们调用协程使其开始。协程立即被挂起,调用返回用于处理协程的接口对象。
b) 然后,我们可以使用接口对象恢复协程,使其开始执行语句。
c) 在协程内部,我们处理起始语句,直到到达第一个挂起点:第一个打印语句、循环头部局部计数器val
的初始化,以及(在检查val
小于或等于max
之后)循环内的打印语句。在这部分结束时,协程被挂起。
d) 挂起将控制权交回主函数,主函数继续执行,直到再次恢复协程。
e) 协程继续执行下一组语句,直到再次到达挂起点,即递增val
,并(在检查val
仍然小于或等于max
之后)执行循环内的打印语句。在这部分结束时,协程再次被挂起。注意,这里我们继续使用协程上次挂起时val
的值。
f) 这再次将控制权交回主函数,主函数继续执行,直到再次恢复协程。
g) 只要递增后的val
小于或等于max
,协程就继续循环。
h) 只要协程因co_await
挂起,主函数就恢复协程。
i) 最后,在val
递增后,协程离开循环,调用带有max
值的最后一个打印语句。
j) 协程结束时,控制权最后一次交回主函数。然后主函数结束其循环并继续执行,直到结束。
协程的初始化和接口的具体行为取决于接口类型CoroTask
。它可能只是启动协程,也可能提供带有一些初始化操作的上下文,比如打开文件或启动线程。它还定义了协程是立即启动还是延迟启动(立即挂起)。在当前CoroTask
的实现中,协程是延迟启动的,这意味着对协程的初始调用还不会执行coro()
中的任何语句。
注意,这里不存在异步通信或控制流。更确切地说,coroTask.resume()
就像是对coro()
的下一部分进行函数调用。
当我们到达协程的末尾时,循环结束。为此,resume()
的实现使其返回协程是否尚未结束。因此,只要resume()
返回true
,我们就继续循环(在打印出协程已挂起之后)。
# 多次使用协程
如前所述,协程有点像准并行函数,通过来回切换控制流顺序执行。它的状态存储在由协程句柄控制的堆内存中,协程句柄通常由协程接口持有。通过拥有多个协程接口对象,我们可以处理多个活动协程的状态,这些协程可能独立运行或挂起。
假设我们用不同的max
值启动两个协程:
// coro/coro2.cpp
#include <iostream>
#include "coro.hpp"
int main() {
// 启动两个协程:
auto coroTask1 = coro(3);
auto coroTask2 = coro(5);
std::cout << "coro(3) and coroTask2.resume();|// initialize 1st coroutine // initialize 2nd coroutine
coro(5) started\n";
// RESUME 2nd coroutine once
// 循环恢复第一个协程,直到它结束:
while (coroTask1.resume()) { // RESUME 1st coroutine
std::cout << "coro() suspended\n";
}
std::cout << "coro() done\n";
coroTask2.resume(); // RESUME 2nd coroutine again
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
我们为初始化后的协程得到两个不同的接口对象coroTask1
和coroTask2
。通过有时恢复第一个协程,有时恢复第二个协程,我们的控制流在主函数和这两个协程之间跳转。
在我们的例子中,我们先恢复一次第二个协程(max
为5),然后循环恢复第一个协程,最后再恢复一次第二个协程。结果,程序的输出如下:
coro(3) and coro(5) started
CORO 5 start
CORO 1/5
CORO 3 start
CORO 1/3
coro() suspended
CORO 2/3
coro() suspended
CORO 3/3
coro() suspended
CORO 3 end
coro() done
CORO 2/5
2
3
4
5
6
7
8
9
10
11
12
13
你甚至可以将协程接口对象传递给不同的函数或线程,在那里恢复它们。它们将始终从当前状态继续执行。
# 14.2.3 按引用传递的生命周期问题
协程的生命周期通常比最初调用它的语句更长。这会带来一个重要的后果:如果按引用传递临时对象,可能会遇到严重的运行时问题。
考虑下面这个稍有修改的协程。我们现在所做的只是按引用获取max
值:
// coro/cororef.hpp
#include <iostream>
#include <coroutine>
#include "corotask.hpp" //for std::suspend_always{} //for CoroTask
CoroTask coro(const int& max) {
std::cout << " CORO " << max << " start\n"; // 糟糕:max的值仍然有效吗?
for (int val = 1; val <= max; ++val) { // 糟糕:max的值仍然有效吗?
std::cout << " CORO " << val << "/" << max << "\n";
co_await std::suspend_always{}; // 挂起
}
std::cout << " CORO " << max << " end\n"; // 糟糕:max的值仍然有效吗?
}
2
3
4
5
6
7
8
9
10
11
12
13
问题在于,传递临时对象(甚至可能是字面量)会导致未定义行为。根据平台、编译器设置和代码的其他部分,你可能会或可能不会发现这个问题。例如,考虑如下调用协程:
// coro/cororef.cpp
#include <iostream>
#include "cororef.hpp"
int main() {
auto coroTask = coro(3);
std::cout << "coro(3) started\n";
coro(375);
std::cout << "coro(375) started\n";
// 循环恢复协程,直到它结束:
while (coroTask.resume()) {
std::cout << "coro() suspended\n";
}
std::cout << "coro() done\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 糟糕:创建了对临时对象/字面量的引用
// 另一个临时协程
// 错误:未定义行为
2
3
在某些平台上,这段代码运行正常。然而,在我测试的一个平台上,得到了以下输出:
coro(3) started
coro(375) started
CORO -2147168984 start
CORO -2147168984 end
coro() done
2
3
4
5
在初始化协程的语句之后,传递的参数3
的max
引用所指向的位置不再可用。因此,当第一次恢复协程时,输出和val
的初始化使用的是对已销毁对象的引用。
一般来说:不要使用引用声明协程参数。
如果复制参数的开销太大,你可以使用std::ref()
或std::cref()
创建的引用包装器来 “按引用传递”。对于容器,你可以使用std::views::all()
代替,它将容器作为视图传递,这样所有标准范围函数仍然可以使用,而无需将参数转换回原来的类型。例如:
CoroTask printElems(auto coll) {
for (const auto& elem : coll) {
std::cout << elem << "\n";
co_await std::suspend_always{}; // 挂起
}
}
std::vector<std::string> coll;
...
// 启动打印元素的协程:
// - 使用std::views::all()创建的视图,避免复制容器
auto coPrintElems = printElems(std::views::all(coll));
while (coPrintElems.resume()) { // 恢复
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 14.2.4 协程调用协程
协程可以(甚至间接地)调用其他协程,并且调用协程和被调用协程都可能存在挂起点。让我们来看看这意味着什么。
假设有一个带有一个挂起点的协程,它分两部分运行:
CoroTask coro()
{
std::cout << " coro(): PART1\n";
co_await std::suspend_always{}; // 挂起
std::cout << " coro(): PART2\n";
}
2
3
4
5
6
当我们这样使用这个协程时:
auto coroTask = coro(); // 初始化协程
std::cout << "MAIN : coro() initialized\n";
while (coroTask.resume()) { // 恢复执行
std::cout << "MAIN : coro() suspended\n";
}
std::cout << "MAIN : coro() done\n";
2
3
4
5
6
我们会得到以下输出:
MAIN: coro() initialized
coro(): PART1
MAIN: coro() suspended
coro(): PART2
MAIN: coro() done
2
3
4
5
现在,让我们通过另一个协程间接调用coro()
。为此,main()
调用callCoro()
而不是coro()
:
auto coroTask = callCoro(); // 初始化协程
std::cout << "MAIN : callCoro() initialized\n";
while (coroTask.resume()) { // 恢复执行
std::cout << "MAIN : callCoro() suspended\n";
}
std::cout << "MAIN : callCoro() done\n";
2
3
4
5
6
有趣的部分在于如何实现callCoro()
。
# 没有内部的resume()
我们可能尝试通过直接调用coro()
来实现callCoro()
:
CoroTask callCoro()
{
std::cout << " callCoro(): CALL coro()\n";
coro(); // 调用子协程
std::cout << " callCoro(): coro() done\n";
co_await std::suspend_always{}; // 挂起
std::cout << " callCoro(): END\n";
}
2
3
4
5
6
7
8
这段代码可以编译。然而,正如程序的输出所示,它并没有达到预期的效果:
MAIN: callCoro() initialized
callCoro(): CALL coro()
callCoro(): coro() done
MAIN: callCoro() suspended
callCoro(): END
MAIN: callCoro() done
2
3
4
5
6
coro()
主体中的打印语句从未被调用。
原因是coro();
只是初始化了协程并立即将其挂起。coro()
从未被恢复。它无法被恢复是因为返回的协程接口甚至都没有被使用。外部协程的resume()
不会自动恢复任何内部协程。
为了在像使用函数一样使用协程时至少得到一个编译器警告,CoroTask
类将被声明为[[nodiscard]]
。
# 使用内部的resume()
我们必须以处理外部协程的相同方式来处理内部协程:在循环中调用resume()
:
CoroTask callCoro()
{
std::cout << " callCoro(): CALL coro()\n";
auto sub = coro(); // 初始化子协程
while (sub.resume()) { // 恢复子协程
std::cout << " callCoro(): coro() suspended\n";
}
std::cout << " callCoro(): coro() done\n";
co_await std::suspend_always{}; // 挂起
std::cout << " callCoro(): END\n";
}
2
3
4
5
6
7
8
9
10
11
通过这样实现callCoro()
,我们得到了想要的行为和输出:
MAIN: callCoro() initialized
callCoro(): CALL coro()
coro(): PART1
callCoro(): coro() suspended
coro(): PART2
callCoro(): coro() done
MAIN: callCoro() suspended
callCoro(): END
MAIN: callCoro() done
2
3
4
5
6
7
8
9
完整示例请查看coro/corocoro.cpp
。
# 使用一次内部的resume()
值得注意的是,如果我们在callCoro()
中只对coro()
调用一次resume()
会发生什么:
CoroTask callCoro()
{
std::cout << " callCoro(): CALL coro()\n";
auto sub = coro(); // 初始化子协程
sub.resume(); // 恢复子协程
std::cout << " callCoro(): call.resume() done\n";
co_await std::suspend_always{}; // 挂起
std::cout << " callCoro(): END\n";
}
2
3
4
5
6
7
8
9
输出结果变为:
MAIN: callCoro() initialized
callCoro(): CALL coro()
coro(): PART1
callCoro(): call.resume() done
MAIN: callCoro() suspended
callCoro(): END
MAIN: callCoro() done
2
3
4
5
6
7
在callCoro()
初始化coro()
之后,coro()
只被恢复了一次,这意味着它只有第一部分被调用。之后,coro()
的挂起将控制流转移回callCoro()
,然后callCoro()
自身挂起,这意味着控制流又回到了main()
。当main()
恢复callCoro()
时,程序结束了callCoro()
的执行。而coro()
根本没有执行完。
# 委托resume()
可以实现CoroTask
,使得co_await
以一种特殊方式注册子协程,即子协程中的挂起操作会像调用协程中的挂起操作一样被处理。那么callCoro()
可以写成如下形式:
CoroTaskSub callCoro()
{
std::cout << " callCoro(): CALL coro()\n";
co_await coro(); // 调用子协程
std::cout << " callCoro(): coro() done\n";
co_await std::suspend_always{}; // 挂起
std::cout << " callCoro(): END\n";
}
2
3
4
5
6
7
8
然而,在这种情况下,如果存在子协程,resume()
必须将恢复请求委托给子协程。为此,CoroTask
接口必须成为一个可等待对象(awaitable)。稍后,在介绍了可等待对象之后,我们将看到这样一个将恢复操作委托给子协程的协程接口。
# 14.2.5 实现协程接口
我多次提到,在我们的示例中,CoroTask
类在处理协程方面起着重要作用。它是编译器和协程调用者通常打交道的接口。协程接口汇集了一些要求,以便让编译器处理协程,并为调用者提供创建、恢复和销毁协程的API。让我们详细阐述一下。
在C++中处理协程需要两样东西:
- 一个承诺类型(promise type) 这个类型用于定义处理协程的某些定制点。特定的成员函数定义了在某些情况下被调用的回调函数。
- 一个
std::coroutine_handle<>
类型的内部协程句柄 当调用协程时(使用上述承诺类型的标准回调之一)会创建这个对象。它可以通过提供一个底层接口来恢复协程以及处理协程的结束,从而用于管理协程的状态。
处理协程返回类型的类型通常目的是将这些要求整合在一起:
- 它必须定义使用哪种承诺类型(通常定义为类型成员
promise_type
)。 - 它必须定义协程句柄存储在哪里(通常定义为数据成员)。
- 它必须为调用者提供处理协程的接口(在这种情况下是成员函数
resume()
)。
# 协程接口CoroTask
CoroTask
类提供了promise_type
,存储协程句柄,并为协程的调用者定义了API,它可以如下定义:
// coro/corotask.hpp
#include <coroutine>
// 用于处理简单任务的协程接口
// - 提供resume()来恢复协程
class [[nodiscard]] CoroTask {
public:
// 初始化状态和定制的成员:
struct promise_type; // 稍后在corotaskpromise.hpp中定义
using CoroHdl = std::coroutine_handle<promise_type>;
private:
CoroHdl hdl; // 原生协程句柄
public:
// 构造函数和析构函数:
CoroTask(auto h)
: hdl{h} { // 在接口中存储协程句柄
}
~CoroTask() {
if (hdl) {
hdl.destroy(); // 销毁协程句柄
}
}
// 禁止复制和移动:
CoroTask(const CoroTask&) = delete;
CoroTask& operator=(const CoroTask&) = delete;
// 恢复协程的API
// - 返回是否还有内容需要处理
bool resume() const {
if (!hdl || hdl.done()) {
return false; // 没有(更多)内容需要处理
}
hdl.resume(); // 恢复执行(阻塞直到再次挂起或结束)
return !hdl.done();
}
};
#include "corotaskpromise.hpp" // promise_type的定义
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
在CoroTask
类中,我们首先定义了处理协程原生API的基本类型和成员。编译器在协程接口类型中查找的关键成员是类型成员promise_type
。通过promise_type
,我们定义了协程句柄的类型,并引入了一个私有成员,用于存储专门针对该promise_type
的协程句柄:
class [[nodiscard]] CoroTask {
public:
// 初始化用于状态和定制的成员
struct promise_type; // 稍后在corotaskpromise.hpp中定义
using CoroHdl = std::coroutine_handle<promise_type>;
private:
CoroHdl hdl; // 原生协程句柄
...
};
2
3
4
5
6
7
8
9
我们引入了promise_type
(每个协程类型都必须有),并声明了原生协程句柄hdl
,它用于管理协程的状态。如你所见,原生协程句柄的类型std::coroutine_handle<>
是用promise_type
作为参数进行参数化的。这样,存储在promise
中的任何数据都是句柄的一部分,并且promise
中的函数可以通过句柄来访问。
promise_type
必须是公共的,以便从外部可见。通常,为协程句柄的类型提供一个公共名称(在本例中为CoroHdl
)也很有帮助。为了简化操作,我们甚至可以将句柄本身设置为公共的。这样,我们就可以使用CoroTask::CoroHdl
,而不是std::coroutine_handle<CoroTask::promise_type>
。
我们也可以直接在这里内联定义promise_type
。然而,在这个例子中,我们将定义推迟到后面包含的corotaskpromise.hpp
文件中。
协程接口类型的构造函数和析构函数用于初始化协程句柄的成员,并在协程接口被销毁之前清理它:
class CoroTask {
...
public:
CoroTask(auto h)
: hdl{h} { // 在内部存储协程句柄
}
~CoroTask() {
if (hdl) {
hdl.destroy(); // 销毁协程句柄(如果有的话)
}
}
...
};
2
3
4
5
6
7
8
9
10
11
12
13
通过使用[[nodiscard]]
声明类,当协程被创建但未被使用时(尤其是在意外地将协程当作普通函数使用的情况下),我们会强制编译器发出警告。
为了简单起见,我们禁用了复制和移动操作。虽然可以提供复制或移动语义,但你必须小心正确处理资源。
最后,我们为调用者定义了唯一的接口resume()
:
class CoroTask {
...
bool resume() const {
if (!hdl || hdl.done()) {
return false; // 没有(更多)内容要处理
}
hdl.resume(); // 恢复执行(阻塞直到再次挂起或结束)
return !hdl.done();
}
};
2
3
4
5
6
7
8
9
10
关键的API是resume()
,它在协程挂起时恢复协程的执行。它或多或少地将恢复请求传递给原生协程句柄。它还返回是否有必要再次恢复协程。
首先,该函数检查我们是否有句柄,或者协程是否已经结束。尽管在这个实现中,协程接口总是有一个句柄,但这是一个安全机制,例如,如果接口支持移动语义,这就是必要的。
只有当协程处于挂起状态且尚未结束时,才允许调用resume()
。因此,检查done()
是必要的。调用本身会恢复挂起的协程,并阻塞直到它到达下一个挂起点或结束。
hdl.resume(); // 恢复执行(阻塞直到再次挂起或结束)
你也可以使用operator()
来实现同样的功能:
hdl(); // 恢复执行(阻塞直到再次挂起或结束)
因为我们为调用者提供的resume()
接口返回是否有必要再次恢复协程,所以我们返回协程是否已经结束:
bool resume() const {
...
return !hdl.done();
}
2
3
4
成员函数done()
由原生协程句柄提供,就是用于这个目的。
如你所见,协程调用者的接口完全封装了原生协程句柄及其API。在这个API中,我们决定了调用者如何与协程进行交互。我们可以使用不同的函数名,甚至是操作符,或者将恢复调用和结束检查分开。稍后你将看到一些示例,在这些示例中,我们可以迭代协程每次挂起时产生的值,甚至可以将值发送回协程,并且我们还可以提供一个API,将协程置于不同的上下文中。
最后,我们包含一个定义promise_type
的头文件:
#include "corotaskpromise.hpp " // promise_type的定义
这通常在接口类的声明内部完成,或者至少在同一个头文件中完成。然而,通过这种方式,我们可以将这个示例的细节分散到不同的文件中。
# 实现promise_type
唯一缺失的部分是promise_type
的定义。它的目的是:
- 定义如何创建或获取协程的返回值(这通常包括创建协程句柄)。
- 决定协程在开始或结束时是否应该挂起。
- 处理协程调用者和协程之间交换的值。
- 处理未处理的异常。
下面是CoroTask
及其协程句柄类型CoroHdl
的promise_type
的典型基本实现:
// coro/corotaskpromise.hpp
struct CoroTask::promise_type {
auto get_return_object() { // 初始化并返回协程接口
return CoroTask{CoroHdl::from_promise(*this)};
}
auto initial_suspend() {
return std::suspend_always{};
}
void unhandled_exception() {
std::terminate();
}
void return_void() { }
auto final_suspend() noexcept {
return std::suspend_always{};
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
我们(必须)按以下顺序定义以下成员(使用它们通常被调用的顺序):
get_return_object()
用于初始化协程接口。它创建一个对象,该对象稍后会返回给协程的调用者。它通常按如下方式实现:- 首先,为调用该函数的
promise
创建原生协程句柄:coroHdl = CoroHdl::from_promise(*this)
。当我们启动协程时,会自动创建调用这个成员函数的promise
。from_promise()
是类模板std::coroutine_handle<>
为此目的提供的一个静态成员函数。 - 然后,我们创建协程接口对象,并用刚创建的句柄对其进行初始化:
coroIf = CoroTask{coroHdl}
。 - 最后,我们返回接口对象:
return coroIf
。
- 首先,为调用该函数的
实现中通过一条语句完成了所有这些操作:
auto get_return_object() {
return CoroTask{CoroHdl::from_promise(*this)};
}
2
3
我们也可以在这里直接返回协程句柄,而不通过它显式创建协程接口:
auto get_return_object() {
return CoroHdl::from_promise(*this);
}
2
3
在内部,返回的协程句柄会被自动用于初始化协程接口。然而,不建议使用这种方法,因为如果CoroTask
的构造函数是显式的,这种方法就行不通,而且也不清楚接口何时被创建。
initial_suspend()
允许进行额外的初始准备,并定义协程是应该立即启动还是延迟启动:- 返回
std::suspend_never{}
意味着立即启动。协程在初始化后会立即执行第一条语句。 - 返回
std::suspend_always{}
意味着延迟启动。协程会立即挂起,不执行任何语句。它会在恢复时被处理。在我们的示例中,我们请求立即挂起。
- 返回
return_void()
定义了到达协程末尾(或遇到co_return;
语句)时的反应。如果声明了这个成员函数,协程就不应该返回值。如果协程产生或返回数据,则必须使用另一个成员函数。unhandled_exception()
定义了如何处理协程中未在本地处理的异常。在这里,我们指定这会导致程序异常终止。稍后会讨论其他处理异常的方法。final_suspend()
定义了协程是否应该最终挂起。在这里,我们指定希望这样做,这通常是正确的做法。注意,这个成员函数必须保证不抛出异常。它通常应该返回std::suspend_always{}
。
稍后将详细讨论promise_type
这些成员的目的和用法。
# 14.2.6 引导接口、句柄和承诺
让我们回顾一下处理协程需要做些什么:
- 对于每个协程,都有一个承诺(promise),在调用协程时会自动创建。
- 协程状态存储在协程句柄(coroutine handle)中。它的类型是
std::coroutine_handle<PrmType>
。该类型提供了恢复协程(以及检查协程是否结束或释放其内存)的原生API。 - 协程接口是将所有内容整合在一起的典型地方。它持有并管理原生协程句柄,由协程调用返回,并提供处理协程的成员函数。
声明承诺类型和协程句柄(以及两者的类型)有多种方式。由于协程句柄的类型需要承诺类型,而承诺类型的定义又使用协程句柄,因此没有显而易见的做法。
在实践中,通常会采用以下几种方式:
- 声明承诺类型,声明协程句柄的类型,然后定义承诺类型:
class CoroTask {
public:
struct promise_type; //承诺类型
using CoroHdl = std::coroutine_handle<promise_type>;
private:
CoroHdl hdl; //原生协程句柄
public:
struct promise_type {
auto get_return_object() {
return CoroTask{CoroHdl::from_promise(*this)};
}
...
};
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- 定义承诺类型并声明协程句柄:
class CoroTask {
public:
struct promise_type { //承诺类型
auto get_return_object() {
return std::coroutine_handle<promise_type>::from_promise(*this);
}
...
};
private:
std::coroutine_handle<promise_type> hdl; //原生协程句柄
public:
...
};
2
3
4
5
6
7
8
9
10
11
12
13
- 将承诺类型作为通用辅助类型在外部定义:
template<typename CoroIf>
struct CoroPromise {
auto get_return_object() {
return std::coroutine_handle<CoroPromise<CoroIf>>::from_promise(*this);
}
...
};
class CoroTask {
public:
using promise_type = CoroPromise<CoroTask>;
private:
std::coroutine_handle<promise_type> hdl; //原生协程句柄
public:
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
由于承诺类型通常与接口相关(具有不同/额外的成员),我通常会使用以下非常简洁的形式:
class CoroTask {
public:
struct promise_type;
using CoroHdl = std::coroutine_handle<promise_type>;
private:
CoroHdl hdl; //原生协程句柄
public:
struct promise_type {
auto get_return_object() { return CoroHdl::from_promise(*this); }
auto initial_suspend() { return std::suspend_always{}; }
void return_void() { }
void unhandled_exception() { std::terminate(); }
auto final_suspend() noexcept { return std::suspend_always{}; }
...
};
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
请注意,到目前为止描述的所有内容都是处理协程的典型方式。协程库设计得更加灵活。例如:
- 你可以将协程接口存储或管理在容器或调度器中。
- 你甚至可以完全跳过协程接口。不过,这是一种很少使用的选项。
# 14.2.7 内存管理
协程具有在不同上下文中使用的状态。为此,协程句柄通常将协程的状态存储在堆内存中。堆内存分配可以优化或根据实际情况更改。
# 调用destroy()
为了使协程句柄的开销更低,并没有对这块内存进行智能管理。协程句柄在初始化时只是指向内存,直到调用destroy()
。因此,通常应该在协程接口被销毁时显式调用destroy()
:
class CoroTask {
...
CoroHdl hdl; //原生协程句柄
public:
~CoroTask() {
if (hdl) {
hdl.destroy(); //销毁协程句柄(如果存在)
}
}
...
};
2
3
4
5
6
7
8
9
10
11
# 复制和移动协程
协程句柄这种简单的实现方式也使得处理复制和移动变得必要。默认情况下,复制协程接口会复制协程句柄,这会导致两个协程接口/句柄共享同一个协程。当然,当一个协程句柄使协程进入另一个句柄不知道的状态时,这就会带来风险。移动协程对象也有同样的效果,因为默认情况下,指针会随着移动而被复制。
为了降低风险,在为协程提供复制或移动语义时应该谨慎。最简单的方法是禁用复制和移动:
class CoroTask {
...
// 禁止复制或移动:
CoroTask(const CoroTask&) = delete;
CoroTask& operator=(const CoroTask&) = delete;
...
};
2
3
4
5
6
7
然而,这意味着你不能移动协程(比如将它们存储在容器中)。因此,支持移动语义可能是有意义的。不过,你应该确保被移动的对象不再引用该协程,并且获得新值的协程会销毁旧协程的数据:
class CoroTask {
...
// 支持移动语义:
CoroTask(CoroTask&& c) noexcept : hdl{std::move(c.hdl)} {
c.hdl = nullptr;
}
CoroTask& operator=(CoroTask&& c) noexcept {
if (this != &c) { // 如果不是自赋值
if (hdl) {
hdl.destroy(); // - 销毁旧句柄(如果存在)
}
hdl = std::move(c.hdl); // - 移动句柄
c.hdl = nullptr; // - 被移动的对象不再有句柄
}
return *this;
}
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
严格来说,这里对句柄使用std::move()
并非必需,但这样做没有坏处,并且能提醒你我们将移动语义委托给了成员。
# 14.3 产生或返回值的协程
在介绍了一个使用co_await
的示例后,我们还应该介绍协程的另外两个关键字:
co_yield
允许协程在每次被挂起时产生一个值。co_return
允许协程在结束时返回一个值。
# 14.3.1 使用co_yield
通过使用co_yield
,协程在被挂起时可以产生中间结果。
一个明显的例子是一个“生成”值的协程。作为之前示例的变体,我们可以遍历一些值直到max
,并将它们产生给协程的调用者,而不是仅仅打印它们。为此,协程代码如下:
[`coro/coyield.hpp`]
#include <iostream>
#include "corogen.hpp" // 用于CoroGen
CoroGen coro(int max) {
std::cout << " CORO " << max << " start\n ";
for (int val = 1; val <= max; ++val) {
// 打印下一个值:
std::cout << " CORO " << val << "/ " << max << "\n";
// 产生下一个值:
co_yield val; // 带值挂起
}
std::cout << " CORO " << max << " end\n ";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
通过使用co_yield
,协程产生中间结果。当协程执行到co_yield
时,它会挂起协程,并提供co_yield
后面表达式的值:
co_yield val; // 对承诺调用yield_value(val)
协程框架会将其映射为对协程承诺的yield_value()
调用,承诺可以定义如何处理这个中间结果。在我们的例子中,我们将值存储在承诺的一个成员中,这样就可以在协程接口中使用:
struct promise_type {
int coroValue = 0; // 来自co_yield的最后一个值
auto yield_value(int val) { // 对co_yield的反应
coroValue = val; // - 本地存储值
return std::suspend_always{}; // - 挂起协程
}
...
};
2
3
4
5
6
7
8
在将值存储到承诺中后,我们返回std::suspend_always{}
,这会真正挂起协程。我们可以在这里编写不同的行为,使协程(有条件地)继续执行。该协程可以这样使用:
[`coro/coyield.cpp`]
#include "coyield.hpp"
#include <iostream>
int main() {
// 启动协程:
auto coroGen = coro(3); // 初始化协程
std::cout << "coro() started\n ";
// 循环恢复协程,直到它结束:
while (coroGen.resume()) { // 恢复
auto val = coroGen.getValue();
std::cout << "coro() suspended with " << val << "\n";
}
std::cout << "coro() done\n ";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
同样,通过调用coro(3)
,我们将协程初始化为计数到3。每当我们对返回的协程接口调用resume()
时,协程就会“计算”并产生下一个值。
然而,resume()
不会返回产生的值(它仍然返回是否有必要再次恢复协程)。为了获取下一个值,我们提供并使用getValue()
。因此,该程序的输出如下:
coro() started
CORO 3 start
CORO 1/3
coro() suspended with 1
CORO 2/3
coro() suspended with 2
CORO 3/3
coro() suspended with 3
CORO 3 end
coro() done
2
3
4
5
6
7
8
9
10
协程接口必须处理产生的值,并为调用者提供略有不同的API。因此,我们使用了不同的类型名称:CoroGen
。这个名称表明,我们拥有的不是一个时不时被挂起的任务协程,而是一个每次挂起时都会生成值的协程。CoroGen
类型可能如下定义:
[`coro/corogen.hpp`]
#include <coroutine>
#include <exception> // 用于terminate()
class [[nodiscard]] CoroGen {
public:
// 初始化状态和定制化的成员
struct promise_type;
using CoroHdl = std::coroutine_handle<promise_type>;
private:
CoroHdl hdl;
public:
struct promise_type {
int coroValue = 0;
auto yield_value(int val) {
coroValue = val;
return std::suspend_always{};
}
// 原生协程句柄
// 来自co_yield的最新值
// 对co_yield的反应:
// - 本地存储值
// - 挂起协程
// 常规成员:
auto get_return_object() { return CoroHdl::from_promise(*this); }
auto initial_suspend() { return std::suspend_always{}; }
void return_void() { }
void unhandled_exception() { std::terminate(); }
auto final_suspend() noexcept { return std::suspend_always{}; }
};
// 构造函数和析构函数:
CoroGen(auto h) : hdl{h} { }
~CoroGen() { if (hdl) hdl.destroy(); }
// 不支持复制或移动:
CoroGen(const CoroGen&) = delete;
CoroGen& operator=(const CoroGen&) = delete;
// API:
// - 恢复协程
bool resume() const {
if (!hdl || hdl.done()) {
return false; // 没有(更多)内容需要处理
}
hdl.resume(); // 恢复
return !hdl.done();
}
// - 获取co_yield产生的值
int getValue() const {
return hdl.promise().coroValue;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
一般来说,这里使用的协程接口定义遵循协程接口的一般原则。有两点不同:
- 承诺提供了
yield_value()
成员,每当到达co_yield
时就会调用该成员。 - 对于协程接口的外部API,
CoroGen
提供了getValue()
。它返回存储在承诺中的最后一个产生的值:
class CoroGen {
public:
...
int getValue() const {
return hdl.promise().coroValue;
}
};
2
3
4
5
6
7
# 迭代协程产生的值
我们也可以使用一个类似范围的协程接口(提供一个迭代挂起产生的值的API):
[`coro/cororange.cpp`]
#include "cororange.hpp"
#include <iostream>
#include <vector>
int main() {
auto gen = coro(3); // 初始化协程
std::cout << "--- coro() started\n ";
// 循环恢复协程以获取下一个值:
for (const auto& val : gen) {
std::cout << " val : " << val << "\n";
}
std::cout << "--- coro() done\n ";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
我们只需要协程返回一个略有不同的生成器(见coro/cororange.hpp
):
Generator<int> coro(int max)
{
std::cout << "CORO " << max << " start\n ";
...
}
2
3
4
5
如你所见,我们使用了一个通用的协程接口来处理传递类型(这里是int
)的生成器值。一个非常简单的实现(展示了要点但存在一些缺陷)可能如下:
[`coro/generator.hpp`]
#include <coroutine>
#include <exception>
#include <cassert> // 用于terminate()
// 用于assert()
template<typename T>
class [[nodiscard]] Generator {
public:
// 定制点
struct promise_type {
T coroValue{}; // 来自co_yield的最后一个值
auto yield_value(T val) { // 对co_yield的反应
coroValue = val; // - 本地存储值
return std::suspend_always{}; // - 挂起协程
}
auto get_return_object() {
return std::coroutine_handle<promise_type>::from_promise(*this);
}
auto initial_suspend() { return std::suspend_always{}; }
void return_void() { }
void unhandled_exception() { std::terminate(); }
auto final_suspend() noexcept { return std::suspend_always{}; }
};
private:
std::coroutine_handle<promise_type> hdl; // 原生协程句柄
public:
// 构造函数和析构函数:
Generator(auto h) : hdl{h} { }
~Generator() { if (hdl) hdl.destroy(); }
// 不支持复制或移动:
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
// 恢复协程并访问其值的API:
// - 带有begin()和end()的迭代器接口
struct iterator {
std::coroutine_handle<promise_type> hdl; // 结束迭代器为nullptr
iterator(auto p) : hdl{p} {
}
void getNext() {
if (hdl) {
hdl.resume(); // 恢复
if (hdl.done()) {
hdl = nullptr;
}
}
}
int operator*() const {
assert(hdl != nullptr);
return hdl.promise().coroValue;
}
iterator operator++() {
getNext(); // 恢复以获取下一个值
return *this;
}
bool operator== (const iterator& i) const = default;
};
iterator begin() const {
if (!hdl || hdl.done()) {
return iterator{nullptr};
}
iterator itor{hdl}; // 初始化迭代器
itor.getNext(); // 恢复以获取第一个值
return itor;
}
iterator end() const {
return iterator{nullptr};
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
关键在于协程接口提供了begin()
和end()
成员以及一个迭代器类型iterator
来迭代这些值:
begin()
在恢复以获取第一个值后返回迭代器。迭代器在内部存储协程句柄,以便知道其状态。- 迭代器的
operator++()
则返回下一个值。 end()
返回一个表示范围结束的状态。其hdl
为nullptr
。当没有更多值时,迭代器也会进入这个状态。- 迭代器的
operator==()
是默认生成的,通过比较两个迭代器的句柄来比较状态。
C++23可能会在其标准库中提供这样一个协程接口std::generator<>
,其API会更加复杂和健壮(见http://wg21.link/p2502)。
# 14.3.2 使用co_return
通过使用co_return
,协程可以在结束时向调用者返回一个结果。考虑以下示例:
[`coro/coreturn.cpp`]
#include <iostream>
#include <vector>
#include <ranges>
#include <coroutine>
#include "resulttask.hpp"
ResultTask<double> average(auto coll) {
double sum = 0;
for (const auto& elem : coll) {
std::cout << " process " << elem << "\n";
sum = sum + elem;
co_await std::suspend_always{}; // 挂起
}
co_return sum / std::ranges::ssize(coll); // 返回计算出的平均值
}
int main() {
std::vector values{0, 8, 15, 47, 11, 42};
// 启动协程:
auto task = average(std::views::all(values));
// 循环恢复协程,直到所有值都被处理:
std::cout << "resume()\n ";
while (task.resume()) { // 恢复
std::cout << "resume() again\n ";
}
// 打印协程的返回值:
std::cout << "result : " << task.getResult() << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
在这个程序中,main()
启动了一个协程average()
,它遍历传递集合中的元素,并将元素值累加到初始和中。处理完每个元素后,协程被挂起。最后,协程通过将总和除以元素数量返回平均值。
注意,需要使用co_return
来返回协程的结果,使用return
是不允许的。
协程接口在ResultTask
类中定义,该类针对返回值的类型进行了参数化。这个接口提供resume()
来在协程挂起时恢复它。此外,它还提供getResult()
来在协程结束后获取其返回值。
ResultTask<>
协程接口如下所示:
[`coro/resulttask.hpp`]
#include <coroutine>
#include <exception> // 用于terminate()
template<typename T>
class [[nodiscard]] ResultTask {
public:
// 定制点
struct promise_type {
T result{}; // co_return的值
void return_value(const auto& value) { // 对co_return的反应
result = value; // - 本地存储值
}
auto get_return_object() {
return std::coroutine_handle<promise_type>::from_promise(*this);
}
auto initial_suspend() { return std::suspend_always{}; }
void unhandled_exception() { std::terminate(); }
auto final_suspend() noexcept { return std::suspend_always{}; }
};
private:
std::coroutine_handle<promise_type> hdl; // 原生协程句柄
public:
// 构造函数和析构函数:
// - 不支持复制或移动
ResultTask(auto h) : hdl{h} { }
~ResultTask() { if (hdl) hdl.destroy(); }
ResultTask(const ResultTask&) = delete;
ResultTask& operator=(const ResultTask&) = delete;
// API:
// - resume()恢复协程
bool resume() const {
if (!hdl || hdl.done()) {
return false; // 没有(更多)内容需要处理
}
hdl.resume(); // 恢复
return !hdl.done();
}
// - getResult()获取co_return的最后一个值
T getResult() const {
return hdl.promise().result;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
同样,这个定义遵循协程接口的一般原则。
然而,这次我们支持返回值。因此,在承诺类型中,不再提供定制点return_void()
。取而代之的是提供return_value()
,当协程到达co_return
表达式时会调用它:
template<typename T>
class ResultTask {
public:
struct promise_type {
T result{}; // co_return的值
void return_value(const auto& value) { // 对co_return的反应
result = value; // - 本地存储值
}
...
};
...
};
2
3
4
5
6
7
8
9
10
11
12
然后,每当调用getResult()
时,协程接口就会返回这个值:
template<typename T>
class ResultTask {
public:
...
T getResult() const {
return hdl.promise().result;
}
};
2
3
4
5
6
7
8
# return_void()
和return_value()
注意,如果协程的实现方式使得它有时可能返回值,有时可能不返回值,这是未定义行为。这样的协程是无效的:
ResultTask<int> coroUB( ... ) {
if ( ... ) {
co_return 42;
}
}
2
3
4
5
# 14.4 协程可等待对象和等待器
到目前为止,我们已经了解了如何通过协程接口(封装协程句柄及其承诺)从外部控制协程。然而,协程还可以(并且必须)自行提供另一个配置点:可等待对象(awaitables,等待器是实现可等待对象的一种特殊方式)。
这些术语的关联如下:
- “可等待对象”指的是
co_await
操作符所需要的操作数。因此,可等待对象是co_await
能够处理的所有对象。 - “等待器”是实现可等待对象的一种特定(且典型)的方式。它必须提供三个特定的成员函数,用于处理协程的挂起和恢复。
每当调用co_await
(或co_yield
)时,就会使用可等待对象。它们允许提供代码来拒绝挂起请求(暂时或在特定条件下),或者为挂起和恢复执行一些逻辑。
你已经使用过两种等待器类型:std::suspend_always
和std::suspend_never
。
# 14.4.1 等待器
在协程挂起或恢复时可以调用等待器。表“等待器的特殊成员函数”列出了等待器必须提供的操作。
操作 | 效果 |
---|---|
await_ready() await_suspend(awaitHdl) await_resume() | 返回是否(当前)禁用挂起 处理挂起 处理恢复 |
表14.1 等待器的特殊成员函数
关键函数是await_suspend()
和await_resume()
:
await_ready()
这个函数在协程即将挂起之前被调用。它用于(暂时)完全关闭挂起。如果返回true
,协程根本不会挂起。
这个函数通常只返回false
(“不,不要阻塞/忽略任何挂起”)。为了节省挂起的开销,在挂起没有意义的情况下(例如,挂起取决于某些数据是否可用),它可能会有条件地返回true
。
请注意,在这个函数中,协程尚未挂起。它不应用于直接或间接调用它所针对的协程的resume()
或destroy()
。
auto await_suspend(awaitHdl)
这个函数在协程挂起之后立即被调用。
参数awaitHdl
是挂起的协程的句柄,其类型为std::coroutine_handle<PromiseType>
。
在这个函数中,你可以指定接下来要做什么,包括立即恢复挂起或正在等待的协程。不同的返回类型允许你以不同的方式指定。你甚至可以通过直接将控制权转移到另一个协程来有效地跳过挂起。
你甚至可以在这里销毁协程。不过,在这种情况下,要确保不再在任何其他地方使用该协程(例如在协程接口中调用done()
或destroy()
)。
auto await_resume()
这个函数在协程成功挂起后恢复时被调用。
它可以返回一个值,这个值将是co_await
表达式产生的值。考虑下面这个简单的等待器:
// coro/awaiter.hpp
#include <iostream>
class Awaiter {
public:
bool await_ready() const noexcept {
std::cout << " await_ready()\n " ;
return false; // 进行挂起
}
void await_suspend(auto hdl) const noexcept {
std::cout << " await_suspend()\n " ;
}
void await_resume() const noexcept {
std::cout << " await_resume()\n " ;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在这个等待器中,我们跟踪等待器的各个函数何时被调用。由于await_ready()
返回false
且await_suspend()
不返回任何值,这个等待器接受挂起(不恢复其他任何东西)。这是标准等待器std::suspend_always{}
的行为,只是添加了一些打印语句。其他返回类型/值可以提供不同的行为。
一个协程可能会如下使用这个等待器(完整代码见coro/awaiter.cpp
):
CoroTask coro(int max)
{
std::cout << " CORO start\n " ;
for (int val = 1; val <= max; ++val) {
std::cout << " CORO " << val << "\n";
co_await Awaiter{}; // 使用我们自己的等待器进行挂起
}
std::cout << " CORO end\n " ;
}
2
3
4
5
6
7
8
9
假设我们这样使用这个协程:
auto coTask = coro(2);
std::cout << "started\n " ;
std::cout << "loop\n " ;
while (coTask.resume()) { // 恢复执行
std::cout << " suspended\n " ;
}
std::cout << "done\n " ;
2
3
4
5
6
7
结果,我们得到以下输出:
started
loop
CORO start
CORO 1
await_ready()
await_suspend()
suspended
await_resume()
CORO 2
await_ready()
await_suspend()
suspended
await_resume()
CORO end
done
2
3
4
5
6
7
8
9
10
11
12
13
14
15
我们稍后将讨论等待器的更多细节。
# 14.4.2 标准等待器
C++标准库提供了我们已经使用过的两个简单等待器:
std::suspend_always
std::suspend_never
它们的定义非常直接:
namespace std {
struct suspend_always {
constexpr bool await_ready() const noexcept { return false; }
constexpr void await_suspend(coroutine_handle<>) const noexcept { }
constexpr void await_resume() const noexcept { }
};
struct suspend_never {
constexpr bool await_ready() const noexcept { return true; }
constexpr void await_suspend(coroutine_handle<>) const noexcept { }
constexpr void await_resume() const noexcept { }
};
}
2
3
4
5
6
7
8
9
10
11
12
它们在挂起或恢复时都不执行任何操作。然而,它们的await_ready()
返回值不同:
- 如果在
await_ready()
中返回false
(并且await_suspend()
不返回任何值),suspend_always
会接受每次挂起,这意味着协程会返回给调用者。 - 如果在
await_ready()
中返回true
,suspend_never
永远不会接受任何挂起,这意味着协程会继续执行(await_suspend()
永远不会被调用)。
如前所述,这些等待器通常用作co_await
的基本等待器:
co_await std::suspend_always{};
它们也在协程承诺的initial_suspend()
和final_suspend()
中返回使用。
# 14.4.3 恢复子协程
通过使协程接口成为可等待对象(提供等待器API),我们可以让它以一种方式处理子协程,使得子协程的挂起点成为主协程的挂起点。
这使得程序员可以避免像corocoro.cpp
示例中那样,为恢复操作编写嵌套循环。
对于CoroTask
的第一个实现,我们只需要进行以下修改:
- 协程接口必须知道它的子协程(如果有的话):
class CoroTaskSub {
public:
struct promise_type;
using CoroHdl = std::coroutine_handle<promise_type>;
private:
CoroHdl hdl; // 原生协程句柄
public:
struct promise_type {
CoroHdl subHdl = nullptr; // 子协程(如果有的话)
...
}
...
};
2
3
4
5
6
7
8
9
10
11
12
13
- 协程接口必须提供等待器的API,以便该接口可以用作
co_await
的可等待对象:
class CoroTaskSub {
public:
...
bool await_ready() { return false; } // 不跳过挂起
void await_suspend(auto awaitHdl) {
awaitHdl.promise().subHdl = hdl; // 存储子协程并挂起
}
void await_resume() { }
};
2
3
4
5
6
7
8
9
- 协程接口必须恢复尚未完成的最深层子协程(如果有的话):
class CoroTaskSub {
public:
...
bool resume() const {
if (!hdl || hdl.done()) {
return false; // 没有(更多)内容要处理
}
// 找到尚未完成的最深层子协程:
CoroHdl innerHdl = hdl;
while (innerHdl.promise().subHdl && ! innerHdl.promise().subHdl.done()) {
innerHdl = innerHdl.promise().subHdl;
}
innerHdl.resume(); // 恢复执行
return !hdl.done();
}
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
coro/corotasksub.hpp
提供了完整代码。有了这些,你可以这样做:
// coro/corocorosub.cpp
#include <iostream>
#include "corotasksub.hpp " // 用于CoroTaskSub
CoroTaskSub coro() {
std::cout << " coro(): PART1\n " ;
co_await std::suspend_always{}; // 挂起
std::cout << " coro(): PART2\n " ;
}
CoroTaskSub callCoro() {
std::cout << " callCoro(): CALL coro()\n " ;
co_await coro(); // 调用子协程
std::cout << " callCoro(): coro() done\n " ;
co_await std::suspend_always{}; // 挂起
std::cout << " callCoro(): END\n " ;
}
int main() {
auto coroTask = callCoro(); // 初始化协程
std::cout << "MAIN : callCoro() initialized\n " ;
while (coroTask.resume()) { // 恢复执行
std::cout << "MAIN : callCoro() suspended\n " ;
}
std::cout << "MAIN : callCoro() done\n " ;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
在callCoro()
内部,我们现在可以通过将coro()
传递给co_await
来调用它:
CoroTaskSub callCoro()
{
...
co_await coro(); // 调用子协程
...
}
2
3
4
5
6
这里发生的事情如下:
- 对
coro()
的调用初始化了协程,并返回其类型为CoroTaskSub
的协程接口。 - 因为这个类型有一个等待器接口,所以该协程接口可以用作
co_await
的可等待对象。
然后co_await
操作符会使用两个操作数进行调用:
- 调用它的等待协程。
- 被调用的协程。
- 应用等待器的常规行为:
- 调用
await_ready()
,询问是否通常拒绝等待请求。答案是“否”(false
)。 - 调用
await_suspend()
,并将等待协程的句柄作为参数传递。
- 调用
- 通过将子协程的句柄(调用
await_suspend()
时针对的对象)存储为传递的等待句柄的子协程,callCoro()
现在知道了它的子协程。 - 通过在
await_suspend()
中不返回任何值,最终接受了挂起,这意味着co_await coro();
会挂起callCoro()
并将控制流转移回调用者main()
。 - 当
main()
随后恢复callCoro()
时,CoroTaskSub::resume()
的实现会找到coro()
作为其最深层的子协程并恢复它。 - 每当子协程挂起时,
CoroTaskSub::resume()
会返回给调用者。 - 这种情况会一直持续到子协程完成。接下来的恢复操作将恢复
callCoro()
。
结果,程序有如下输出:
MAIN: callCoro() initialized
callCoro(): CALL coro()
MAIN: callCoro() suspended
coro(): PART1
MAIN: callCoro() suspended
coro(): PART2
MAIN: callCoro() suspended
callCoro(): coro() done
MAIN: callCoro() suspended
callCoro(): END
MAIN: callCoro() done
2
3
4
5
6
7
8
9
10
11
# 直接恢复被调子协程
注意,前面CoroTaskSub
的实现使得
co_await coro(); // 调用子协程
成为一个挂起点。我们初始化了coro()
,但在启动它之前,就将控制流转移回了callCoro()
的调用者。
你也可以在此处直接启动coro()
。要做到这一点,只需要对await_suspend()
做如下修改:
auto await_suspend(auto awaitHdl) { | |
---|---|
awaitHdl.promise().subHdl = hdl; | // 存储子协程 |
return hdl; | // 并直接恢复它 |
} |
如果await_suspend()
返回一个协程句柄,那么该协程会立即被恢复。这将我们程序的行为改变为如下输出:
MAIN: callCoro() initialized
callCoro(): CALL coro()
coro(): PART1
MAIN: callCoro() suspended
callCoro(): coro() done
MAIN: callCoro() suspended
callCoro(): END
MAIN: callCoro() done
2
3
4
5
6
7
8
返回另一个要恢复的协程可以用于在co_await
时恢复任何其他协程,我们稍后会将其用于对称转移。
如你所见,await_suspend()
的返回类型可以有所不同。
在任何情况下,如果await_suspend()
表明不应该挂起,await_resume()
将永远不会被调用。
# 14.4.4 从挂起状态向协程传递值
可等待对象(awaitables)和等待者(awaiters)的另一个应用是允许在挂起后将值传递回协程。考虑以下协程:
// coro/coyieldback.hpp
#include <iostream>
#include "corogenback.hpp" //for CoroGenBack
CoroGenBack coro(int max) {
std::cout << " CORO " << max << " start\n";
for (int val = 1; val <= max; ++val) {
// 打印下一个值:
std::cout << " CORO " << val << "/" << max << "\n";
// 产生下一个值:
auto back = co_yield val; // 带值挂起并等待响应
std::cout << " CORO => " << back << "\n";
}
std::cout << " CORO " << max << " end\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
同样,该协程会迭代到最大值,并在挂起时将当前值产生给调用者。不过这次,co_yield
会从调用者将一个值回传给协程:
auto back = co_yield val; // 带值挂起并等待响应
为了支持这一点,协程接口提供了一些修改后的常用API:
// coro/corogenback.hpp
#include "backawaiter.hpp"
#include <coroutine>
#include <exception> //for terminate()
#include <string>
class [[nodiscard]] CoroGenBack {
public:
struct promise_type;
using CoroHdl = std::coroutine_handle<promise_type>;
private:
// 原生协程句柄
CoroHdl hdl;
public:
// 挂起时传递给调用者的值
struct promise_type {
int coroValue = 0;
std::string backValue;
auto yield_value(int val) {
coroValue = val;
backValue.clear();
return BackAwaiter<CoroHdl>{};
}
// 挂起后从调用者传回来的值
// 对co_yield的响应
// - 本地存储值
// - 重新初始化回传值
// - 使用特殊的等待者用于响应
// 常规成员:
auto get_return_object() { return CoroHdl::from_promise(*this); }
auto initial_suspend() { return std::suspend_always{}; }
void return_void() { }
void unhandled_exception() { std::terminate(); }
auto final_suspend() noexcept { return std::suspend_always{}; }
};
// 构造函数和析构函数:
CoroGenBack(auto h) : hdl{h} { }
~CoroGenBack() { if (hdl) hdl.destroy(); }
// 禁止复制和移动:
CoroGenBack(const CoroGenBack&) = delete;
CoroGenBack& operator=(const CoroGenBack&) = delete;
// API:
// - 恢复协程:
bool resume() const {
if (!hdl || hdl.done()) {
return false; // 没有(更多)内容需要处理
}
hdl.resume(); // 恢复执行
return !hdl.done();
}
// - 获取co_yield产生的值:
int getValue() const {
return hdl.promise().coroValue;
}
// - 挂起后将值回传给协程:
void setBackValue(const auto& val) {
hdl.promise().backValue = val;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
修改内容如下:
promise_type
有一个新成员backValue
。yield_value()
返回一个BackAwaiter
类型的特殊等待者。- 协程接口有一个新成员
setBackValue()
,用于将值回传给协程。
让我们详细了解这些变化。
# Promise数据成员
通常,协程接口的承诺类型是协程与调用者共享和交换数据的最佳位置:
class CoroGenBack {
// ...
public:
struct promise_type {
int coroValue = 0;
std::string backValue;
// ...
};
// ...
};
2
3
4
5
6
7
8
9
10
我们现在有两个数据成员:
coroValue
用于从协程传递给调用者的值。backValue
用于从调用者传递回协程的值。
# 用于co_yield的新等待器
为了通过承诺(promise)将值传递给调用者,我们仍然需要实现yield_value()
。不过,这次yield_value()
不会返回std::suspend_always{}
。相反,它返回一个BackAwaiter
类型的特殊等待器,这个等待器能够产生从调用者返回的值:
class CoroGenBack {
...
public:
struct promise_type {
int coroValue = 0;
std::string backValue;
auto yield_value(int val) {
coroValue = val;
backValue.clear();
return BackAwaiter<CoroHdl>{};
}
...
};
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 挂起时传递给调用者的值
// 挂起后从调用者返回的值
// 对co_yield的反应
// - 本地存储值
// - 重新初始化返回值
// - 使用特殊等待器获取响应
2
3
4
5
6
等待器的定义如下:
// coro/backawaiter.hpp
template<typename Hdl>
class BackAwaiter {
Hdl hdl = nullptr; // 从await_suspend()保存的协程句柄,用于await_resume()
public:
BackAwaiter() = default;
bool await_ready() const noexcept {
return false; // 进行挂起
}
void await_suspend(Hdl h) noexcept {
hdl = h; // 保存句柄,以便访问其promise
}
auto await_resume() const noexcept {
return hdl.promise().backValue; // 返回存储在promise中的返回值
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这个等待器所做的就是将传递给await_suspend()
的协程句柄存储在本地,以便在调用await_resume()
时使用。在await_resume()
中,我们使用这个句柄从其promise(由调用者使用setBackValue()
存储)中产生backValue
。
yield_value()
的返回值用作co_yield
表达式的值:
auto back = co_yield val; // co_yield产生yield_value()的返回值
BackAwaiter
是通用的,它可以用于所有具有任意类型backValue
成员的协程句柄。
# 用于挂起后返回值的协程接口
和往常一样,我们必须确定协程接口提供的API,以便让调用者返回响应。一种简单的方法是为此提供成员函数setBackValue()
:
class CoroGenBack {
...
public:
struct promise_type {
int coroValue = 0; // 挂起时传递给调用者的值
std::string backValue; // 挂起后从调用者返回的值
...
};
...
// - 挂起后将值设置回协程:
void setBackValue(const auto& val) {
hdl.promise().backValue = val;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
你也可以提供一个接口,返回对backValue
的引用,以支持对其进行直接赋值。
# 使用协程
该协程的使用方式如下:
// coro/coyieldback.cpp
#include "coyieldback.hpp"
#include <iostream>
#include <vector>
int main() {
// 启动协程:
auto coroGen = coro(3); // 初始化协程
std::cout << "**** coro() started\n";
// 循环恢复协程,直到它结束:
std::cout << "\n**** resume coro()\n";
while (coroGen.resume()) { // 恢复
// 处理co_yield产生的值:
auto val = coroGen.getValue();
std::cout << "**** coro() suspended with " << val << "\n";
// 设置响应(co_yield产生的值):
std::string back = (val % 2 != 0? "OK " : "ERR ");
std::cout << "\n**** resume coro() with back value : " << back << "\n";
coroGen.setBackValue(back); // 将值设置回协程
}
std::cout << "**** coro() done\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这个程序的输出如下:
**** coro() started
**** resume coro()
CORO 3 start
CORO 1/3
**** coro() suspended with 1
**** resume coro() with back value: OK
CORO => OK
CORO 2/3
**** coro() suspended with 2
**** resume coro() with back value: ERR
CORO => ERR
CORO 3/3
**** coro() suspended with 3
**** resume coro() with back value: OK
CORO => OK
CORO 3 end
**** coro() done
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
协程接口有足够的灵活性来以不同方式处理这些事情。例如,我们可以将响应作为resume()
的参数传递,或者在一个成员中共享产生的值及其响应。
# 14.5 补充说明
对协程支持的请求最初由奥利弗·科瓦尔克(Oliver Kowalke)和纳特·古德speed(Nat Goodspeed)在http://wg21.link/n3708 (opens new window)中作为纯库扩展提出。
由于该特性的复杂性,通过http://wg21.link/n4403 (opens new window)制定了一个协程技术规范(实验性技术规范,Coroutine TS)来研究细节。
最终将协程合并到C++20标准中的措辞由戈尔·尼沙诺夫(Gor Nishanov)在http://wg21.link/p0912r5 (opens new window)中制定。