C++20协程初探!
协程(coroutine)是在执行过程中可以被挂起,在后续可以被恢复执行的函数。在C++20中,当一个函数内部出现了co_await、co_yield、co_return中的任何一个时,这个函数就是一个协程。
C++20协程的一个简单的示例代码:
coro_ret<int> number_generator(int begin, int count) {
std::cout << "number_generator invoked." << std::endl;
for (int i=begin; i<count; ++i) {
co_yield i;
}
co_return;
}
int main(int argc, char* argv[])
{
auto g = number_generator(1, 10);
std::cout << "begin to run!" << std::endl;
while(!g.resume()) {
std::cout << "got number:" << g.get() << std::endl;
}
std::cout << "coroutine done, return value:" << g.get() << std::endl;
return 0;
}
number_generator内出现了co_yield和co_return所以这不是一个普通的函数,而是一个协程,每当程序执行到第4行co_yield i;时,协程就会挂起,程序的控制权会回到调用者那里,直到调用者调用resume方法,此时会恢复到上次协程yield的地方,继续开始执行。
Promise
number_generator的返回类型是coro_ret<int>,而协程本身的代码中并没有通过return返回这个类型的数据,这就是C++20里实现协程的一个关键点: 协程的返回类型T中,必须有T::promise_type这个类型定义,这个类型要实现几个接口。还是先看代码:
//!coro_ret 协程函数的返回值,内部定义promise_type,承诺对象
template <typename T>
struct coro_ret
{
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
//! 协程句柄
handle_type coro_handle_;
//!promise_type就是承诺对象,承诺对象用于协程内外交流
struct promise_type
{
promise_type() {
std::cout << "promise constructor invoded." << std::endl;
}
~promise_type() = default;
//!生成协程返回值
auto get_return_object()
{
std::cout << "get_return_object invoked." << std::endl;
return coro_ret<T>{handle_type::from_promise(*this)};
}
//! 注意这个函数,返回的就是awaiter
//! 如果返回std::suspend_never{},就不挂起,
//! 返回std::suspend_always{} 挂起
//! 当然你也可以返回其他awaiter
auto initial_suspend()
{
//return std::suspend_never{};
std::cout << "initial_suspend invoked." << std::endl;
return std::suspend_always{};
}
//!co_return 后这个函数会被调用
/*
void return_value(const T& v)
{
return_data_ = v;
return;
}
*/
void return_void()
{
std::cout << "return void invoked." << std::endl;
}
//!
auto yield_value(const T& v)
{
std::cout << "yield_value invoked." << std::endl;
return_data_ = v;
return std::suspend_always{};
//return std::suspend_never{};
}
//! 在协程最后退出后调用的接口。
auto final_suspend() noexcept
{
std::cout << "final_suspend invoked." << std::endl;
return std::suspend_always{};
}
//
void unhandled_exception()
{
std::cout << "unhandled_exception invoked." << std::endl;
std::exit(1);
}
//返回值
T return_data_;
};
coro_ret(handle_type h)
: coro_handle_(h)
{
}
~coro_ret()
{
//!自行销毁
if (coro_handle_)
{
coro_handle_.destroy();
}
}
//!恢复协程,返回是否结束
bool resume()
{
if (!coro_handle_.done()) { //! 如果已经done了,再调用resume,会导致coredump
coro_handle_.resume();
}
return coro_handle_.done();
}
bool done() const
{
return coro_handle_.done();
}
//!通过promise获取数据,返回值
T get()
{
return coro_handle_.promise().return_data_;
}
};
coro_ret<T> get_return_object() 这个接口要能用promise自己的实例构造出一个协程的返回值,会在协程正在运行前进行调用,这个接口的返回值会作为协程的返回值。
awaiter initial_suspend() 这个接口会在协程被创建(也就是第一次调用),真正运行前,被调用,如果这个接口返回的是std::suspend_never{},那么协程一创建出来,就会立刻执行;如果返回的是std::suspend_always{},那么协程被创建出来时,会处于挂起状态,不会立刻执行,需要调用者主动resume才会触发第一次执行。这两个值其实都是awaiter类型,后面再解释这个类型。
awaiter yield_value(T v) 这个接口会在 co_yield v 时被调用,把co_yield后面跟着的值v做为参数传入,这里一般就是把这个值保存下来,提供给协程的调用者,返回值也是awaiter,这里一般返回的是std::suspend_always{}。
void return_value(T v) 这个接口会在 co_return v 时被调用,把co_return后面跟着的值v作为参数传入,这里一般就是把这个值保存下来,提供给协程调用者。
void return_void() 如果 co_return 后面没有接任何值,那么就会调用这个接口。return_void和return_value只能选择一个实现,否则会报编译错误。
awaiter final_suspend() 在协程最后退出后调用的接口,如果返回 std::suspend_always 则需要用户自行调用coroutine_handle的destroy接口来释放协程相关的资源;如果返回std::suspend_never则在协程结束后,协程对应的handle就已经为空,不能再调用destroy了(会coredump)
void unhandled_exception()如果协程内的代码抛出了异常,那么这个接口会被调用。
协程相关对象
可以看出promise类的工作主要是两个:一是定义协程的执行流程,主要接口是initial_suspend,final_suspend,二是负责协程和调用者之间的数据传递,主要接口是yield_value和return_value。
std::coroutine_handle<promise_type>是协程的控制句柄类,最重要的接口是promise、resume,前者可以获得协程的promise对象,后者可以恢复协程的运行。此外还有destroy接口,用来销毁协程实例,done接口用于返回协程是否已经结束运行。通过std::coroutine_handle<promise_type>::from_promise()方法,可以从promise实例获得对应的handle。
coro_ret中其他几个接口resume,done和get_data不是必须的,只是为了方便使用而存在。
总结一下,一个协程与这几个对象关联在一起:
promise
coroutine handle
coroutine state
这是个在堆上分配的内部对象,没有暴露给开发者,是用来保存协程内相关数据和状态的,具体来说就是:
promise对象
传给协程的参数
当前挂起点的相关数据
生命周期跨越挂起点的临时变量和本地变量,也就是在resume后需要恢复出来的变量。
协程的创建
临时总结
要在c++20里实现一个协程,需要定义一个协程的返回类型T,这个T内需要定义一个promise_type的类型,这个类型要实现几个指定的接口,这样就足够了。这样,要开发一个包含异步操作的协程,代码的结构大致会是这样的:
coro_return<T> logic() {
// 发起异步操作
some_async_oper();
co_yield xxx
// 恢复执行了,要先检查和获得异步操作的结果
auto result = get_async_oper_result()
do_some_thing(result)
co_return
}
int main() {
auto co_ret = logic();
// 循环检查异步操作是否结束
while(true) {
auto result = get_async_result();
if (result) {
// 异步操作结束了,恢复协程的运行,要把结果传过去
co_ret.resume()
break;
}
}
}
可以看到,在协程内部,发起异步操作和获取结果,被yield分割为了两步,和同步代码还是有着明显的区别。这时,co_await就可以发挥它的作用了,使用了co_await后的协程代码会是这样的
coro_return<T> logic() {
auto result = co_await some_async_oper();
do_some_thing(result);
}
这样就和同步代码就基本没有区别了,除了这个co_await
co_await
co_await最常见的使用方式为auto ret=co_await expr,co_await后跟一个表达式,整个语句的执行过程有多种情况,是比较复杂的。这里描述的是简化版本,主要是简化了promise.await_transform的作用,以及awaitable对象,可以点击下面链接看完整的描述。这里假定协程的promise_type没有实现await_transform方法。
https://en.cppreference.com/w/cpp/language/coroutines
用代码表达,是这样:
if (!awaiter.await_ready())
{
using handle_t = std::experimental::coroutine_handle<P>;
using await_suspend_result_t =
decltype(awaiter.await_suspend(handle_t::from_promise(p)));
<suspend-coroutine>
if constexpr (std::is_void_v<await_suspend_result_t>)
{
awaiter.await_suspend(handle_t::from_promise(p));
<return-to-caller-or-resumer>
}
else
{
static_assert(
std::is_same_v<await_suspend_result_t, bool>,
"await_suspend() must return 'void' or 'bool'.");
if (awaiter.await_suspend(handle_t::from_promise(p)))
{
<return-to-caller-or-resumer>
}
}
<resume-point>
}
return awaiter.await_resume();
首先是expr求值
expr表达式的返回值类型(awaiter)必须实现这几个接口: await_ready、await_suspend和await_resume。
await_ready被调用,如果返回true,那么协程完全不会被挂起,直接会去调用await_resume()接口,把这个接口作为await的返回值,继续执行协程。
如果await_ready返回false,那么协程会被挂起,然后调用await_suspend接口,并将协程的句柄传给这个接口。注意,此时协程已经被挂起,但控制权还没有交给调用者。
如果await_suspend接口的返回类型是void,或者返回类型是bool,返回值是true,那么就将控制权交还给调用者。
如果await_suspend接口返回的是false,那么协程会被resume,并接着调用await_resume,把这个接口作为await的返回值,继续执行协程。
如果前面的步骤中,协程被挂起了,那么当协程被调用者resume的时候,会先调用await_resume接口,把这个接口作为await的返回值,继续执行协程。
co_await的例子
以封装一个socket的connect操作为例,我们希望能像这样在协程中去connect一个tcp地址:
coro_ret<int> connect_addr_example(io_service& service, const char* ip, int16_t port)
{
coroutine_tcp_client client;
// 异步连接, service是对epoll的一个封装
auto connect_ret = co_await client.connect(ip, port, 3, service);
printf("client.connect return:%d\n", connect_ret);
if (connect_ret)
{
printf("connect failed, coroutine return\n");
co_return -1;
}
do_something_with_connect(client);
co_return 0;
}
那么需要做的事情是
第5行中的client.connect首先发起一个异步连接的请求(设置socket为noneblock,然后connect, 并把socket和自己的指针加入epoll),返回的类型需要是一个awaiter,也就是要实现这三个接口:await_ready、await_suspend和await_resume
在await_ready中,判断连接是否已经建立了(某些情况下connect会立刻成功返回),或者出错了(比如给connect传了非法的参数),此时需要返回true,协程就完全不会挂起。其他情况需要返回false,让协程挂起
在await_suspend中,可以保存下传入的协程句柄,然后直接返回true。
在await_resume中,判断下连接的结果,成功返回0,其他情况返回错误码。
协程外的主循环里,使用epoll进行轮询,当对应的句柄有事件时(成功连接、超时、出错),就取出对应的client指针,设置好连接的结果,并resume协程。
大致的代码如下:
struct connect_awaiter
{
coroutine_tcp_client& tcp_client_;
// co_await开始会调用,根据返回值决定是否挂起协程
bool await_ready()
{
auto status = tcp_client_.status();
switch(status)
{
case ERROR:
printf("await_ready: status error invalid, should not suspend!\n");
return true;
case CONNECTED:
printf("await_ready: already connected, should not suspend!\n");
return true;
default:
printf("await_ready: status:%d, return false.\n", status);
return false;
}
}
// 在协程挂起后会调用这个,如果返回true,会返回调用者,如果返回false,会立刻resume协程
bool await_suspend(std::coroutine_handle<> awaiting)
{
printf("await_suspend invoked.\n");
tcp_client_.handle_ = awaiting;
return true;
}
// 在协程resume的时候会调用这个,这个的返回值会作为await的返回值
int await_resume()
{
int ret = tcp_client_.status() == CONNECTED ? 0 : -1;
printf("awati_resume invoked, ret:%d\n", ret);
return ret;
}
};
了解了co_await之后,可以回头看一下之前的内容,前面多次出现的std::suspend_never和std::suspend_always就是两个预定义好的awaiter,也有那三个接口的定义,有兴趣的同学可以看看对应的源代码。promise对象的initial_suspend、final_suspend、yield_value返回的都是awaiter,实际上系统执行的是 co_await promise.initial_suspend() ,co_yield实际上执行的是 co_await promise.yield_value() 。如果有需要,也可以返回自定义的awaiter。
总结
1.协程 (C++20)
2.C++ 协程:了解运算符co_await
3.C++20即将到来的coroutine能否与Golang的goroutine媲美?
作者简介
杨良聪
腾讯后台开发工程师
腾讯后台开发工程师,毕业于华中科技大学,目前负责欢乐斗地主后端开发工作,有丰富的后台开发经验。
推荐阅读