0%

C++20 协程详解

在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();

// Read a request
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 /* bytes_transferred */) {
if (ec) {
// error handling
}

// Read the rest of the message.
do_read();
}

void do_read() {
// Read a request
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 /* bytes_transferred */) {
if (ec) {
// error handling
}

auto req = req_parser_->release();

// Send the response
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 {
// ...

// Write the response
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) {
// error handling
}

if (close) {
// This means we should close the connection, usually because
// the response indicated the "Connection: close" semantic.
return do_eof();
}

// create a new response object
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);

// ...
// Read another request
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>  // std::coroutine_traits
#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_awaitco_yieldco_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”类型,具体来说:

  • 令协程函数返回值和形参为RArgs...
  • 如果协程函数为类非静态成员函数,令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_valueco_return;对应于return_void。与yield_value不同的是,无须等待return_voidreturn_value的返回值,这两个接口的返回值皆为void
需要注意协程函数的返回值和co_return返回值是两个东西,前者是类似“句柄”的存在,后者是传给承诺对象的return_value的参数。还需注意,协程函数中不可出现return关键字。
需要注意return_voidreturn_value两者只能定义其中一个,也就是co_return expr;co_return;在一个协程内只能出现一种。

final_suspend

必要接口,与initial_suspend对应,在协程完成后执行该接口,与initial_suspend类似该接口返回值同样会被co_await
需要注意final_suspend通常要求是noexcept的。
需要注意filnal_suspend如果返回suspend_never,则协程将接着完成析构和资源回收动作。

小结

至此一个包含co_yieldco_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
// gcc 14
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;
}

//...
// [coroutine.handle.promise], promise access
_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_yiledco_return的使用,在介绍co_await之前需要解释下什么是可等待体。基础示例中struct awaitable即是一个可等待体,再来看看上文提到的std::suspend_always,其源码为:

1
2
3
4
5
6
7
8
9
// gcc14 头文件 <coroutine>
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_readyawait_suspendawait_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_yieldco_await等待),将get_return_object的返回对象返回给调用方
  • 当协程抵达co_return时,通过承诺return_voidreturn_value返回最终值;当未有co_return而协程已结束,则视为co_return;,调用承诺return_void,如果承诺未提供则行为未定义。
  • 以创建顺序逆序销毁自动期存储变量
    如果协程因为未捕获异常结束:
  • 捕获异常调用承诺unhandled_exception
  • 调用承诺final_suspendco_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_handledestroy即是通过该函数销毁协程,通过汇编可以看出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
// Handles an HTTP server connection
net::awaitable<void>
do_session(
beast::tcp_stream stream,
std::shared_ptr<std::string const> doc_root)
{
// This buffer is required to persist across reads
beast::flat_buffer buffer;

for(;;)
{
// ...

// Read a request
http::request<http::string_body> req;
co_await http::async_read(stream, buffer, req);

// Handle the request
http::message_generator msg = handle_request(*doc_root, std::move(req));
// ...

co_await beast::async_write(stream, std::move(msg));

// ...
}
// ...
}

// Accepts incoming connections and launches the sessions
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[])
{
//...

// The io_context is required for all I/O
net::io_context ioc{ threads };

// Spawn a listening port
net::co_spawn(
ioc,
do_listen(net::ip::tcp::endpoint{ address, port }, doc_root),
[](std::exception_ptr e)
{
// ...
});

// Run the I/O service on the requested number of threads
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