20

C++20 协程

 3 years ago
source link: https://netcan.github.io/2020/09/05/C-20协程/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

C++20 终于引入了协程特性,给库作者提供了一个实现协程的机制,让用户方便使用协程来编写异步逻辑,降低了异步并发编程的难度。结合我最近协程的学习,在这里记录一下相关内容。

使用场景

协程和普通函数相比,多了个中途随时 挂起 ,随后 恢复 的过程,当用户调用一个阻塞请求接口,从而让出控制权,当响应时,恢复之前的控制流,从而大大提高线程复用率,这也注意了协程只是并发的,并不是真正意义上的并行,在 IO 密集型场景下,协程能够很好的提高资源利用率,用少数的线程达到并发成百上万个协程的效果。

而相对传统的线程池 + 回调模式,每发起一个请求,为了避免阻塞当前线程,需要挂一个回调函数处理后续过程,而回调函数又可能产生竞争,导致得加锁处理。而协程却能够以同步方式写实现异步,后续过程直接挂起,当响应的时候恢复执行。

我参与的项目中,对象随时都可能起个线程干活,或者常驻于对象生命周期里,统计下来整个项目居然开了几百个线程,由于多线程编程难免导致竞争,从而需要锁这种很低级的机制做同步,而一旦引入了锁,就不可避免的扩散开来,大家看到这里加把锁,那我也加把锁,统计下来代码里面居然也有几百把锁。。真是维护的噩梦啊

由于协程能够随时挂起,后续恢复,这就能实现一些延迟计算的特性,例如生成器。

扯远了,本文主题是关于 C++20 的协程,在 C++20 还没稳定之前,先来学习一下相关知识,读完本文后你应该能利用这个机制实现一些想要的协程了。

概念模型

C++20 的协程设计为无栈协程,相对于有栈协程,省掉了上下文切换开销,只能手动切换,效率更高,也不用管理复杂的寄存器状态,移植性更好,但这同时也导致了不能被非协程函数嵌套调用。

同时引入了 3 个关键字:

  • co_yield: 挂起并返回值
  • co_await: 挂起
  • co_return: 结束协程

当一个函数出现了上面的关键字,则该函数是个协程。

Promise

当 caller 调用一个 callee 协程的时候,协程自身的状态信息(形参,局部变量,自带数据,各个阶段点执行点)会被保存在堆上的 Promise 对象中,这也是编译器会在协程里面插入 Promise 相关代码,以及一些执行点。由于 Promise 的大小可以在编译期计算出来,从而避免了内存浪费。而 Promise 对象所有权可由 coroutine_handle 句柄持有。

Future

而 Future 对象主要是与 Promise 对象交互的桥梁,既 caller 与 callee 之间的通信:

  • callee 挂起时,将值返回给 caller: yield 语义
  • callee 执行结束时,将值返回给 caller: return 语义
  • callee 恢复时,caller 将值带给 callee

需要注意的是,这些概念和标准库的 std::promise/std::future 不是同一个东西,后者用于做同步用, std::future 会阻塞等待直到 std::promise 提供值,可以看做是条件变量的封装,同样地,和其他语言的 Promise/Future 概念也不一样。

Awaitable

如果一个对象是 Awaitable 对象,那么可以用 co_await 操作符去触发该对象的动作 ready/suspend/resume,从而转移、恢复控制权,co_await 细节留到后面在介绍。

具体机制

了解了概念模型后,我们可以进一步探讨背后的机制了。

Promise/Future 对象

当一个协程被调用时,会创建 Promise 对象,然后编译器会在各个阶段插入一些代码:

{
  co_await promise.initial_suspend();
  try
  {
    <body-statements>
  }
  catch (...)
  {
    promise.unhandled_exception();
  }
FinalSuspend:
  co_await promise.final_suspend();
}

可以看到一个协程函数,分为如下几个步骤:

<body-statements>
<body-statements>
coroutine_handle<Promise>::done

而协程返回类型则是一个 Future 对象,这一步编译器通过 Promise::get_return_object() 来创建 Future 对象。而 Future 对象一般持有 Promise 的句柄: coroutine_handle<Promise> ,这样 caller 可以通过 Future 与 Promise 交互,从而恢复协程。

而 Promise 对象释放的时间点有两个,避免重复执行,否则会 double free:

coroutine_handle<Promise>::destroy()

比较好的做法是在 final_suspend 阶段挂起,这时候就不可 resume 了,在 caller 通过调用 Future 持有的句柄 destroy() 方法释放 Promise 对象。

综上,一个 Promise 对象需要实现如下方法:

  • initial_suspend: 返回一个 Awaitable 对象
  • final_suspend: 返回一个 Awaitable 对象
  • get_return_object: 返回一个 Future 对象给 caller
  • unhandled_exception: 处理异常
  • return_value/return_void: co_return 时返回值给 caller
  • yield_value: 挂起时返回值给 caller

