在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