查看原文
其他

C++20协程初探!

杨良聪 腾讯云开发者 2022-12-22


导语 | 本文推选自腾讯云开发者社区-【技思广益 · 腾讯技术人原创集】专栏。该专栏是腾讯云开发者社区为腾讯技术人与广泛开发者打造的分享交流窗口。栏目邀约腾讯技术人分享原创的技术积淀,与广泛开发者互启迪共成长。本文作者是腾讯后台开发工程师杨良聪。

协程(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&amp; v) { return_data_ = v; return; } */
void return_void(){ std::cout << "return void invoked." << std::endl; }
//! auto yield_value(const T&amp; 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是个自定义的结构,为了能作为协程的返回值,需要定义一个promise_type。这个类型需要实现如下的接口:


  • 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&amp; 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。



总结


可以看出C++20给出了一个非常灵活、有很强大可定制性的协程机制,但缺少基本的库支持,连写一个最简单的协程都需要开发者付出不少理解和学习的成本,目前的状态只能说是打了一个的地基,在C++23中,为协程提供库的支持是重要的目标之一,可以拭目以待。

参考资料:

1.协程 (C++20)

2.C++ 协程:了解运算符co_await

3.C++20即将到来的coroutine能否与Golang的goroutine媲美?



 作者简介


杨良聪

腾讯后台开发工程师

腾讯后台开发工程师,毕业于华中科技大学,目前负责欢乐斗地主后端开发工作,有丰富的后台开发经验。



 推荐阅读


GooseFS 在云端数据湖存储上的降本增效实践
新周期重构地产与物业数智化价值,TVP行业大使有话说
轻松上手!手把手带你掌握从Context到go设计理念
深入浅出带你走进Redis!


👇点击「阅读原文」注册成为社区创作者,认识大咖,打造你的技术影响力!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存