原文:https://blog.panicsoftware.com/your-first-coroutine/
原作者: Dawid Pilarski
系列文章第一篇 Coroutine Introduction 原文,译文
你的第一个协程程序
当你熟悉了协程的介绍,我认为是时候实现你的第一个协程了。本文关注理解怎样实现协程和相关实体类(特别是
我先提个问题,考虑下面的代码片段:
1 | std::future<int> foo(); |
你认为这是一个函数还是一个协程呢?
嗯,乍一看,它和普通函数没什么不同。它实际上更像是函数声明,但它是函数或是协程取决于实现细节,换句话说,一个函数是不是协程取决于它的函数体。即将进入C++ 20的协程定义了3个关键字。
co_await co_return co_yield
如果这些关键字出现在函数中,那么这个函数就是一个协程。
译注:实际上还有其他的因素,比如main函数,构造函数不能是协程,详情可参考cppreference的介绍
为什么要在这些关键字前面加 co_ 呢?为了向前兼容,我们不想让一些程序突然不符合语法,比如程序自己定义了一个yield函数来实现有栈协程(stackful coroutine)。
那我们开始写第一个协程程序吧。
1 2 3 4 5 6 7 | #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 对象
因为我们还不知道怎么调用这种类型的子程序
1 2 | msvc中的编译错误(手动翻译) "promise_type": 不是std::experimtal::coroutine_traits<void>的成员 |
在没有深入理解协程时,这个错误提示信息并没有多大作用,但我们已经看到,有一些额外类型需要定义。
我们为什么需要定义额外的类型
你大概知道,C++实际上是一个灵活的语言。关于它,我找到张很好的动图,我是在最近跟同事讨论的时候得到的:
所以,对于协程,我们需要自己实现大部分的行为。关键字只是为协程特性生成了一些相关的模板代码(boilderplate)
实现协程
现在的问题是,我们一旦有了自己的协程,就需要通过某些方式跟它通信。如果我们的协程暂停了,一定有某些方法可以恢复它。为了实现这个功能,我们需要有一个专门的对象。
用来跟协程通信的对象就是协程的返回值类型。我们开始慢慢地实现这个类型,如果我们的协程能够暂停,我们就需要通过某种方式恢复它,所以我们的返回类型需要有一个用来恢复的方法,创建类型如下:
1 2 3 4 | class resumable{ public: bool resume(); }; |
如果协程还没有执行到结束
所以我们的协程定义也需要调整(更新了返回值类型):
1 2 3 4 5 6 | #include <experimental/coroutine> resumable foo(){ std::cout << "Hello" << std::endl; co_await std::experimental::suspend_always(); std::cout << "Coroutine" << std::endl; } |
那么问题来了,这个
1 2 3 4 5 6 7 8 9 10 | Promise promise; co_await promise.initial_suspend(); try { // 协程函数体,co-routine body } catch(...) { promise.unhandled_exception(); } final_suspend: co_await promise.final_suspend(); |
另外,
1 | promise.get_return_object(); |
所以我们需要做的是创建一个Promise类型,有两种方式:
- 把
promise_type 作为resumable 类型中的成员类型(译注:或者说子类型) (或者创建一个别名(译注:即用typedef或者using定义promise_type) )。 - 特化出
coroutine_traits 类型,并在里面定义promise_type (你甚至可以特化出coroutine_traits 来区分协程),或者创建一个别名。
如果这还不够,你的协程不返回任何东西(通过
return_void
我们看看这个类型的大致形式(draft):
1 2 3 4 5 6 7 8 9 10 11 12 | 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
1 2 3 4 5 6 7 8 9 10 | 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; } |
我们再看看它的特化版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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
如果想创建非空
协程的恢复
恢复协程有2种方法。一是通过调用
协程的销毁
当发生下面任意事件时,协程销毁就会触发。一是调用
检查执行的状态
协程在整个生命周期中可以暂停很多次。协程有且仅有在最后的暂停点暂停,
coroutine_handle 和Promise类型
实现promise_type
现在,我们有必要的知识来实现
我们先来实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 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之后,协程还能不能继续执行。
另一方面,
另外注意:
现在我们需要定义
1 2 3 4 5 6 7 8 9 10 11 12 | 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(); } }; |
首先,
接着,
在我们的例子中,
最后一个方法
使用我们的协程
一旦我们定义好了自己的协程,就可以看看应该怎么用它了。
1 2 3 4 | int main(){ resumable res = foo(); while (res.resume()); } |
预期结果应该是
1 2 | Hello Coroutine |
深入promise类型
除了我们所展示的之外,promise类型还可以有除了我们已经实现的其他的更多的方法。
内存分配
当协程状态分配的时候,内存分配就会发生,内存会分配到堆上(我们应该假设它总会发生,但编译器可以选择去优化它)。如果这样的分配发生,而且
1 2 3 4 5 6 7 | struct resumable::promise_type { // ... static resumable get_return_object_on_allocation_failure(){ throw std::bad_alloc(); } // ... }; |
在我们的例子中,我们应该抛出异常,因为我们的
我们可以通过在
1 2 3 4 5 6 7 8 9 10 11 12 13 | 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中,程序执行时会弹出下面的错误:
1 | 因未捕获的异常(std::bad_alloc)而终止 |
处理co_return
前面我们有了一个协程,只可以暂停自身。我们可以轻松地想象协程,它可以在最后返回某些值。正如前面提到的,从协程返回一个值可以通过
我认为了解
首先,如果你使用
1 2 | promise.return_void(); goto final_suspend; |
在这种情况下,我们的协程和
但如果关键字右边是非空表达式,那么编译器会生成一些不同的代码:
1 2 | promise.return_value(expression); goto final_suspend; |
在这种情况下,我们需要在
1 2 3 4 5 6 | struct resumable::promise_type { const char* string_; // ... void return_value(const char* string) {string_ = string;} // ... }; |
所以,这里有两个变化。首先,我们在
提醒你一下,
我们扩展一下示例,让我们的协程能够返回值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | 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_; } |
在这个示例中,关键是要记得暂停,并且不要结束协程,知道
使用co_yield 操作符
我们还没有说到
我们来写一个生成器协程。
1 2 3 4 5 6 | resumable foo(){ while(true){ co_yield "Hello"; co_yeild "Coroutine"; } } |
现在,要正确地实现
1 | co_await promise.yield_value(expression); |
所以缺的就是
我们再改一下
1 2 3 4 5 6 7 8 9 | struct resumable::promise_type { const char* string_ = nullptr; // ... auto yield_value(const char* string){ string_=string; return std::experimental::suspend_always(); } // ... }; |
我们添加了
我们还改了
1 2 3 4 5 6 7 | class resumable{ // ... const char* recent_val(); // ... }; //... const char* resumable::recent_val(){return handle_.promise().string_;} |
现在,使用一下我们的协程
1 2 3 4 5 6 7 8 | int main() { resumable res = foo(); int i=10; while (i--){ res.resume(); std::cout << res.recent_val() << std::endl; } } |
在程序执行之后,会有如下输出:
1 2 | Hello Coroutine |
总结
正如你说看到的,协程很难学,毕竟,这还不是关于协程的所有知识,因为我们只接触到了
好消息是,一般的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
最后,翻译水平有限,如果有翻译错误的地方,欢迎指出,也欢迎关于协程的讨论提问,共同学习。