再来看看其 coroutine_handle<Promise> 句柄编译器提供了哪些主要方法:

coroutine_handle

Awaitable 对象

前面提到的 co_await 关键字,其操作的对象其实是 Awaiter 对象,若对象实现如下方法,则说明该对象是 Awaitable 的:

coroutine_handle<>

那么当执行 co_await <expr> 表达式时,编译器会生成如下代码:

{
  auto&& value = <expr>;
  auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
  auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
  if (!awaiter.await_ready())
  {
    <suspend-coroutine>

    //if await_suspend returns void
    try {
        awaiter.await_suspend(coroutine_handle);
        return_to_the_caller();
    } catch (...) {
        exception = std::current_exception();
        goto resume_point;
    }
    //endif
    //if await_suspend returns bool
    bool await_suspend_result;
    try {
        await_suspend_result = awaiter.await_suspend(coroutine_handle);
    } catch (...) {
        exception = std::current_exception();
        goto resume_point;
    }
    if (not await_suspend_result)
        goto resume_point;
    return_to_the_caller();
    //endif
    //if await_suspend returns another coroutine_handle
    decltype(awaiter.await_suspend(std::declval<coro_handle_t>())) another_coro_handle;
    try {
        another_coro_handle = awaiter.await_suspend(coroutine_handle);
    } catch (...) {
        exception = std::current_exception();
        goto resume_point;
    }
    another_coro_handle.resume();
    return_to_the_caller();
    //endif
  }
  resume_point:
if(exception)
  std::rethrow_exception(exception);
"return" awaiter.await_resume();
}

也就是:

  1. 通过 <expr> 拿到 Awaiter 对象
  2. 通过 Awaiter.await_ready()方法判断是否需要挂起,若为 true 则无需挂起
  3. 判断 Awaiter.await_suspend()的返回值类型:
    • void,无返回值,直接挂起返回 caller
    • bool,若为 true,则返挂起返回 caller,否则不挂起,直接 resume
    • coroutine_handle<> , 则挂起并将控制权转移到另一个协程上,另一个协程可以再 resume 回来,到达 resume_point
  4. Awaiter.await_resume()的返回值即为 co_await 的结果

那么问题来了,谁来创建 Awaiter 对象呢?有两种方法:

await_transform(<expr>)

标准库里面实现了两种 Awaiter,分别如下:

struct suspend_never {
  bool await_ready() const { return true; }
  void await_suspend(coroutine_handle<>) const {}
  void await_resume() const {}
};

struct suspend_always {
  bool await_ready() const { return false; }
  void await_suspend(coroutine_handle<>) const {}
  void await_resume() const {}
};

主要在 await_ready 阶段判断是否需要挂起协程。

最后 co_yield <expr> 其实是 co_await 的语法糖,生成如下代码:

co_await promise.yield_value(expression);

而 co_return 则会调用 Promise 对象的 return_void/return_value 方法。

协程实战

有了以上知识,应该足够实现一些协程了。

为了简单起见,这里我实现一个 Fibonacci 的生成器,完整代码可以见: https://github.com/netcan/recipes/blob/master/cpp/coroutine/FibonacciGen.cpp

首先先写协程函数,并在 main caller 中调用,内容如下:

FiboFuture generate_fibo() {
    int i = 0, j = 1;
    while (true) {
        co_yield j;
        std::tie(i, j) = std::make_pair(j, i + j);
    }
}

int main() {
    for (auto x = generate_fibo(); x < 1000; x.resume())
        std::cout << "fibo:" << x << std::endl;
    return 0;
}

接着实现我们所需要的 Proimse 对象,用于将结果传给 caller:

struct promise_type {
    int value_; // 返回结果给 caller
    auto initial_suspend() { return suspend_never{}; }
    auto final_suspend() noexcept { return suspend_always{}; } // final_suspend 挂起,由 FiboFuture 释放 promise 对象
    FiboFuture get_return_object()
    { return {coroutine_handle<promise_type>::from_promise(*this)}; } // 返回 FiboFuture 对象
    void unhandled_exception() { std::terminate(); }

    auto yield_value(int value) { // yield 一个值并挂起返回 caller
        value_ = value;
        return suspend_always{};
    }
    void return_void() {}
};

还有对应的 Future:

struct FiboFuture {
    struct promise_type;
    FiboFuture(coroutine_handle<promise_type> handle): handle_(handle) {}

    operator int() { return handle_.promise().value_; }
    void resume() { if (! handle_.done()) handle_.resume(); }
    ~FiboFuture() { handle_.destroy(); }
private:
    coroutine_handle<promise_type> handle_;
};

一切搞定,这就是目前生成器的实现,待后续集成到标准库中去,方便使用。

还有一个例子是 caller/callee 相互协作,互相通信完成任务,具体见: https://github.com/netcan/recipes/blob/master/cpp/coroutine/DoubleClick.cpp

总结

期待 C++20 协程的稳定成熟,这样写业务代码就简单多了,心智负担没那么重了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK