在20标准刚发布时,我曾读过几篇介绍20标准协程的文章,那时感觉标准里的协程难用,离开箱即用还很远,且当时编译器还没支持协程。
写本文时重读标准的协程方案,有了不同的看法,对于语言标准来说,20标准中的coroutine是一个基础设施,作为基础设施只需要提供足够的自由度就行。
那么废话少说,以下是c++20标准的协程详解。
why coroutine
本想在第一章系统介绍下协程这个概念,写写发现这话题太大,况且对于老码农来说也不需要我在这啰嗦(Go的goroutine、lua的coroutine)。如果你不知道协程,只需要知道协程是一个用户态的可挂起可恢复执行的函数即可。
本节聊聊为什么c++在要加入协程:
性能需求
用户态协程相比内核态线程轻量的多,协程切换相比线程切换开销小很多,在应对并发IO上协程模型相比线程在性能上有更大优势。
异步编程 callback hell
这里贴一段实际项目中使用boost beast的异步http服务端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 template <class Socket >class beast_http_session : public detail::abstract_conn, public std::enable_shared_from_this<beast_http_session<Socket>> { void do_read_header () { read_begin_ = steady_clock::now (); http::async_read_header (socket_, buffer_, *req_parser_, [self = this ->shared_from_this ()]( beast::error_code ec, std::size_t bytes_transferred) { self->on_read_header (ec, bytes_transferred); }); } void on_read_header ( beast::error_code ec, std::size_t ) { if (ec) { } do_read (); } void do_read () { http::async_read (socket_, buffer_, *req_parser_, [self = this ->shared_from_this ()]( beast::error_code ec, std::size_t bytes_transferred) { self->on_read (ec, bytes_transferred); }); } void on_read (beast::error_code ec, std::size_t ) { if (ec) { } auto req = req_parser_->release (); handle_request (std::move (req)); } template <class Body , class Allocator > void handle_request ( http::request<Body, http::basic_fields<Allocator>>&& req) { send_response ( fc::json::to_string (fc::variant (result), fc::time_point::maximum ()), 200 ); } virtual void send_response (std::string&& json, unsigned int code) final { http::async_write (socket_, *res_, [self = this ->shared_from_this (), payload_size, close]( beast::error_code ec, std::size_t bytes_transferred) { self->decrement_bytes_in_flight (payload_size); self->on_write (ec, bytes_transferred, close); }); } void on_write ( beast::error_code ec, std::size_t bytes_transferred, bool close) { boost::ignore_unused (bytes_transferred); if (ec) { } if (close) { return do_eof (); } res_.emplace (); switch (continue_state_) { case continue_state_t ::read_body: continue_state_ = continue_state_t ::none; do_read (); break ; case continue_state_t ::reject: continue_state_ = continue_state_t ::none; do_eof (); break ; default : assert (continue_state_ == continue_state_t ::none); do_read_header (); break ; } } }
以上代码省略了很多细节,虽然称不上callback hell,但对于初见这份代码的新手来说还是看着头疼的,原因在于异步将整个io处理逻辑打散,这种情况在引入协程后可以得到缓解,本文结尾有一个使用协程的beast例子。
顺带一提,以上代码来自一个开源区块链平台EOS,实际代码见这里github 。
c++20 coroutine
首先来一个最简单的协程例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 #include <coroutine> #include <iostream> struct awaitable { bool await_ready () { std::cout << "awaitable::await_ready" << std::endl; return false ; } bool await_suspend (std::coroutine_handle<> h) { std::cout << "awaitable::await_suspend" << std::endl; return false ; } int await_resume () { std::cout << "awaitable::await_resume" << std::endl; return 1 ; } }; template <>struct std ::coroutine_traits<void > { struct promise_type { promise_type () { std::cout << "promise_type::promise_type" << std::endl; } std::suspend_never initial_suspend () { std::cout << "promise_type::initial_suspend" << std::endl; return {}; } std::suspend_never final_suspend () noexcept { std::cout << "promise_type::final_suspend" << std::endl; return {}; } void get_return_object () { std::cout << "promise_type::get_return_object" << std::endl; } std::suspend_never yield_value (int i) { std::cout << "promise_type::yield_value:" << i << std::endl; return {}; } void return_value (int i) { std::cout << "promise_type::return_value:" << i << std::endl; } void unhandled_exception () {} }; }; void co_test () { auto i = co_await awaitable{}; std::cout << "co_test::co_await result:" << i << std::endl; co_yield 2 ; co_return 3 ; } int main () { co_test (); return 0 ; }
编译运行结果见基础示例godbolt 。
以上co_test
即是c++20的协程函数,co_await
、co_yield
、co_return
为20加入的协程关键字,只要函数中出现这三个其中任意一个关键字,这个函数即变为协程函数。
co_await
,暂停当前协程,等待一个可“await”对象就绪后继续往下执行。
co_yield
,暂停当前协程,返回一个值给外部。
co_return
,完成协程并返回一个值给外部。
需要说明,这个基础示例中包含三个协程关键字的使用,这里为了说明协程的运行接口调用逻辑,co_await
并未等待任何条件,co_yield
也未实际暂停协程执行。
在详细介绍这三个关键字相关接口之前,需要先解释下c++的无栈协程方案。
无栈协程需要额外的对象用于保存执行上下文,在c++这份实现里,这个上下文保存于一个分配于堆上的内部协程状态机对象中,协程状态包含:
和外界沟通的承诺“promise”对象(并非std::promise
)
协程函数参数,所有参数按值复制进入协程状态(如果有移动复制,会进行移动复制),协程函数实际所使用的参数皆为协程状态中的堆上参数。
暂停点表示,用于恢复协程运行
生存期跨过暂停点的临时变量
去年我重温了一遍南京大学蒋炎岩老师的操作系统课程,蒋老师有句话放在这里比较合适,在计算机世界中“everything is a state machine”。协程是需要被切出切入的过程,这一点和线程比较像,所以常能看到说法称协程是轻量级的线程。线程的切换由操作系统内核负责上下文的保存和恢复,而c++的无栈协程切换则是由编译器生成的代码在用户态完成,c++协程状态机的数据结构即是上述四方面的对象,后三者都是协程实现相关不太需要协程使用者关注,需要关注的是“promise”承诺:
承诺
20协程要求每个协程对象必须有一个对应的“promise”对象,在基础示例中可以看到特化了一个std::coroutine_traits<void>
类型,这是因为编译器是通过std::coroutine_traits
来确定具体的“promise”类型,具体来说:
令协程函数返回值和形参为R
和Args...
如果协程函数为类非静态成员函数,令ClassT
表示协程函数所属类
如果协程函数为类非静态成员函数,令cv
表示成员函数的cv限定
按照以下逻辑确认“promise”:
std::coroutine_traits<R, Args...>::promise_type
,非类成员函数时
std::coroutine_traits<R, cv ClassT&, Args...>::promise_type
,非右值引用对象的类成员函数时
std::coroutine_traits<R, cv ClassT&&, Args...>::promise_type
,右值引用对象的类成员函数时
基础示例中协程函数为void co_test()
,所以编译器选择了std::coroutine_traits<void>::promise_type
来确认承诺。
你可能看过其他的20协程示例,比如协程函数为MyTask co_test(int)
,然后这个协程的"promise"长这样:
1 2 3 4 5 struct MyTask { struct promise_type { }; };
这是因为std::coroutine_traits
有默认行为(以下代码来自gcc14):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 template <typename _Result, typename = void > struct __coroutine_traits_impl {}; template <typename _Result> #if __cpp_concepts requires requires { typename _Result::promise_type; } struct __coroutine_traits_impl <_Result, void > #else struct __coroutine_traits_impl <_Result, __void_t <typename _Result::promise_type>> #endif { using promise_type = typename _Result::promise_type; }; template <typename _Result, typename ... _ArgTypes> struct coroutine_traits : __coroutine_traits_impl<_Result> {};
即默认使用R::promise_type
来确认承诺类型(标准规定的行为,非gcc自行行为)。
那么再来看看承诺类型需要提供什么,以下按照大致调用顺序介绍各接口:
承诺构造函数
编译器构造承诺对象会优先选择和协程函数形参一致的构造函数,该优选构造的实参为协程状态中的堆上,如无该优选构造使用默认空构。
比如void co_test(int i)
,会优先选择promise_type::promise_type(int i)
进行构造。
get_return_object
必要接口,承诺类型通过该方法创建协程函数返回对象,这可能会违反你的直觉,因为以往函数的返回值通常是在函数返回时创建,而协程的返回值和普通函数不同,该返回对象更像是一个协程执行体的“句柄”对象(加引号是因为实际有std::coroutine_handle
)。基础示例中协程函数没有返回值,如果协程函数为MyTask co_test(int i)
,对应的承诺里必须有MyTask promise_type::get_return_object()
。
initial_suspend
必要接口,编译器创建完返回值对象后,会调用承诺的initial_suspend
用以判断初始完后是否需要暂停协程执行。基础示例中没有暂停,如果需要暂停可以改为:
1 2 3 4 5 struct promise_type { std::suspend_always initial_suspend () { return {}; } };
编译器生成的代码中会co_await
initial_suspend
的返回值。(有关co_await
的逻辑见可等待体)
unhandled_exception
必要接口,协程函数中产生了未捕获异常后会进到该调用中,在该函数内可以通过throw;
机制获取当前的异常。
yield_value
非必要接口,协程函数中有co_yield expr
则承诺类型必须有该接口,expr
将作为参数传递给yield_value
,编译器生成代码会co_await
yield_value
的结果。基础示例中std::suspend_never yield_value(int i)
表明协程中会有co_yield int
,同时这次co_yield
不暂停协程执行。
return_void || return_value
非必要接口,和yield_value
类似,两个接口对应于co_return
操作,co_return expr;
对应于return_value
,co_return;
对应于return_void
。与yield_value
不同的是,无须等待return_void
和return_value
的返回值,这两个接口的返回值皆为void
。
需要注意协程函数的返回值和co_return
返回值是两个东西,前者是类似“句柄”的存在,后者是传给承诺对象的return_value
的参数。还需注意,协程函数中不可出现return
关键字。
需要注意return_void
和return_value
两者只能定义其中一个,也就是co_return expr;
和co_return;
在一个协程内只能出现一种。
final_suspend
必要接口,与initial_suspend
对应,在协程完成后执行该接口,与initial_suspend
类似该接口返回值同样会被co_await
。
需要注意final_suspend
通常要求是noexcept
的。
需要注意filnal_suspend
如果返回suspend_never
,则协程将接着完成析构和资源回收动作。
小结
至此一个包含co_yield
和co_return
的协程执行过程就比较清晰了,那么来看个每次执行co_yield
一个斐波那契的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 #include <iostream> #include <coroutine> #include <memory> struct FiboGenerator { struct promise_type ; using handle_type = std::coroutine_handle<promise_type>; struct promise_type { promise_type () : data_ (std::make_shared <int >(0 )) {} std::suspend_always initial_suspend () { return {}; } std::suspend_always final_suspend () noexcept { return {}; } void unhandled_exception () {} FiboGenerator get_return_object () { return FiboGenerator (handle_type::from_promise (*this ), data_); } std::suspend_always yield_value (int i) { *data_ = i; return {}; } void return_value (int i) { *data_ = i; } std::shared_ptr<int > data_; }; FiboGenerator (handle_type handle, std::shared_ptr<int > data) : handle_ (handle), data_ (std::move (data)) {} ~FiboGenerator () { handle_.destroy (); } operator bool () { return handle_ && !handle_.done (); } int Get () { handle_.resume (); return *data_; } std::coroutine_handle<promise_type> handle_; std::shared_ptr<int > data_; }; FiboGenerator co_fibo (int m) { co_yield 0 ; if (m == 0 ) co_return 0 ; co_yield 1 ; if (m == 1 ) co_return 1 ; int a = 0 , b = 1 ; int r = 0 ; for (int i = 2 ; i <= m; ++i) { r = a + b; a = b; b = r; if (i == m) co_return r; co_yield r; } } int main () { auto gen = co_fibo (17 ); for (int i = 0 ; gen; ++i) { std::cout << "Fibo index:" << i << " value:" << gen.Get () << std::endl; } return 0 ; }
编译运行结果见fibo示例godbolt 。
以上示例中在当前讲解内容里“超纲”一点的是std::coroutine_handle
的使用,coroutine_handle
即是c++这份协程实现里的协程句柄(不带引号的)。coroutine_handle
可以与承诺对象互相转换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 template <typename _Promise> struct coroutine_handle { static coroutine_handle from_promise (_Promise& __p) { coroutine_handle __self; __self._M_fr_ptr = __builtin_coro_promise((char *) &__p, __alignof(_Promise), true ); return __self; } _Promise& promise () const { void * __t = __builtin_coro_promise (_M_fr_ptr, __alignof(_Promise), false ); return *static_cast <_Promise*>(__t ); } }
coroutine_handle
是底层协程实现的封装,用以控制实际协程的运行。以上用法中需要注意coroutine_handle::done
方法,只在promise_type::final_suspend
返回suspend_always
这个暂停点时才会返回true
;还需注意栈上的gen
这个FiboGenerator
析构时调用coroutine::destroy
销毁协程对象。有关coroutine_handle
详细见参考链接
可等待体
以上承诺的解释大致覆盖了co_yiled
和co_return
的使用,在介绍co_await
之前需要解释下什么是可等待体。基础示例中struct awaitable
即是一个可等待体,再来看看上文提到的std::suspend_always
,其源码为:
1 2 3 4 5 6 7 8 9 struct suspend_always { constexpr bool await_ready () const noexcept { return false ; } constexpr void await_suspend (coroutine_handle<>) const noexcept {} constexpr void await_resume () const noexcept {} };
可以看出可等待体亦即提供了await_ready
、await_suspend
和await_resume
接口的对象。这三个接口的调用时机按顺序为:
await_ready
,判断等待条件是否已就绪,某些情况在co_await
时条件已经满足,条件满足则不进行等待。返回值可以不为bool
,可转换为bool
即可。
await_suspend
,在await_ready
返回"false"后,暂停协程执行,调用await_suspend
,参数即为当前的协程句柄,可以在等待某个条件满足后调用句柄resume
来继续执行协程。该函数体内抛出异常,将恢复协程执行,并立刻重抛异常给外部。
返回值为void
时,保持协程暂停
返回值为bool
时,当返回true
时保持协程暂停,返回false
时协程恢复执行
返回值为std::coroutine_handle
时,恢复该返回值协程执行
await_resume
,当协程恢复后(即使未有暂停,await_ready
返回了true
),执行await_resume
,该接口的返回值即为co_await x
表达式的返回值。
介绍完可等待体的逻辑,co_await x
的逻辑也就比较清晰了。
总执行过程
以上已经把c++20协程的大部分机制介绍了一遍,这里再从头开始梳理一遍协程的总体执行过程:
调用operator new
分配协程状态对象(承诺对象、函数参数、暂停点表示、临时对象)
协程函数参数复制进入协程状态内:按值传递的参数被移动或复制,按引用传递的参数保持为引用
调用对应承诺对象的构造,优先选择符合协程函数参数签名版本构造
调用承诺对象的get_return_object
方法创建协程函数返回对象,该对象为协程外临时对象。至此过程中产生的所有异常将由协程外处理
调用承诺的initial_suspend
方法,co_await
其返回值
initial_suspend
恢复时,开始协程函数内执行
当协程抵达暂停点时(co_yield
、co_await
等待),将get_return_object
的返回对象返回给调用方
当协程抵达co_return
时,通过承诺return_void
、return_value
返回最终值;当未有co_return
而协程已结束,则视为co_return;
,调用承诺return_void
,如果承诺未提供则行为未定义。
以创建顺序逆序销毁自动期存储变量
如果协程因为未捕获异常结束:
捕获异常调用承诺unhandled_exception
调用承诺final_suspend
,co_await
其返回值
调用承诺析构
调用各函数参数副本的析构
调用operator delete
释放协程状态内存
控制权回到调用方
调试gcc协程
以上述斐波那契示例为例,在编译结果fibo示例godbolt 汇编中可以看到三个长着比较像的函数:
1 2 3 co_fibo (int )co_fibo (co_fibo (int )::_Z7co_fiboi.Frame*) (.actor)co_fibo (co_fibo (int )::_Z7co_fiboi.Frame*) (.destroy)
第一个函数即为我们定义的协程函数,但如果你读下其汇编,会发现这个函数并没有实际执行协程,这个函数里执行的即是上文里的准备协程的过程,包括operator new
创建协程状态(即这里的co_fibo(int)::_Z7co_fiboi.Frame*
)、调用承诺get_return_object
创建返回值等。
实际协程的执行放在了co_fibo(co_fibo(int)::_Z7co_fiboi.Frame*) (.actor)
函数中,纵观整个函数,该actor
就是一个包含多个暂停点跳转的状态机执行函数,该函数的参数即是operator new
创建的协程状态(或称协程帧)。如果你要调试你的协程,需要注意入口是该actor
而非co_fibo(int)
。
co_fibo(co_fibo(int)::_Z7co_fiboi.Frame*) (.destroy)
是实际销毁协程时的入口,std::coroutine_handle
的destroy
即是通过该函数销毁协程,通过汇编可以看出destroy
也是通过actor
的状态机函数来完成销毁,这是因为协程如果没有等待执行完成后退出也是需要做销毁的。
beast协程示例
最后来看一个boost beast的协程示例,以下代码来自boost 1.88版本beast/example/http/server/awaitable :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 net::awaitable<void > do_session ( beast::tcp_stream stream, std::shared_ptr<std::string const > doc_root) { beast::flat_buffer buffer; for (;;) { http::request<http::string_body> req; co_await http::async_read (stream, buffer, req) ; http::message_generator msg = handle_request (*doc_root, std::move (req)); co_await beast::async_write (stream, std::move(msg)) ; } } net::awaitable<void > do_listen (net::ip::tcp::endpoint endpoint, std::shared_ptr<std::string const > doc_root) { auto executor = co_await net::this_coro::executor; auto acceptor = net::ip::tcp::acceptor{ executor, endpoint }; for (;;) { net::co_spawn ( executor, do_session ( beast::tcp_stream{ co_await acceptor.async_accept () }, doc_root), [](std::exception_ptr e) { }); } } int main (int argc, char * argv[]) { net::io_context ioc{ threads }; net::co_spawn ( ioc, do_listen (net::ip::tcp::endpoint{ address, port }, doc_root), [](std::exception_ptr e) { }); std::vector<std::thread> v; v.reserve (threads - 1 ); for (auto i = threads - 1 ; i > 0 ; --i) v.emplace_back ([&ioc] { ioc.run (); }); ioc.run (); return EXIT_SUCCESS; }
以上我省略了部分代码,保留了协程处理的大致逻辑。
可以看到boost::asio
提供了co_spawn
来向io_context
中创建一个协程对象,20标准中提供了协程的基础设施,但未有协程调度的东西,这也是大家认为20协程只适合给库作者使用的原因之一。
大致解释下以上代码逻辑:
主线程中向io_context
创建了do_listen
协程
ioc.run()
运行do_listen
协程
do_listen
协程co_await
等待连接
当有新连接后ioc.run()
会处理该事件并恢复do_listen
协程
do_listen
向当前io_context
创建一个do_session
协程后循环下一次co_await
等待另一个连接
do_session
协程被调度时先发起异步读,co_await
等待当前连接可读
当连接可读事件到后ioc.run()
处理该事件并恢复do_session
协程
do_session
恢复,处理请求,发起异步写回应答,co_await
等待写入完成
当写入完成事件到后ioc.run()
处理该事件并恢复do_session
协程
do_session
协程如果连接关闭则退出
可以看出协程的出现,让之前支离破碎的异步io处理逻辑变得更“程序员友好”了,同时以上示例是可以单线程处理所有协程的,相比以往单独开连接处理线程、会话处理线程的方案,以上所有协程的切换全在用户态完成。
参考链接
cpp reference 协程
cpp reference coroutine_handle