1 教程
粗略地说,协程是可以相互调用但不共享堆栈的函数,因此可以在任何时候灵活地暂停执行以进入不同的协程。在C++的真正精神中,C++20协程被实现为一个埋在垃圾堆下面的漂亮的小金块,你必须费力才能访问到漂亮的部分。坦率地说,我对这个设计感到失望,因为最近的其他语言变化都做得更有品味,但遗憾的是,没有协同工作。进一步混淆协程的是,C++标准库实际上并没有提供访问协程所需的垃圾堆,所以你实际上必须滚动自己的垃圾,然后费力地浏览它。无论如何,无论如何,我会尽量把任何进一步的评论留到这篇博客文章的结尾…
需要注意的另一个复杂情况是,C++协程通常使用术语future和promise来解释甚至指定。这些术语与C++<future>标头中可用的类型std::future和std::promise无关。具体来说,std::promise不是协程promise对象的有效类型。除了这一段,我的博客文章中没有任何内容与std::future或std::promise有关。
有了这些,C++20给我们的一个很好的小金块是一个名为co_await的新运算符。粗略地说,表达式“co-await a;”做下列事情:
- 1 确保当前函数中的所有局部变量(必须是协程)都保存到堆分配的对象中。
- 2 创建一个可调用对象,当调用该对象时,该对象将在计算co_await表达式后立即恢复协程的执行。
- 3 调用(或者更准确地说,跳转到)co_await的目标对象a的方法,将步骤2中的可调用对象传递给该方法。
请注意,步骤3中的方法在返回时,不会将控制权返回到协程。只有当从步骤2调用的可调用程序被调用时,协程才会恢复执行。如果您使用了支持call with current continuation,的语言,或者使用了Haskell Cont monad,那么步骤2中的可调用函数有点像continuation。
2 使用协程编译代码
由于编译器还没有完全支持C++20,因此您需要确保编译器实现协同程序来使用它们。我使用的是GCC 10.2,它似乎支持协同程序,只要您使用以下标志进行编译:
g++ -fcoroutines -std=c++20
Clang的支持并不那么遥远。您需要安装llvm libc++并使用以下代码进行编译:
clang++ -std=c++20 -stdlib=libc++ -fcoroutines-ts
不幸的是,对于clang,您还需要将协程头包含为<experiment/coroutine>,而不是<coroutine>。此外,许多类型被命名为std::experimental::xxx,而不是std::xxx。因此,在撰写本文时,下面的示例不会随clang一起开箱即用编译,但理想情况下应该随未来的发行版一起编译。
如果你想玩,这篇博客文章中的所有演示都可以在一个文件corodemo.cc中获得。
3 协程句柄
如前所述,新的co-await运算符确保函数的当前状态捆绑在堆的某个位置,并创建一个可调用对象,该对象的调用将继续执行当前函数。可调用对象的类型为std::coroutine_handle<>。
协程句柄的行为非常像C指针。它可以很容易地复制,但它没有析构函数来释放与协程状态相关的内存。为了避免内存泄漏,通常必须通过调用coroutine_handle::destroy方法来销毁协程状态(尽管在某些情况下,协程可以在完成时自行销毁)。同样像C指针一样,一旦协程句柄被破坏,引用同一协程的协程句柄将指向垃圾,并在调用时表现出未定义的行为。从好的方面来说,即使控制多次进出协同程序,协同程序句柄对协同程序的整个执行都是有效的。
现在让我们更具体地看一下co_await的作用。当您计算表达式co_await时,编译器会创建一个协程句柄,并将其传递给方法a.await_suspend(coroutine_handle)。类型必须支持某些方法,有时被称为“awaitable”对象或“awaiter”
现在让我们来看一个使用co_await的完整程序。现在,忽略ReturnObject类型——它只是我们访问co_await时必须通过的垃圾的一部分。
#include <concepts>
#include <coroutine>
#include <exception>
#include <iostream>
struct ReturnObject {
struct promise_type {
ReturnObject get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
};
};
struct Awaiter {
std::coroutine_handle<> *hp_;
constexpr bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) { *hp_ = h; }
constexpr void await_resume() const noexcept {}
};
ReturnObject
counter(std::coroutine_handle<> *continuation_out)
{
Awaiter a{continuation_out};
for (unsigned i = 0;; ++i) {
co_await a;
std::cout << "counter: " << i << std::endl;
}
}
void
main1()
{
std::coroutine_handle<> h;
counter(&h);
for (int i = 0; i < 3; ++i) {
std::cout << "In main1 function\n";
h();
}
h.destroy();
}
运行结果:
In main1 function
counter: 0
In main1 function
counter: 1
In main1 function
counter: 2
这里的计数器是一个永远计数的函数,递增并打印一个无符号整数。尽管计算很愚蠢,但该示例令人兴奋的是,即使控制在计数器和调用它的函数main之间反复切换,变量i也会保持其值。
在本例中,我们使用std::coroutine_handle<>*调用counter,我们将其固定在Awaiter类型中。在其await_suspend方法中,此类型将co_await生成的协程句柄存储到main1的协程手柄中。每次main1调用协程句柄时,它都会在计数器中再触发一次循环迭代,然后在co_await语句中再次暂停执行。
为了简单起见,我们在每次调用await_suspend时都存储协程句柄,但句柄在不同调用之间不会发生变化。(回想一下,句柄就像一个指向协程状态的指针,所以虽然i的值在这种状态下可能会改变,但指针本身保持不变。)我们可以很容易地写道:
void
Awaiter::await_suspend(std::coroutine_handle<> h)
{
if (hp_) {
*hp_ = h;
hp_ = nullptr;
}
}
您会注意到Awaiter上还有另外两个方法,因为这些方法是语言所必需的。await_ready是一个优化。如果返回true,那么co_await不会挂起该函数。当然,通过恢复(或不挂起)当前协程,您可以在await_suspend中实现相同的效果,但在调用await_suspend之前,编译器必须将所有状态绑定到协程句柄引用的堆对象中,这可能会非常昂贵。最后,这里的方法await_resume返回void,但如果它返回一个值,那么这个值将是co_await表达式的值。
<coroutine>头提供了两个预定义的awaiter,std::suspend_allways和std::suspend_never。正如它们的名称所暗示的,suspend_allways::await_ready总是返回false,而suspend_never::await_ready始终返回true。这些类上的其他方法都是空的,什么也不做。
4 协程返回对象
在前面的示例中,我们忽略了计数器的返回类型。但是,该语言限制了协程的允许返回类型。具体来说,协程的返回类型(称为R)必须是嵌套类型为R::promise_type的对象类型。在其他要求中,R::promise_type必须包括一个返回外部类型R实例的方法R get_return_object()。get_return_object()的结果是协程函数的返回值,在本例中为counter()。注意,在许多关于协程的讨论中,返回类型R被称为future,但为了清楚起见,我只将其称为返回对象类型。
与其将coroutine_handle<>*传递到counter中,不如直接从counter()返回句柄。如果我们将协程句柄放在返回对象中,我们就可以做到这一点。由于promise_type::get_return_object计算返回对象,我们只需要该方法将协程句柄粘贴到返回对象中。我们如何从get_return_object中获得协程句柄?碰巧,coroutine_handle引用的协程状态包含一个已知偏移量的promise_type实例,因此std::coroutine_handle允许我们从promise对象计算一个协程句柄。
到目前为止,我们已经掩盖了协同例程句柄的模板参数,它们实际上是这样声明的:
template<class Promise = void> struct coroutine_handle;
任何类型T的std::coroutine_handle<T>都可以隐式转换为std::coroutine_handle<void>。可以调用任意一种类型以恢复具有相同效果的协同程序。但是,非void类型允许您在协程句柄和处于协程状态的promise_type之间来回转换。具体来说,在promise类型中,我们可以使用静态方法coroutine_handle::from_promise来获得协程句柄:
// from within a method of promise_type
std::coroutine_handle<promise_type>::from_promise(*this)
现在,我们已经拥有了将协程句柄粘贴到新函数counter2的返回对象中所需的一切。以下是修改后的示例:
struct ReturnObject2 {
struct promise_type {
ReturnObject2 get_return_object() {
return {
// Uses C++20 designated initializer syntax
.h_ = std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
};
std::coroutine_handle<promise_type> h_;
operator std::coroutine_handle<promise_type>() const { return h_; }
// A coroutine_handle<promise_type> converts to coroutine_handle<>
operator std::coroutine_handle<>() const { return h_; }
};
ReturnObject2
counter2()
{
for (unsigned i = 0;; ++i) {
co_await std::suspend_always{};
std::cout << "counter2: " << i << std::endl;
}
}
void
main2()
{
std::coroutine_handle<> h = counter2();
for (int i = 0; i < 3; ++i) {
std::cout << "In main2 function\n";
h();
}
h.destroy();
}
运行结果:
In main2 function
counter2: 0
In main2 function
counter2: 1
In main2 function
counter2: 2
关于上面的代码需要注意的几点。首先,由于我们不再需要awaiter来保存协程句柄(因为我们已经将句柄放入返回对象中),所以我们只运行co_await std::suspend_allways{}。第二,注意返回的对象超出了范围,并在main2的第一行中被销毁。然而,coroutine_handle类似于C指针,而不是对象。我们销毁了包含ReturnObject2::h的对象并不重要,因为我们已经将指针复制到了h中。另一方面,需要有人回收h指向的空间,我们在main2的末尾通过调用h.destroy()来完成这一操作。特别是,如果任何代码调用counter2()并忽略返回值(或者未能销毁ReturnObject2对象中的句柄),就会造成内存泄漏。
5 promise对象
到目前为止,我们的例子有点不令人满意,因为即使我们可以在主函数和协程之间来回传递控制,但我们没有传递任何数据。如果我们的计数器函数不写入标准输出,而是将值返回到main,然后main可以打印这些值或在计算中使用这些值,那就太好了。
由于我们知道协程状态包括promise_type的一个实例,我们可以向该类型添加一个字段value_,并使用该字段将值从协程传输到我们的主函数。我们如何访问promise类型?在主要功能中,这并不太难。我们可以将我们的coroutine_handle转换为std::coroutine_handle<ReturnObject3:promise_type>,而不是将其保留为std::coroutine_handle<>。这个协程句柄上的方法promise()将返回我们需要的promise_type&。
计数器内部呢?协程如何获得自己的promise对象?回想一下我们第一个例子中的Awaiter对象,以及它是如何为main1保存协程句柄的副本的。我们可以使用类似的技巧在协程中获得promise:在给我们promise对象的自定义awaiter上执行co_await。然而,与我们以前的类型Awaiter不同,我们不希望这个新的自定义Awaiter挂起协同程序。毕竟,在我们得到promise对象之前,我们不能在其中粘贴有效的返回值,因此不会从协程返回任何有效的值。
尽管之前我们的Awaiter::await_suspend方法返回了void,但该方法也被允许返回bool。在这种情况下,如果await_suspend返回false,那么协程就不会被挂起。换句话说,协程实际上并没有被挂起,除非首先await_ready返回false,然后await_suspend(如果它返回类型为bool而不是void)返回true。
因此,我们定义了一个新的awaiter类型GetPromise,它包含一个字段promise_type *p_。我们让它的await_suspend方法将promise对象的地址存储在p_中,但随后返回false以避免实际挂起协程。到目前为止,我们只看到void类型的co_await表达式。这一次,我们希望我们的co_await返回promise对象的地址,所以我们还添加了一个返回p_的await_resume函数。
template<typename PromiseType>
struct GetPromise {
PromiseType *p_;
bool await_ready() { return false; } // says no call await_suspend
bool await_suspend(std::coroutine_handle<PromiseType> h) {
p_ = &h.promise();
return false; // says no don't suspend coroutine after all
}
PromiseType *await_resume() { return p_; }
};
除了void和bool,await_suspend还可能返回一个coroutine_handle,在这种情况下,返回的句柄会立即恢复。GetPromise::await_suspend也可以返回句柄h以立即恢复协同程序,而不是返回false,但这可能效率较低。
这是我们的新计数器代码,其中主函数打印出协程返回的计数器值:
struct ReturnObject3 {
struct promise_type {
unsigned value_;
ReturnObject3 get_return_object() {
return ReturnObject3 {
.h_ = std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
};
std::coroutine_handle<promise_type> h_;
operator std::coroutine_handle<promise_type>() const { return h_; }
};
ReturnObject3
counter3()
{
auto pp = co_await GetPromise<ReturnObject3::promise_type>{};
for (unsigned i = 0;; ++i) {
pp->value_ = i;
co_await std::suspend_always{};
}
}
void
main3()
{
std::coroutine_handle<ReturnObject3::promise_type> h = counter3();
ReturnObject3::promise_type &promise = h.promise();
for (int i = 0; i < 3; ++i) {
std::cout << "counter3: " << promise.value_ << std::endl;
h();
}
h.destroy();
}
运行结果:
counter3: 0
counter3: 1
counter3: 2
需要注意的一点是,我们的promise对象通过将i的值复制到promise_type::value_中,将其从协程传输到主函数。有些违反直觉的是,我们也可以将value_设为无符号*,并返回一个指向counter3中变量i的指针。我们之所以能做到这一点,是因为协程的局部变量位于堆中的协程状态对象内,因此它们的内存在调用co_await时仍然有效,直到有人调用协程句柄上的destroy()。将&i插入返回对象会更方便,但不幸的是,考虑到返回对象的构造方式,没有优雅的方法可以做到这一点。
6 co_yield运算符
协程获得自己的promise对象之所以如此笨拙,是因为C++设计者心中有一个特定的用例,并为特定的用例而不是一般的用例进行设计。然而,具体的情况是有用的,即从协同程序返回值。为此,该语言包含另一个运算符co_yield。
如果p是当前协程的promise对象,则表达式“co_yield e;”等效于计算“co_await p.co_yield_value(e);”。使用co_yeild,我们可以通过在返回对象内的promise_type中添加yield_value方法来简化前面的示例。由于yield_value是promise_type上的一个方法,我们不再需要跳过重重关卡来获得promise对象,它就是这样。以下是新代码的样子:
struct ReturnObject4 {
struct promise_type {
unsigned value_;
ReturnObject4 get_return_object() {
return {
.h_ = std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
std::suspend_always yield_value(unsigned value) {
value_ = value;
return {};
}
};
std::coroutine_handle<promise_type> h_;
};
ReturnObject4
counter4()
{
for (unsigned i = 0;; ++i)
co_yield i; // co yield i => co_await promise.yield_value(i)
}
void
main4()
{
auto h = counter4().h_;
auto &promise = h.promise();
for (int i = 0; i < 3; ++i) {
std::cout << "counter4: " << promise.value_ << std::endl;
h();
}
h.destroy();
}
运行结果:
counter4: 0
counter4: 1
counter4: 2
7 co_return运算符
到目前为止,我们的协程已经产生了无限的整数流,而我们的主要函数在读取前三个整数后简单地破坏了协程状态。如果我们的协程只想在发出协程条件结束的信号之前产生有限数量的值,该怎么办?
为了表示协程的结束,C++添加了一个新的co_return运算符。协程有三种方式来表示它已完成:
- 1 协程可以使用“co_return e;”返回最终值e。
- 2 协程可以使用不带值(或带void表达式)的“co_return;”来结束没有最终值的协程。
- 3 协程可以让执行落在函数的末尾,这与前面的情况类似。
在情况1中,编译器在promise对象p上插入对p.return_value(e)的调用。在情况2-3中,编译器调用p.return_void()。要确定一个协程是否完成,可以在其协程句柄h上调用h.done()。不要将coroutine_handle::done()与coroutine_handle::operator bool()混淆。后者只是检查协程句柄是否包含指向协程内存的非空指针,而不是检查执行是否完成。
这里是一个新版本的计数器,其中计数器函数本身决定只生成3个值,而主函数只是保持打印值,直到协同程序完成。我们还需要对promise_type::final_suspend()进行一个更改,但让我们先看一下新代码,然后在下面讨论promise对象。
struct ReturnObject5 {
struct promise_type {
unsigned value_;
~promise_type() {
std::cout << "promise_type destroyed" << std::endl;
}
ReturnObject5 get_return_object() {
return {
.h_ = std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() {}
std::suspend_always yield_value(unsigned value) {
value_ = value;
return {};
}
void return_void() {}
};
std::coroutine_handle<promise_type> h_;
};
ReturnObject5
counter5()
{
for (unsigned i = 0; i < 3; ++i)
co_yield i;
// falling off end of function or co_return; => promise.return_void();
// (co_return value; => promise.return_value(value);)
}
void
main5()
{
auto h = counter5().h_;
auto &promise = h.promise();
while (!h.done()) { // Do NOT use while(h) (which checks h non-NULL)
std::cout << "counter5: " << promise.value_ << std::endl;
h();
}
h.destroy();
}
运行结果:
counter5: 0
counter5: 1
counter5: 2
promise_type destroyed
关于co_return,有几点需要注意。请注意,在前面的示例中,我们的promise对象上没有return_void()方法。只要我们不使用co_return,那没关系。否则,如果您使用co_return,但没有适当的return_void或return_value方法,则会收到有关缺少方法的编译错误。这是个好消息。坏消息是,如果您执行到函数的末尾,并且您的promise_type缺少return_void方法,则会得到未定义的行为。关于这一点,我将在下面的社论中有更多的话要说,但只要说未定义的行为真的非常糟糕就足够了——就像在自由后使用或数组边界溢出一样糟糕。因此,请注意不要执行到promise对象缺少return_void方法的协程的末尾!
关于co-return,需要注意的另一件事是promise_type::return_void()和promise_type::return_value(v)都返回void;特别是,它们不会返回不可回收的对象。这大概是出于统一处理返回值和异常的愿望(我们将在下面进一步讨论)。尽管如此,有一个重要的问题是,在一次协程结束后该怎么办。编译器是否应该更新协程状态并最后一次挂起协程,这样即使在计算了co_return之后,主函数中的代码也可以访问promise对象并合理使用协程句柄?还是应该像对coroutine_handle::destroy()的隐式调用一样,从协程返回自动销毁协程状态?
这个问题通过promise_type上的final_suspend方法来解决。C++规范说,协程的函数体有效地封装在以下伪代码中:
{
promise-type promise promise-constructor-arguments ;
try {
co_await promise.initial_suspend() ;
function-body
} catch ( ... ) {
if (!initial-await-resume-called)
throw ;
promise.unhandled_exception() ;
}
final-suspend :
co_await promise.final_suspend() ;
}
// "The coroutine state is destroyed when control flows
// off the end of the coroutine"
当协程返回时,您隐式地协等待promise.final_suspend()的结果。如果final_suspend实际上挂起了协程,那么协程状态将最后一次更新并保持有效,并且协程之外的代码将负责通过调用协程句柄的destroy()方法来释放协程对象。如果final_suspend不挂起协程,则协程状态将自动销毁。
如果您再也不打算接触协程状态(可能是因为协程刚刚更新了一些全局变量和/或在co_return之前释放了一个信号量,这就是您所关心的),那么就没有理由为最后一次保存状态而付费,也没有理由担心手动释放协程状态,所以您可以让final_suspend()返回std::suspend_never。另一方面,如果在协程返回后需要访问协程句柄或promise对象,则需要final_suspend()返回std::suspend_allways(或其他挂起协程的awaitable 对象)。
为了更具体地说明这一点,如果我们将ReturnObject5::promise_type::final_suspend()更改为返回std::suspend_never而不是std::suspend_allways,将会发生以下情况:
counter5: 0
counter5: 1
counter5: 2
promise_type destroyed
counter5: 2
Segmentation fault
第一个co_yield(在main5中的循环开始之前)产生0。第二次和第三次co_yield,对应于我们在main5中恢复h的第一次和第二次,无问题地产生1和2。然而,当我们第三次恢复h时,执行到协程的末尾,破坏了协程状态。我们看到promise_type在这一点上被破坏,实际上留下了一个悬空指针h。然而,我们在这个悬空指针上调用了h.done(),从而引发了未定义的行为。在我的机器上,未定义的行为恰好是h.done()返回false。这导致main5停留在循环中并再次调用h(),只是这次它恢复的是垃圾,而不是有效的协程状态。毫不奇怪,恢复垃圾不会更新promise.value_,它仍然是2。同样不足为奇的是,由于我们引发了越来越多未定义的行为,我们的程序很快就会崩溃。
8 通用生成器示例
现在,我们几乎已经完成了构建通用生成器类型的所有部分,这是您将在web上找到的最流行的C++协同程序示例。现在只剩下几个话题要介绍。
首先,到目前为止,我一直在掩盖例外情况。一旦协程被挂起,因此您不再等待初始调用 (例如,counter())返回,恢复协程就不再自动在主函数中抛出异常。相反,它调用promise对象的unhanded_exception()方法。可以说,对于我们的例子,我们应该一直在该函数中调用std::terminate()。(事实上,我们用一个空函数来抑制任何异常,这使得在协程中抛出异常等效于co_return)
如果我们想构建一个通用的生成器返回对象类型来帮助人们编写协程,那么处理异常最有用的方法可以说是将它们重新抛出调用生成器的主例程中。我们可以通过让unhanded_exception()调用std::current_exception来获得存储在promise对象中的std::exception_ptr来实现这一点。当此execption_ptr为非NULL时,生成器使用std::rethrow_exception在主函数中传播异常。
另一个重要的点是,到目前为止,我们的协程一直在计算第一个值(0),只要它们被调用,在第一个co_await之前,因此在构造返回对象之前。您可能希望将第一个值的计算推迟到第一个协程挂起之后,原因有两个。首先,在计算值昂贵的情况下,最好节省工作,以防协程永远无法恢复(可能是因为不同协程中的错误)。其次,由于需要手动销毁协程句柄,如果协程在第一次挂起之前抛出异常,事情可能会变得很尴尬。以以下示例为例:
void
f()
{
std::vector<std::coroutine_handle<>> coros =
{ mkCoroutineA(), mkCoroutineB() };
try {
for (int i = 0; i < 3; ++i)
for (auto &c : coros)
if (!c.done())
c();
}
catch (...) {
for (auto &c : coros)
c.destroy();
throw;
}
for (auto &c : coros)
c.destroy();
}
在上面的例子中,假设mkCoroutineA()返回一个协程句柄,而mkCoroutineB()在其第一个协等待之前抛出一个异常。在这种情况下,mkCoroutineA()创建的协程将永远不会被破坏。当然,您可以重组代码,将mkCoroutineB封装在它自己的try-catch块中,但您可以看到,在创建许多协程时,这会很快变得难以处理。
为了解决这些问题,方法promise_type::initial_uspend()可以返回std::suspend_allways,从而在协程中的任何代码执行之前(因此在所述代码可能引发异常之前),在条目时立即挂起mkCoroutineB。我们在下面的生成器示例中使用此技术。这只是意味着在从生成器返回第一个值之前,我们必须恢复一次协程。
这是我们的通用生成器。生成类型T的生成器必须返回Generator<T>。主函数使用operator bool来确定Generator是否仍有输出值,并使用运算符()来获取下一个值。
template<typename T>
struct Generator {
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
struct promise_type {
T value_;
std::exception_ptr exception_;
Generator get_return_object() {
return Generator(handle_type::from_promise(*this));
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { exception_ = std::current_exception(); }
template<std::convertible_to<T> From> // C++20 concept
std::suspend_always yield_value(From &&from) {
value_ = std::forward<From>(from);
return {};
}
void return_void() {}
};
handle_type h_;
Generator(handle_type h) : h_(h) {}
Generator(const Generator &) = delete;
~Generator() { h_.destroy(); }
explicit operator bool() {
fill();
return !h_.done();
}
T operator()() {
fill();
full_ = false;
return std::move(h_.promise().value_);
}
private:
bool full_ = false;
void fill() {
if (!full_) {
h_();
if (h_.promise().exception_)
std::rethrow_exception(h_.promise().exception_);
full_ = true;
}
}
};
Generator<unsigned>
counter6()
{
for (unsigned i = 0; i < 3;)
co_yield i++;
}
void
main6()
{
auto gen = counter6();
while (gen)
std::cout << "counter6: " << gen() << std::endl;
}
运行结果:
counter6: 0
counter6: 1
counter6: 2
这里需要注意的最后一点是,我们现在在Generator的析构函数中销毁coroutine_hande,因为在我们的特定用例中,我们知道一旦Generator消失,就不再需要coroutine句柄。
9 评论
你可能已经感觉到我很高兴看到C++中的协程,但很遗憾设计太笨拙了。我认为co_await操作符考虑得相当周到,但返回对象的设计却一团糟。您真正需要的只是一些简单的东西:在创建返回对象时同时访问协程和协程句柄中的局部变量。然而,接口既复杂又阻止您同时访问所有必要的变量。
显然,我只考虑了几天C++协程,但在我看来,基本接口应该是两个运算符,co_await(或多或少是原样)和co_init,用于分配协程句柄和创建返回对象。std::coroutine_handle甚至不应该是一个模板,因为promise对象的任何概念都应该被分层在语言提供的任何基元之上。类似于:
template<typename T>
struct Yield {
T *target_;
Yield(T &t) : target_(&t) {}
std::suspend_always operator()(const T &t) { *target_ = t; }
std::suspend_always operator()(T &&t) { *target_ = std::move(t); }
};
template<typename T, bool Suspend = true>
struct ResumeWith {
T value_;
ResumeWith(const T &v) : value_(v) {}
ResumeWith(T &&v) : value_(std::move(v)) {}
constexpr bool await_ready() const noexcept { return !Suspend; }
void await_suspend(std::coroutine_handle) {}
T await_resume() { return std::move(value_); }
};
struct HypotheticalReturnObject {
std::coroutine_handle h;
bool done = false;
unsigned val;
ResumeWith<Yield<unsigned>> operator co_init(std::coroutine_handle hh) {
h = hh;
return ResumeWith(Yield(val));
}
std::suspend_always operator co_return() {
done = true;
return {};
}
};
HypotheticalReturnObject &
hypothetical_counter()
{
auto yield = co_init HypotheticalReturnObject{};
for (unsigned i = 0; i < 3; ++i)
co_await yield(i);
}
但有了这种设计,你会有更多的灵活性。例如,您也可以先声明unsigned i,然后将地址&i粘贴在返回对象内,因为在构建返回对象时,所有内容都在范围内。
显然,这并不完美,因为我刚开始研究C++协程,对设计历史一无所知。假设的设计不会告诉你如何处理异常。尽管如此,我还是很难相信,不可能比目前的设计做得更好,也不可能想出一些涉及更少、更简单、更具表现力的低级概念。
另一个真正让我困惑的笨拙来源是,在promise对象上没有return_void()方法的协程结束时,出现了未定义的行为。未定义的行为是极其糟糕的。你为什么要对程序员这样做?我能想到的唯一理由是,程序员知道执行不会从函数的末尾掉下来,但编译器无法弄清楚。在这些情况下,编译器可能需要生成几个字节的死代码来处理不可能的情况。但是,即使优化边缘情况非常重要,也不要将未定义的行为设为默认行为!例如,为什么不允许在协同程序上使用[[noreturn]]标记,或者允许协同程序以[[fallthrough]]结尾;语句,并说只有当你从协程的末尾掉下来并且其中一个标签存在时,行为才是未定义的? 这将满足需要优化此案例的极少数人,而不会为绝大多数程序员创造轻松的机会。
当然,总的来说,笨拙的协程仍然比没有协程好得多。我预计C++20协程将显著改变我的编程方式,并且可能比lambda表达式更重要。