【协程】C++20协程初体验

为什么我们需要协程?

为什么我们有了线程还需要协程呢?(其实这个问题不应该这么问,协程的出现在线程之前)在一个进程中虽然我们可以创建多个线程,但是在一个进程中能创建的线程数量是有限制的,并且线程的调度仍然受操作系统控制,也就是说线程何时抢占、何时被抢占对于开发者来说都是透明的,并且在调度的过程中还可能涉及到用户态和内核态的切换开销。

当我们需要去处理一个非常耗时的IO操作时(假设使用的阻塞IO),为了不阻塞当前线程,我们可能会想到新建一个线程去执行这个操作,但是线程的创建和调度也是需要消耗资源的,我们希望有更加轻便的方法,例如:在当前线程中,一个任务遇到阻塞IO时,不要傻傻的停在这里,而是暂停这个任务,转而去执行其他任务,直到IO完成再恢复之前的任务来运行。

这里看起来是不是像两个函数之间的调用和被调用关系?确实有点像,但区别可大了,在任务1中我们并没有去显式地调用任务2!

协程的最本质的解释是“可以挂起和恢复的函数”。例如上图中我们遇到耗时的IO操作了,我们就可以主动将当前运行的函数挂起(suspend),让其等待(await)IO操作,让线程去运行其他的函数,直到IO操作完成后再恢复(resume)。上下文切换的时机是靠调用方(写代码的开发人员)自身去控制的,这样协程的调度掌握在我们自己手中,相比与线程减小了系统切换上下文和其他资源的开销。因此协程在需要处理大量I/O操作或者并发任务的情况下提高程序的性能和可维护性。

C++20带来的协程

C20带来的协程并不像python或lua中的那么易用,相反,C给我们提供的是更为底层的操作(不过C++23已经有std::generator这种更高级的抽象,之后也会有更多丰富的用法)。

C++协程中有很重要的三个概念:

  • Promise
  • Awaitable
  • Coroutine Handle

Promise

promise_type 是每个协程函数的幕后执行对象。它主要负责以下几个任务:

  • 创建和初始化协程:当协程开始执行时,编译器会通过 promise_type 创建一个对象,并调用其 get_return_object() 方法来获取协程的返回对象。
  • 处理协程的暂停和恢复:协程在暂停时会调用 yield_value()await_suspend() 等方法来处理协程的状态,并决定何时恢复。
  • 处理协程的结束:当协程执行结束时,return_void()return_value() 会被调用,来处理协程的返回结果。

以下是 promise_type 中一些常见的方法:

  • get_return_object():用于创建和返回协程的返回对象,一般是协程返回类型的实例。
  • initial_suspend():返回一个 std::suspend_alwaysstd::suspend_never,决定协程在启动时是否立即暂停。
  • final_suspend():返回一个 std::suspend_alwaysstd::suspend_never,决定协程在结束时是否暂停,以允许调用方执行清理操作。
  • return_void()return_value(T value):用于在协程完成时返回结果。return_void() 用于没有返回值的协程,而 return_value(T) 则用于有返回值的协程。
  • yield_value(T value):用于生成值并让协程暂停,等待下一次恢复时继续执行。

Awaitable

Awaitable 是一个可以与 co_await 表达式一起使用的对象或类型。Awaitable 对象必须提供一组特定的方法,使协程可以暂停执行,并在某个条件满足时继续执行。

co_await 是 C++ 协程中的一种操作符,用于暂停协程并等待某个条件的满足。当协程遇到 co_await 时,它会暂停,并返回控制权给调用者。协程可以通过调用 co_await some_awaitable 来等待 some_awaitable 完成。

一个 Awaitable 对象需要提供以下三个方法中的一个或多个:

  • operator co_await:返回一个 Awaitable 对象。Awaitable 对象是实际实现等待逻辑的对象。
  • await_ready():这是 Awaitable 对象上的方法。它返回一个 bool,用于指示是否需要等待。如果返回 true,协程将不会暂停。
  • await_suspend(std::coroutine_handle<>):这是 Awaitable 对象上的方法。它接受一个 std::coroutine_handle<> 参数,并在协程暂停时调用。这个方法决定协程何时恢复执行。
  • await_resume():这是 Awaitable 对象上的方法。它在协程恢复时调用,并返回 co_await 表达式的结果。

C++中提供了两个简单的Awaitable:std::suspend_neverstd::suspend_always

Coroutine Handle

std::coroutine_handle 是 C++20 协程库中一个核心的工具类,用于表示和操作协程。它是一个模板类,通常用来指向协程的状态信息。它可以通过协程的 promise_type 访问和控制协程的状态。每个协程在创建时,都会生成一个 std::coroutine_handle,用于管理协程的生命周期。

std::coroutine_handle 提供了一系列方法来控制协程的执行,包括以下几个主要功能:

  • 创建和获取句柄
    • std::coroutine_handle<>::from_address(void* ptr):通过指针获取一个句柄。
    • std::coroutine_handle<promise_type>::from_promise(promise_type& promise):通过 promise_type 对象创建一个句柄。
  • 控制协程的执行
    • void resume():恢复协程的执行。
    • void destroy():销毁协程并释放其占用的资源。
    • void operator()():等效于 resume(),恢复协程的执行。
    • void* address():返回协程句柄的地址,用于低级操作。
  • 检查协程的状态
    • bool done():检查协程是否已经完成执行。
  • 访问 promise_type
    • promise_type& promise():获取与当前协程关联的 promise_type 对象,允许访问协程内部状态。

简单示例

这里有一个使用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
#include <coroutine>
#include <iostream>
#include <thread>

using namespace std::chrono_literals;

struct Result {
struct Promise {
Result get_return_object() {
return std::coroutine_handle<Promise>::from_promise(*this);
}

std::suspend_never initial_suspend() {
return {};
}

std::suspend_always final_suspend() noexcept {
return {};
}

void unhandled_exception() {}
};

using promise_type = Promise;

Result(std::coroutine_handle<Promise> h)
: handle(h) {}

std::coroutine_handle<Promise> handle;
};

Result hello() {
std::cout << "Hello " << std::endl;
co_await std::suspend_always{}; // 挂起hello
std::cout << "world!" << std::endl;
std::cout << "hello one\n";
}

Result hello2() {
std::cout << "你好 " << std::endl;
std::cout << "hello two two\n";
co_await std::suspend_always{}; // 挂起hello2
std::cout << "世界!" << std::endl;
}

int main() {
Result coro = hello();
Result coro2 = hello2();
coro.handle.resume(); // 恢复hello
coro2.handle.resume(); // 恢复hello2
}

在main函数中,一开始我们启动了协程hello,输出了“Hello”后被挂起;之后启动了协程hello2,其输出“你好\nhello two two\n”后也被挂起。紧接着我们通过coroutine_handle来依次恢复这两个协程,最终输出结果为:

1
2
3
4
5
6
Hello 
你好
hello two two
world!
hello one
世界!

References

  • https://zplutor.github.io/2022/03/25/cpp-coroutine-beginner/
  • https://www.bluepuni.com/archives/stackless-coroutine-and-asio-coroutine
  • https://zhuanlan.zhihu.com/p/355100152?utm_psn=1808059511308697600
  • https://itnext.io/c-20-coroutines-complete-guide-7c3fc08db89d
  • https://jasonkayzk.github.io/2022/06/03/%E6%B5%85%E8%B0%88%E5%8D%8F%E7%A8%8B/
  • https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await
  • https://juejin.cn/post/6844903715099377672