C++ 协程学习笔记
协程是可以理解为一个可以被暂停(挂起)和唤醒的函数, 系统会帮助你保存协程函数中的局部变量.
协程 vs 线程
协程和线程几乎没有任何联系, 它们是相互独立的.
- 对于执行时间比较短的任务, 不建议用线程来处理, 线程的创建, 调度, 销毁都是会占用资源的.
- 线程适合那些长时间的任务, 比如轮循或监控.
- 线程是一个实际的需要操作系统进行调度的对象, 而协程只是函数外加一些在底层跳来跳去的指令.
C++协程特性
无栈
首先要理解每个函数建立都会申请自己的栈帧, 栈帧中存储的是自己当前的局部变量, 当函数调用return后属于该函数的栈帧也会随之销毁, 在栈中有比较明确的后进先出的顺序, 这和函数调用的方式也是一致的. 而协程也可以被视为一个函数, 但是有暂停和唤醒的功能, C++的协程是无栈的, 其实是说协程函数没有自己的栈帧, 取而代之的是其会在堆上申请一片区域代替栈帧的作用, 替自己记录局部变量及协程信息, 在堆上也契合其要暂停唤醒的功能.
C++20协程使用
现在开始我们将会开始以关键字co_await为中心, 实现一个最简易的协程调用.
先认识一下co_await :
这是在协程函数中进行协程挂起的关键字, 其后方要加上可挂起类(Awaitable)来辅助协程的挂起和恢复, 形如co_wait Awaitable; , 可挂起类后面细讲, 只要知道协程函数执行到co_await处就会进行暂停返回调用方(也不一定非得是调用方)就行了.
两个核心类
首先我们要明确实现协程需要两个核心类, 这两个类C++20协程中有明确规定要符合一定的规范 :
返回对象类 : 构建返回对象类的原因有两点.
协程作为函数, 调用方可能需要协程函数返回一些结果, 因此可以存在返回对象类中.
协程的效果是可以被暂停和唤醒, 暂停是协程函数内部执行的, 但唤醒需要调用方执行, 因此返回对象类还要有可以恢复协程运行的功能.
编写规范 : 形如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 返回对象类
// 持有整个协程的句柄, 在协程被挂起时其被交给调用方, 用其可再次唤醒协程
struct CoRet
{
struct promise_type
{
// 控制协程开始和结束时是否暂停的函数
suspend_never initial_suspend() { return {}; }
suspend_never final_suspend() noexcept { return {}; }
// 控制发生异常时的调用
void unhandled_exception() {}
// 控制协程最后返回得到的对象
CoRet get_return_object()
{
return {coroutine_handle<promise_type>::from_promise(*this)};
}
};
coroutine_handle<promise_type> _h;
};- 类名怎么起都行, 但是内部一定要有名为
promise_type的结构体, 其中必须编写所示的四个函数, 功能都有标注, 要注意的是promise_type对象是可以实际被协程句柄调用到的, 也就是说我们可以在其中存自定义句段存储数据. - 前两个函数的返回值可以改, 返回
suspend_never代表不暂停, 返回suspend_always代表暂停. - 最后一个函数可以返回当前类对象的指针, 可以让调用方获取返回类对象.
- 一定要有类型为
coroutine_handle<promise_type>的_h作为协程句柄, 这就是前面说的让调用方可以控制协程唤醒的关键, 这个类型中有成员函数resume(), 调用即可唤醒.
- 类名怎么起都行, 但是内部一定要有名为
可挂起类 :
可挂起类用来辅助co_await进行线程的暂停和唤醒, 那么可以帮些什么呢?
可以用来确定co_await是否生效, 也就是说是否真的要暂停.
可以决定线程暂停前和唤醒时调用的回调.
编写规范 : 形如下
1
2
3
4
5
6
7
8
9
10
11
12
13struct Input
{
Note ¬e;
// 当前对象是否不需要挂起, 返回ture就会立即往下执行, 返回false会立即挂起, 等待外部resume
bool await_ready() { return false; }
// 如果前面返回false, 在挂起前会调用此函数, 做挂起前的准备
void await_suspend(coroutine_handle<CoRet::promise_type> h)
{
// h.promise()
}
// 被唤醒后调用的函数
int await_resume() { return note.val; }
};还是类名怎么起都行, 先不管Note类, 下面的三个函数必须要有, 作用已经标注.
注意三个函数中只有await_suspend可以获取到协程句柄, 也就是说其可以通过句柄控制唤醒的时机(比如异步设置一个回调, 回调中触发resume()). 如果await_resume也想要用句柄, 需要提前在await_suspend中保存下句柄才行.
await_resume有一个返回值, 你可以填void, 也就是什么都不返回, 但如果协程被唤醒时, 你有想从调用方获取的数据时, 可以通过此处设置返回类型. 也就是说我们可以这样写 :
1
int g = co_await input;我们在await_resume中返回的值就会在协程函数中被g获取.
其实前面在返回对象类中前两个函数的返回值suspend_never和suspend_always其实就是协程库内置的标准可挂起类, 没有其他任何作用, 只是为了确定是否要暂停线程. 所以如果完全不需要数据传输等别的功能, 也可以直接
co_await suspend_always{};来实现暂停.
协程函数与main函数的编写
在构建出上面两个类后, 我们来考虑编写出一个最简单的协程 :
1 | |
执行可执行文件我们可以得到 :
1 | |
先说明这个协程干了些什么 : main函数中调用协程函数, 协程函数利用传入的note构造Input对象, 执行到co_await暂停, main函数设置note值后恢复协程, 协程打印我们在main中设置的note值.
这里面有关note值的设置, 目的是为了展示调用方和协程之间的数据交互, 我们可以看到note在main中构造后传入协程函数, 将其记录在input中, 于是我们便可以在main中设置note值让协程中的input看到, 进而提供给协程函数.
可以看到协程函数中有很多注释, 这些其实是运行时内部会加上去的代码, 其会根据我们构建的promise_type生成对象, 并以此调用我们设置的函数. 由此我们可以很明显地感觉到, 返回对象类的作用在于整个协程的把握, 其控制住了整个函数的始终与返回值. 而可挂起对象则辅助我们更好地实现协程函数的暂停和唤醒.
co_yield / co_return
学习完了co_await后再去学习co_yield就简单多了, 这个其实类似于一个语法糖, 其在底层会被替换为co_await :
1 | |
根据替换的形式, 可以推断我们需要在promise_type中构造一个yield_value函数, 形如 :
1 | |
- _out 是我们在promise_type中自定义的句段, 用来存储想告诉调用方的信息.
- yield_value必须返回一个awaitable对象, 这里我们直接使用内部自带的suspend_always, 表示我们需要暂停.
于是我们就是可以在外部通过句柄取得_out值来进行通信了, 其实 co_yield 在底层只是实现了一层替换, 其他什么都没有做, 但是其本身的目的是产生一个值并跳转返回给调用方, 所以我们需要自己在 promise_type中自定义句段存储传入的值, 再在外部取出, 做法相当原始, 但就是这样.
co_return就更简单了, 其只负责让我们自己记录希望给调用方的返回值, 一般被放置在协程函数的最后表示协程的结束, 底层替换如下 :
1 | |
替换完其实就帮我调用了return_value这个函数, 其形如 :
1 | |
其实还是要我们自己手动记录, 非常原始.
至此我们可以进行一次简单的猜数字 :
1 | |
运行后的结果如下 :
1 | |
- done : 其作用在于判断当前协程是否进入了
final_suspend()并且已挂起, 也就是说这是一个调用方用来判断线程是否结束的函数, 如果已经结束, 就可以通过句柄取出希望的结果. - destory : 销毁协程资源, 必须调用, 否则会内存泄露.
完整的猜数字游戏
1 | |
运行如下 :
1 | |
与boost::asio网络库联动
asio网络库对C++20协程提供了原生支持, 也就是说我们可以用aiso网络库更简便的实现代码编写, 在前面学习协程使用时, 相信大家都会认为为了使用协程还要编写两个核心类很麻烦, 但那很多时候不一定非得是我们的工作, 之后会有更多的协程库会帮我实现这些类的编写, asio网络库就是其中之一, 我们只需要关注于协程函数的编写就行了.
下面是利用boost::asio网络库编写的简易Tcp回显服务器 :
1 | |
可以看到代码行数非常少, 并且代码风格不像异步那样复杂, 是否解决同步, 但底层却是异步的!
如果没有学过asio网络库可能有些难以理解, 我之后的博客会出, 最好先学习过再理解.
co_spawn :
其作用是将协程函数作为任务绑定到事件循环的调度器中去, 会先执行协程函数, 并且这里awaitable在内部promise_type中的起始回调会被设置为suspend_always, 会由调度器发布任务到事件循环中来resume协程.
我们可以看到很多co_await后面都会跟着一些网络库函数, 这些其实就是这些网络库函数的协程重载版本, 也可以隐约感觉到其肯定会返回一个awaitable对象, 以
async_accept为例, 其返回的对象类型大概是这样的 :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
38template <>
class awaitable<tcp::socket>
{
public:
struct awaiter
{
tcp::acceptor& acceptor_;
tcp::socket socket_;
std::coroutine_handle<> handle_;
boost::system::error_code ec_;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h)
{
handle_ = h;
acceptor_.async_accept(
socket_,
[this](boost::system::error_code ec)
{
ec_ = ec;
handle_.resume();
});
}
tcp::socket await_resume()
{
if (ec_)
throw boost::system::system_error(ec_);
return std::move(socket_);
}
};
awaiter operator co_await() const
{
return awaiter{...}; // 用构造器填入 acceptor/socket 等
}
};可以看到其在协程暂停前的回调中会调用自己的普通版函数, 将异步任务发布给事件循环, 当socket触发后, 事件循环就会帮我们执行这段代码进而resume协程. 也就是说其思路其实是把resume的时机交给事件循环判断, 非常高明.
回显服务器只是boost::asio网络库与C++20最简单的联动, 还有很多更复杂的设计值得学习, 比如利用strand串行化调度实现协程间资源共享, 这里就不再详述.
by 天目中云