C++20中的协程(Coroutine)的实现

来自:网络
时间:2021-06-03
阅读:

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 

返回顶部
顶部