C++20中的协程(Coroutine)的实现
C++20中的协程(Coroutine)
从2017年开始, 协程(Coroutine)的概念就开始被建议加入C++20的标准中了,并已经开始有人对C++20协程的提案进行了介绍。1事实上,协程的概念在很早就出现了,甚至其他语言(JS,Python,C#等)早就已经支持了协程。
可见,协程并不是C++所特有的概念。
那么,什么是协程?
简单来说,协程就是一种特殊的函数,它可以在函数执行到某个地方的时候暂停执行,返回给调用者或恢复者(可以有一个返回值),并允许随后从暂停的地方恢复继续执行。注意,这个暂停执行不是指将函数所在的线程暂停执行,而是单纯的暂停执行函数本身。
那么,这种特殊函数有什么用呢?最常见的用途,就是将“异步”风格的编程“同步”化。
比如,我们有一个请求webapi的库,然后在某个应用中我们需要发送一个http请求,然后等待web服务器反馈消息。恰巧的是,我们需要按顺序请求多次,比如,只有请求A返回了,我们才能发送请求B,因为请求B中包含请求A返回的结果。然后等请求B返回了,我们才能发送请求C等等。
我们不能阻塞主线程,那么此时我们应该怎么办?
最常见的思路就是开一个新线程,然后使用“回调函数”,例如:
// 示意代码 void requestA(int req, std::function<void(int)> cb) { // 我们的webapi是异步调用, 我们开启一个线程请求并等待调用完毕 std::thread t([req, cb]() { auto response = webapi.request(req); // 假定response有个等待返回值的接口waitForFinish,他会阻塞当前线程,直到拿到返回值 int rt = response.waitForFinish(); // 返回了, 那么我们调用回调函数 cb(rt); }); t.detach(); }
假定我们还有相同结构的requestB,requestC以及其它, 那么我们会怎么用呢? 有了lamda表达式,通过回调函数进行链式调用可以很简单的写成如下形式:
int main() { requestA(1, [](int rt){ requestB(rt, [](int rt2){ requestC(rt2, [](int rt3){ // 根据需要可能会继续嵌套下去 }); }); }); // 甚至可能需要再来一遍, 因为我们还需要使用另一个参数请求 requestA(2, [](int rt){ requestB(rt, [](int rt2){ requestC(rt2, [](int rt3){ // 根据需要可能会继续嵌套下去 }); }); }); }
这还是好的,如果你使用Qt的信号槽来实现,并同时可能有多个请求,你可能还会遇到另一个问题:“我怎么知道这个返回值是我发送的哪个请求产生的?”如果webapi库没有提供请求与反馈之间互相对应的相关支持,你可能会更加的郁闷。
那么, 使用协程又会有哪些不一样呢?
想象一下, 同样的requestA,requestB,requestC,(当然已经修改为了协程的写法) 你可以这么用
task<void> request() { int rt = co_await requestA(1); // 处理一些中间结果 rt = co_await requestB(rt); // 处理一些中间结果 rt = co_await requestC(rt); // 对最终结果做一些事情 }
这三个异步函数会在同一个线程中按照调用顺序依次完成调用。
没错, 不再需要回调函数, 你可以完全顺序的, 仿佛异步调用不存在的使用同步调用的写法。正是因为协程,我们就可以使用一个更加“同步”化的方式,实现异步调用了。
只要一个关键字co_await就能享用。隔壁的JavaScript早就用上了(ES6版本),现在,终于,C++也可以使用了!
那么这么好用的协程,是不是只要C++20一推出,我们加上一个关键字就能直接把异步调用转化为同步调用呢?
很遗憾,并不能。
C++20的协程只是给了我们一个“使用同步风格进行异步调用”的框架,具体的实现还是需要我们自己去做。
如果你对JavaScript中的协程有所了解的话,就会明白,在ES6中,一个函数可以通过await等待返回的前提,是这个函数被声明为async,而这是ES6提供的一个“语法糖”,也就是说,这个关键字只起到“提示”的作用,真正的实现是需要Promise的。
C++20中也是这样,协程是特殊函数,但是在C++20中,这个特殊函数不是由普通函数添加一个关键字组成的,我们需要为实现这个特殊函数做一些额外的工作。
目前,C++20应该不会提供自动化的包装功能,或者简化包装的库,也就是说,想要让某个函数成为协程函数,我们需要人工的做一些额外的工作,一些辅助的自动化的工具应该会在C++23标准中提供,让协程真正的可以被广大开发人员使用。
虽然辅助工具再C++23才会提供,但是最基础的已经在C++20中存在了。
在我们继续讲解之前,先明确一些概念。
co_return,co_yield,co_await是为了使用协程而新增加的三个关键字,这些关键字在非协程函数中是无法使用的。这也就意味着,在main函数中直接调用co_await xxxx(); 是不行的。
这似乎有点违反我们的常识。协程的关键字只能在协程函数中使用有点递归的意思,这难道意味着普通的函数中没法使用协程函数了?这其实是我们一开始听说协程的描述时会产生的一种误解。
为了消除这种误解,我们先了解一下到底什么是协程函数,以及它到底特殊在哪里。
协程函数和Awaitable类
接下来我们先从如何定义协程函数开始:
简单来说,就是如果一个函数的返回值是一个符合Promise规范的类,并且在这个函数中使用了co_return,co_yield,co_await中的一个或多个,那么这个函数就是一个协程函数。
那么Promise规范又是啥?Promise在英文中是许诺的意思。简单来说,Promise规范就是:如果在类A中定义一个叫做promise_type的结构体,并且其中包含特定名字的函数,那么这个类A就符合Promise规范,它就是一个符合Promise规范的类,它也就是一个Promise。
比如以下例子:
struct task{ struct promise_type { auto get_return_object() { return task{}; } std::suspend_never initial_suspend() { return {}; } std::suspend_never final_suspend() { return {}; } void return_void() {} void unhandled_exception() {} }; }
由于类task中定义了promise_type,同时其中包含了符合规范的5个函数,它就是一个Promise。
然后根据协程规范,返回这个类的函数就是协程函数,于是如果我们有以下定义:
task getTask() { // 实现中不需要返回task,也不能写return co_return; }
getTask()就是一个协程函数了。当然,如果协程函数中不使用co_wait或者co_yield其实就没有什么意义。
然而,我们虽然有了协程函数,但是我们依旧无法使用co_await,为什么呢?因为co_await关键字实际上是一个运算符,其后面只能跟随一个“实现了三个特定函数的类”。这三个特定函数如下所述:2
struct suspend_always { constexpr bool await_ready() const noexcept { return false; } constexpr void await_suspend(std::coroutine_handle<> h) const noexcept {} constexpr void await_resume() const noexcept {} };
注意,我们实现的时候只需要有包含这三个名字的函数就行了,并不需要继承。
如果我们使用co_await suspend_always(); 会发生什么呢?
- suspend_always会被构造,调用其构造函数(一般情况下我们就可以通过构造函数模仿一个普通的函数调用了)。
- 通过await_ready()判断是否需要等待,如果返回true,就表示不需要等待,如果返回false,就表示需要等待。
- 如果不需要等待,则立刻执行await_resume,否则先执行await_suspend,然后进入等待,调用co_await awaitable(); 的函数会在这里暂停运行,但是不会影响所在线程的执行。
- 我们可以在await_suspend函数中通过传统的回调函数法执行一些异步操作,然后在回调函数中调用std::coroutine_handle<>的resume函数主动恢复。
- await_resume会在恢复执行后立刻执行,注意:co_wait的返回值就是该函数的返回值,而await_resume函数允许拥有任意的返回值类型,模板类型也是允许的。
也就是说可以使用以下的模板类让co_wait的返回值更加的自由:3
template <class T> struct someAsyncOpt { bool await_ready() void await_suspend(std::coroutine_handle<>); T await_resume(); };
最后,我们也应该了解,同一个线程在一个时间点最多只能跑一个协程;在同一个线程中,协程的运行是穿行的,没有数据争用(data race),也不需要锁。
至此,我们完成了协程的基本介绍。
那么,到底要如何使用协程呢?
了解了协程后我们就可以发现了以下事实:
一个线程只能有一个协程
- 协程函数需要返回值是Promise
- 协程的所有关键字必须在协程函数中使用
- 在协程函数中可以按照同步的方式去调用异步函数,只需要将异步函数包装在Awaitable类中,使用co_wait关键字调用即可。
知道了以上事实,我们就可以按照以下方式使用协程了:
- 在一个线程中同一个时间只调用一个协程函数,即只有一个协程函数执行完毕了,再去调用另一个协程函数。
- 使用Awatiable类包装所有的异步函数,一个异步函数处理一请求中的一部分工作(比如执行一次SQL查询,或者执行一次http请求等)。
- 在对应的协程函数中按照需要,通过增加co_wait关键字同步的调用这些异步函数。注意一个异步函数(包装好的Awaiable类)可以在多个协程函数中调用,协程函数可能在多个线程中被调用(虽然一个线程同一时间只调用一个协程函数),所以最好保证Awaiable类是线程安全的,避免出现需要加锁的情况。
- 在线程中通过调用不同的协程函数响应不同的请求。
写在最后
协程事实上并没有消灭回调函数,它只是为我们提供了一种方案,让我们可以“用同步调用的方式进行异步调用”。
回调函数还是存在的,只是被实现所隐藏起来了。
同时,协程并不是只能用于“用同步调用的方式进行异步调用”,它的本意其实就是“协同工作”。
也就是我等待你完成某个操作再去执行其它的操作,和多线程类似,但是避免了资源竞争,因为只有一个线程。
所有拥有类似需求的情况都可以使用协程来做。
目前C++20中协程只是刚刚出现,作为一个基础设施存在,因为缺乏必要的辅助支持的库,直接使用协程反而会增加开发的复杂度和困难度。我们可以等待C++23为我们带来一个更好用的协程,而现在我们需要的就是了解而已。
参考链接
https://lewissbaker.github.io/
C++20标准的草案n4849.pdf http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4849.pdf § 17.12.5
C++20标准的草案n4849.pdf http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4849.pdf § 7.6.2.3
到此这篇关于C++20中的协程(Coroutine)的实现的文章就介绍到这了,更多相关C++20 协程 内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!