原文:https://blog.panicsoftware.com/your-first-coroutine/
原作者: Dawid Pilarski
系列文章第一篇 Coroutine Introduction 原文,译文
当你熟悉了协程的介绍,我认为是时候实现你的第一个协程了。本文关注理解怎样实现协程和相关实体类(特别是promise_type
)。在我们开始冒险前,请确保你的编译器支持协程特性(在写这篇博文之前,GCC还不完全支持协程,我建议用clang或msvc,本文中的例子用clang 9应该可以全部通过编译)。这是一篇长文,请预留1到2个小时。
我先提个问题,考虑下面的代码片段:
std::future<int> foo();
你认为这是一个函数还是一个协程呢?
嗯,乍一看,它和普通函数没什么不同。它实际上更像是函数声明,但它是函数或是协程取决于实现细节,换句话说,一个函数是不是协程取决于它的函数体。即将进入C++ 20的协程定义了3个关键字。
co_await
co_return
co_yield
如果这些关键字出现在函数中,那么这个函数就是一个协程。
译注:实际上还有其他的因素,比如main函数,构造函数不能是协程,详情可参考cppreference的介绍
为什么要在这些关键字前面加 co_ 呢?为了向前兼容,我们不想让一些程序突然不符合语法,比如程序自己定义了一个yield函数来实现有栈协程(stackful coroutine)。
那我们开始写第一个协程程序吧。
#include <experimental/coroutine>
void foo(){
std::cout << "Hello" << std::endl;
co_await std::experimental::suspend_always();
std::cout << "World" << std::endl;
}
可以发现两样新东西:
co_await
suspend_always
对象co_await
是一个一元操作符,接受Awaitable
对象作为参数。我们将在下一篇博文详细介绍Awaitable
的概念,现在,你只需要知道suspend_always
是一个Awaitable
对象(定义在experimental/coroutine中),然后以它为参数调用co_await
会暂停协程(注意:标准库中还定义了一个suspend_never
,以它为参数调用co_await
时不会暂停协程)。
因为我们还不知道怎么调用这种类型的子程序(译注,可能是指suspend_never)
,所以暂时跳过它。但我们即使尝试编译上面的代码,也会得到以下错误:
msvc中的编译错误(手动翻译)
"promise_type": 不是std::experimtal::coroutine_traits<void>的成员
在没有深入理解协程时,这个错误提示信息并没有多大作用,但我们已经看到,有一些额外类型需要定义。
你大概知道,C++实际上是一个灵活的语言。关于它,我找到张很好的动图,我是在最近跟同事讨论的时候得到的:
译注:这里本来有一张GIF图,但图片太大,传不上来,请查看原文
所以,对于协程,我们需要自己实现大部分的行为。关键字只是为协程特性生成了一些相关的模板代码(boilderplate)(译注:不是template)
,而实现整个协程,我们需要通过自定义点(customisation points)定制实现。接下来,我们尝试理解我们的协程程序哪错了。
现在的问题是,我们一旦有了自己的协程,就需要通过某些方式跟它通信。如果我们的协程暂停了,一定有某些方法可以恢复它。为了实现这个功能,我们需要有一个专门的对象。
用来跟协程通信的对象就是协程的返回值类型。我们开始慢慢地实现这个类型,如果我们的协程能够暂停,我们就需要通过某种方式恢复它,所以我们的返回类型需要有一个用来恢复的方法,创建类型如下:
class resumable{
public:
bool resume();
};
如果协程还没有执行到结束(译注:应该是指协程还没有return)
,那么调用resume方法将恢复协程,方法的返回值表示恢复后,协程内是否还有代码要执行。
所以我们的协程定义也需要调整(更新了返回值类型):
#include <experimental/coroutine>
resumable foo(){
std::cout << "Hello" << std::endl;
co_await std::experimental::suspend_always();
std::cout << "Coroutine" << std::endl;
}
那么问题来了,这个resumable
对象怎么创建呢(方法里并没有return语句)?看上去有点神奇,像是协程的魔力,它其实是由底层为我们实现的(that happens under the hood)。大致来说,编译器为协程生成一些额外的代码,它把每个协程函数转换为类似下面的形式:
Promise promise;
co_await promise.initial_suspend();
try {
// 协程函数体,co-routine body
}
catch(...) {
promise.unhandled_exception();
}
final_suspend:
co_await promise.final_suspend();
另外,resumable
对象会在下面的调用之前创建:
promise.get_return_object();
所以我们需要做的是创建一个Promise类型,有两种方式:
promise_type
作为resumable
类型中的成员类型(译注:或者说子类型)
(或者创建一个别名(译注:即用typedef或者using定义promise_type)
)。coroutine_traits<resumable>
类型,并在里面定义promise_type
(你甚至可以特化出coroutine_traits<resumable, Args..>
来区分协程),或者创建一个别名。如果这还不够,你的协程不返回任何东西(通过co_return
),经过函数体到达函数结尾,那么我们也需要定义额外的成员函数。
return_void
我们看看这个类型的大致形式(draft):
class resumable{
public:
struct promise_type;
bool resume();
};
struct resumable::promise_type{
resumable get_return_object() {/**/}
auto initial_suspend() {/**/}
auto final_suspend() {/**/}
void return_void() {}
void unhandled_exception();
};
不幸地是,我们还没有完成第一个promise类型的定义。为了操作协程,我们需要持有某种形式的协程句柄(handle),以管理它。好在已经存在一个用于这个目的的内建的对象。
coroutine_handle
coroutine_handle
是一个对象,指向协程动态分配的状态。多亏了这个对象,你才能执行像恢复协程这样的操作。coroutine_handle
是一个模板类型,Promise
类型是它的模板参数。我们来看看它的定义。
template <typename Promise = void>
struct coroutine_handle;
template <typename Promise>
struct coroutine_handle : coroutine_handle<> {
using coroutine_handle<>::coroutine_handle;
static coroutine_handle from_promise(Promise&);
coroutine_handle& operator=(nullptr_t) noexcept;
constexpr static coroutine_handle from_address(void* addr);
Promise& promise() const;
}
我们再看看它的特化版本:
template <> struct coroutine_handle<void>{
constexpr coroutine_handle() noexcept;
constexpr coroutine_handle(nullptr_t) noexcept;
coroutine_handle& operator=(nullptr_t) noexcept;
constexpr void* address() const noexcept;
constexpr static coroutine_handle from_address(void* addr);
constexpr explicit operator bool() const noexcept;
bool done() const;
void operator()();
void resume();
void destroy();
private:
void* ptr;// exposition only
};
coroutine_handle
是否空值首先,coroutine_handle
是有默认构造函数的。调用无参的构造函数和调用以nullptr_t为参数的构造函数的效果是一样的。当这样的构造函数被调用,coroutine_handle
将是空的。可以通过operator bool
或者用address
成员函数的值与nullptr
字面量比较来判空。[注意: operator bool
容易和done
成员函数搞混,值得注意一下。]
coroutine_handle
如果想创建非空coroutine_handle
,我们需要创建使用静态成员方法from_address
。这不是我们真正需要注意的,因为创建coroutine_handle
通常是编译器的职责。一旦协程被创建,我们就可以使用operator bool
和address
成员函数来检查它的有效性。
恢复协程有2种方法。一是通过调用coroutine_handle
的resume
成员函数,另一种是通过调用函数调用运算符[译注:比如有如下定义coroutine_handle ch,然后调用ch()]
。
当发生下面任意事件时,协程销毁就会触发。一是调用coroutine_handle
的析构函数。二是函数执行离开协程函数体[译注:即结束]
(最后的暂停点[译注:应该是指不同通过co_yield或者函数调用的方式离开,可能是指co_return或者执行到最后离开?]
)。
协程在整个生命周期中可以暂停很多次。协程有且仅有在最后的暂停点暂停,done
方法才会返回true
。
coroutine_handle
和Promise类型coroutine_handle
特化使它有可能通过promise对象创建,并允许获取协程的promise对象。[注意:手动特化coroutine_handle
是有可能的,但手动特化coroutine_handle
的程序的行为是未定义行为。]
promise_type
现在,我们有必要的知识来实现resumable
和promise_type
了。
我们先来实现resumable
类型。
class resumable {
public:
struct promise_type;
using coro_handle = std::experimental::coroutine_handle<promise_type>;
resumable(coro_handle handle) : handle_(handle) { assert(handle); }
resumable(resumable&) = delete;
resumable(resumable&&) = delete;
bool resume() {
if (not handle_.done())
handle_.resume();
return not handle_.done();
}
~resumable() { handle_.destroy(); }
private:
coro_handle handle_;
};
首先,我们不想让对象被拷贝,因为我们只能调用一次销毁函数。我们也不想让对象被移动,为了让代码简单点。恢复的逻辑如下:如果我们的协程没有完成,那么就恢复它,否则不恢复。返回值表示,在调用resume之后,协程还能不能继续执行。
另一方面,resumable
使用了done
成员方法。为了让方法能够正常运作,协程必须在运行到最后的暂停时暂停自己。
另外注意:coroutine_handle
对象不是线程安全的。同时访问销毁和恢复方法可能会导致数据竞争。多次调用销毁函数是一个错误,就像对同一个指针释放了两次,所以,再销毁协程时一定要小心。[注意:协程在运行到快结束时(在最后一个暂停点之后)也会销毁自己]。
现在我们需要定义promise_type
,以让协程如我们的期待一样表现。
struct resumable::promise_type {
using coro_handle = std::experimental::coroutine_handle<promise_type>;
auto get_return_object() {
return coro_handle::from_promise(*this);
}
auto initial_suspend() { return std::experimental::suspend_always(); }
auto final_suspend() { return std::experimental::suspend_always(); }
void return_void() {}
void unhandled_exception() {
std::terminate();
}
};
首先,get_return_object
方法被用来创建resumable
。resumable
需要coroutine_handle
(通过以*this
为参数调用from_promise
得到)。
接着,initial_suspend
成员函数被调用。对于我们的例子,协程在执行的开始暂停与否并没有差别,所以,随兴选了suspend_always
。这将导致我们需要调用resume
来启动协程的执行。
unhandled_exception
必须要定义,但对于我们的例子,实现与否没有差别,因为我们不会抛出异常。
在我们的例子中,final_suspend
必须返回suspend_always
,因为只有这样,coroutine_handle
的done
方法才能正常工作(就是当函数暂停在最后的暂停点时,done
会返回true)。
最后一个方法return_void
,对于我们的协程例子,也是必须的,即便它是空实现。在永远不会结束的协程中{常见的是生成器(generator
)},return_void
和return_value
都不需要定义。但在我们的例子中,协程最终会运行到函数结尾,如果没有定义return_void
,在协程返回时,我们会遇到未定行为。如果协程通过co_return
返回了某些值,那么就需要定义return_value
而不是return_void
。
一旦我们定义好了自己的协程,就可以看看应该怎么用它了。
int main(){
resumable res = foo();
while (res.resume());
}
预期结果应该是
Hello
Coroutine
除了我们所展示的之外,promise类型还可以有除了我们已经实现的其他的更多的方法。
当协程状态分配的时候,内存分配就会发生,内存会分配到堆上(我们应该假设它总会发生,但编译器可以选择去优化它)。如果这样的分配发生,而且promise_type
有声明get_return_object_on_allocation_failure
,那么不抛出异常版本的operator new
将会被搜索[译注:并使用]
。如果分配失败了,我们将不会有异常抛出,跟它的字面意思一样,编译器会做的是在调用处插入调用get_return_object_on_allocation_failure
静态成员方法的代码。我们可以扩展我们的promise类型:
struct resumable::promise_type {
// ...
static resumable get_return_object_on_allocation_failure(){
throw std::bad_alloc();
}
// ...
};
在我们的例子中,我们应该抛出异常,因为我们的resumable
不支持空的coroutine_handle
(在构造函数中有一个断言,而且我们没有检查handle是不是在可以转换为true之后才去执行操作)。但如果我们的实现在某种程度上支持内存分配失败的情况,那么行为可能会被适当地调整。在我们的例子中,行为将和不声明get_return_object_on_alloction_failure
函数的情况相同。
我们可以通过在promise_type
中为协程提供自定义的operator new
来检查上述行为,我们来定义一个。
struct resumable::promise_type {
using coro_handle = std::experimental::coroutine_handle<promise_type>;
auto get_return_object() {
return coro_handle::from_promise(*this);
}
void* operator new(std::size_t) noexcept {
return nullptr;
}
static resumable get_return_object_on_allocation_failure(){
throw std::bad_alloc();
}
// ...
};
要看到改动后的结果,这些应该足够了。在clang中,程序执行时会弹出下面的错误:
因未捕获的异常(std::bad_alloc)而终止
co_return
前面我们有了一个协程,只可以暂停自身。我们可以轻松地想象协程,它可以在最后返回某些值。正如前面提到的,从协程返回一个值可以通过co_return
关键字实现,但这需要开发者做一些额外的支持。
我认为了解co_return
如果工作的最好的方式是去看编译器在遇到co_return
关键字时生成了什么样的代码。
首先,如果你使用co_return
关键字,右边不带任何表达式(或者void表达式),那么编译器会生成:
promise.return_void();
goto final_suspend;
在这种情况下,我们的协程和promise_type
已经支持了这些操作,所以不需要做别的操作。
但如果关键字右边是非空表达式,那么编译器会生成一些不同的代码:
promise.return_value(expression);
goto final_suspend;
在这种情况下,我们需要在promise_type
中定义return_value
成员函数。定义如下。
struct resumable::promise_type {
const char* string_;
// ...
void return_value(const char* string) {string_ = string;}
// ...
};
所以,这里有两个变化。首先,我们在promise_type
最开始的地方定义了一个额外成员string_
,然后return_void
改为了return_value
,这个函数以const char *
为参数。这个参数后面会保存到string_
成员中。现在你可能想知道,在实际编码时怎么在调用处获取返回值呢,关键就在coroutine_handle
对象上。
提醒你一下,coroutine_handle
知道promise对象,也能够通过promise的成员函数返回对promise对象的引用。
我们扩展一下示例,让我们的协程能够返回值。
resumable foo(){
std::cout << "Hello" << std::endl;
co_await std::experimental::suspend_always();
co_return "Coroutine";
}
int main(){
resumable res = foo();
while(res.resume());
std::cout << res.return_val() << std::endl;
}
class resumable{
// ...
const char* return_val();
// ...
};
const char* resumable::return_val(){
return handle_.promise().string_;
}
在这个示例中,关键是要记得暂停,并且不要结束协程,知道resumable
对象被销毁。理由是,promise对象是在协程内部创建的,所以在它被销毁之后再去访问它会导致未定义行为。
co_yield
操作符我们还没有说到co_yield
关键字的细节,这对于一些使用情况来说是关键的。co_yield
的用处在于从协程中返回某些值,但又不结束协程。通常,如果你要实现某种生成器,你将用到这个关键字。
我们来写一个生成器协程。
resumable foo(){
while(true){
co_yield "Hello";
co_yeild "Coroutine";
}
}
现在,要正确地实现promise_type
,我们需要知道编译器在遇到co_yield
关键字的时候生成了什么样的代码。下面的代码片段准确展示了它们。
co_await promise.yield_value(expression);
所以缺的就是yield_value
成员函数。值得注意的是,虽然没有co_await
关键字出现,但我们后面将讨论它。现在,知道co_await
和使用suspend_always
暂停协程就够了。
我们再改一下promise_type
。
struct resumable::promise_type {
const char* string_ = nullptr;
// ...
auto yield_value(const char* string){
string_=string;
return std::experimental::suspend_always();
}
// ...
};
我们添加了yield_value
。
我们还改了resumable
,代码如下:
class resumable{
// ...
const char* recent_val();
// ...
};
//...
const char* resumable::recent_val(){return handle_.promise().string_;}
现在,使用一下我们的协程
int main() {
resumable res = foo();
int i=10;
while (i--){
res.resume();
std::cout << res.recent_val() << std::endl;
}
}
在程序执行之后,会有如下输出:
Hello
Coroutine
正如你说看到的,协程很难学,毕竟,这还不是关于协程的所有知识,因为我们只接触到了Awaitable
和Awaiter
对象。幸运地是,因为难的缘故,协程也非常灵活。
好消息是,一般的C++开发者不需要知道这个特性整个复杂的部分。实际上,一般的C++开发者知道如何编写一个协程的函数体就可以了,而不用知道协程对象本身。
开发者应该使用已经在标准库定义的协程对象(后面我们会讲到标准库)或者第三方库(比如cppcoro)。
https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await
http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/n4775.pdf
https://www.youtube.com/watch?v=ZTqHjjm86Bw (cppcon McNeils introduction into coroutines)
https://github.com/lewissbaker/cppcoro
最后,翻译水平有限,如果有翻译错误的地方,欢迎指出,也欢迎关于协程的讨论提问,共同学习。