Featured image of post 从头学习C++20协程

从头学习C++20协程

全面解析C++20协程的原理与实现,涵盖Promise、Coroutine Handle、关键字用法及状态机设计,深入探讨协程的生命周期管理与异常处理。通过详细的代码示例,展示协程在异步编程中的实际应用,帮助开发者掌握C++协程的核心概念与高效使用技巧。

差不多有半年没有写东西了,主要是这段时间太忙了,一直没空,这两周开始有了一点空闲时间,重新捡起了C++协程相关的知识,又学了一下,写篇文章,做点笔记。 去年就了解了一点C++协程相关的知识,但是当时只是浅显的了解了一下,很多东西没有弄清楚,这次整理了一些概念,写了一些demo。

C++的协程是C++20开始支持的,协程也不是什么新鲜东西了,诞生于2009年的golang出生就带协程。使用过golang协程的可能都深有体会,协程在异步编程中提供了很好的支持,可以大幅度简化代码逻辑。但是说实话,C++这版本的协程只是实现了基础功能,对于写具体业务代码来说,还是很不方便的,太多的东西需要自己解决。不像golang的协程,一个 go 关键字就行了。所以很多人开玩笑说,这版本的协程是给库作者的,不是给普通开发者的。这样也有好处,给了使用者更多的灵活性和控制权。

在学习之前,先明确一个知识点,C++20的协程,如果没有自己实现多线程的调度逻辑,都是在单线程中执行的。只是 并发 不是 并行 。golang的协程能做到并行,是因为go的runtime实现了多线程的调度逻辑,而C++20的协程只是提供了协程的语法和基本的状态机支持,并没有提供多线程的调度逻辑。C++的协程,是通过函数的状态切换(把暂时不需要使用CPU的函数暂停,等到合适的时机再恢复执行)提高了CPU的利用效率,看上去像是异步执行了。

C++协程的实现方式,后面会浅显介绍一下。因为太深入的我也了解不多。目前了解到的,C++的协程是基于状态机的实现的,编译器在编译协程时,会将协程转换为状态机的形式,从而实现协程的调度和切换。

说到协程,首先要了解的是协程的两种基本类型:有栈协程和无栈协程。

有栈协程和无栈协程

  • 有栈协程: 协程在执行时会使用自己的栈空间,协程的调用和返回都是通过栈来实现的。这种方式的优点是实现简单,性能较好,但缺点是栈空间有限,容易导致栈溢出。go语言的协程就是有栈协程的一个典型例子。

  • 无栈协程: 协程在执行时不使用自己的栈空间,而是将所有的状态信息保存在堆上。这种方式的优点是可以支持更大的协程栈,但缺点是实现复杂。C#的协程就是无栈协程的一个典型例子。

既然是聊C++的协程(没有特殊说明,下文中所提到的C++协程都是指C++20的原生协程),那关注点就要放在C++的协程实现上,C++的协程采用的就是无栈协程,当然也有一些第三方协程库是有栈协程,这个不在本次讨论范围。C++的协程是基于状态机的实现的,编译器在编译协程时,会将协程转换为状态机的形式,从而实现协程的调度和切换。

说的通俗一点,就是编译器在编译代码时,把协程函数编译成一个特殊的函数,这个函数有很多状态,可以被挂起(让出CPU资源,也叫暂停执行),恢复执行等等。这样的函数也叫做可重入函数。协程的状态信息(如局部变量、返回地址等)保存在堆上,而不是栈上。

画一下协程的状态机图,可能会更清晰一些:

从这个图中可以看出,协程函数可以被挂起(暂停执行)和恢复执行,协程函数可以在多个状态之间切换,这些状态包括挂起、恢复和完成等。这样的设计使得协程能够在执行过程中灵活地让出控制权。

至于协程函数是怎样被挂起和恢复的,主要是通过编译器生成的状态机来实现的。当协程函数被挂起时,编译器会保存当前的执行状态(如局部变量、返回地址等),并将控制权交回给调用者。当协程函数被恢复时,编译器会恢复之前保存的执行状态,从而实现协程的继续执行。

下面将一点点来分析协程函数的控制。

C++协程函数

C++协程函数的实现主要依赖于编译器的支持。在编写协程函数时,开发者只需使用 co_awaitco_yieldco_return 等关键字来定义协程的行为。编译器会根据这些关键字生成相应的状态机代码,从而实现协程的挂起和恢复。

具体来说,当协程函数被调用时,编译器会创建一个状态机对象来管理协程的状态。这个状态机对象会保存协程的局部变量、返回地址等信息。当协程函数执行到 co_awaitco_yield 时,编译器会将当前的执行状态保存到状态机对象中,并将控制权交回给调用者。当协程函数被恢复时,编译器会从状态机对象中恢复之前保存的执行状态,从而实现协程的继续执行。

需要注意的是,C++协程函数的返回类型并不是普通的返回值,而是一个特殊的协程句柄(coroutine handle)。这个句柄可以用来管理协程的生命周期,例如启动、挂起和恢复等操作。

看到这些可能会一头雾水,别着急,让我们一点点来。

协程函数和普通函数的区别

最明显的区别是协程函数可以被挂起和恢复。但是有没有想过,编译器在编译代码的时候,是怎样识别一个函数是普通函数还是协程函数呢? 这主要是通过协程函数的返回类型来判断的。协程函数的返回类型是一个特殊的协程句柄coroutine handle,而不是普通的返回值。这个协程句柄可以用来管理协程的生命周期。

所以当编译器看到一个函数的返回类型是coroutine handle时,就会将其识别为协程函数。

下面来看一下什么是协程句柄 coroutine handle

协程句柄是一个特殊的对象,用于管理协程的生命周期。它可以用来启动、挂起和恢复协程。协程句柄的实现依赖于编译器生成的状态机代码。

说到 coroutine_handle 就不得不提协程的 promisepromise 类型是协程的核心,它负责管理协程的状态和生命周期。每个协程都有一个对应的 promise 对象,用于保存协程的状态信息。

在 C++20 中,协程句柄的类型是 std::coroutine_handle。这个类型是一个模板类,接受一个 promise 类型作为模板参数。promise 类型是协程的状态机对象,负责管理协程的状态。

promise 不是一个具体的类型,通常需要实现以下几个方法的结构体就是 promise类型:

  • get_return_object():返回协程的句柄
  • initial_suspend():协程开始时是否挂起
  • final_suspend():协程结束时是否挂起
  • yield_value():协程中使用 co_yield 返回值时的处理
  • return_void():协程返回时是否返回值
  • unhandled_exception():处理未捕获的异常

通过这些方法,协程的 promise 类型可以与协程句柄进行交互,从而实现协程的挂起和恢复。

也就是说,只要一个函数的返回值是 coroutine_handle 类型,编译器就会将其识别为协程函数,协程函数能实现挂起和恢复的功能,主要是通过 promise 对象来管理协程的状态和生命周期。

协程关键字

在C++20中,协程函数使用了三个个关键字来控制协程行为

  1. co_await:用于挂起协程并等待一个异步操作的完成。
  2. co_yield:用于返回一个值并挂起协程的执行。
  3. co_return:用于返回协程的最终结果。

通过这些关键字,开发者可以方便地定义协程的行为,从而实现异步编程的需求。

co_awaitco_yield 关键字可以用于实现协程的挂起和恢复,而 co_return 关键字则用于返回协程的最终结果。

其中 co_await 是用于挂起协程并等待一个异步操作的完成的关键字。

说到 co_await,我们就不得不提一下可等待对象awaitable。这个对象和 promise 有点类似,不是一个具体的类型,而是一个接口。这个对象需要实现以下三个方法:

  1. await_ready():用于检查异步操作是否已经完成。
  2. await_suspend():用于挂起协程并等待异步操作的完成。
  3. await_resume():用于恢复协程并返回异步操作的结果。

下面一个个来介绍一下这三个方法的具体实现。

1bool await_ready() const noexcept;

这个函数用于检查异步操作是否已经完成。如果异步操作已经完成,则返回 true,否则返回 false。如果返回 true,则协程会立即恢复执行,而不会挂起。

这个函数一般会在协程的 await 表达式中被调用。如果返回 false,则协程会被挂起,等待异步操作的完成。

1void await_suspend(std::coroutine_handle<result::promise_type> coro)

这个函数用于挂起协程并等待异步操作的完成。它接受一个协程句柄作为参数,这个句柄指向当前协程的 promise 对象。 当异步操作完成时,协程会被恢复执行。

1void await_resume()

这个函数用于恢复协程并返回异步操作的结果。它一般会在协程的 await 表达式中被调用,用于获取异步操作的结果。

函数的返回值类型可以是任意类型,通常是异步操作的结果类型。如果异步操作没有结果,则可以返回 void

这里还有两个特殊的 awaitstd::suspend_alwaysstd::suspend_never

  • std::suspend_always:表示协程在每次挂起时都会等待,直到显式调用 resume() 恢复协程。
  • std::suspend_never:表示协程在挂起时不会等待,直接返回控制权给调用者。

直接去看一下这两个类型的实现就明白了,下面是在 llvm-18 的 libc++ 中的实现:

 1struct suspend_never {
 2  _LIBCPP_HIDE_FROM_ABI constexpr bool await_ready() const noexcept { return true; }
 3  _LIBCPP_HIDE_FROM_ABI constexpr void await_suspend(coroutine_handle<>) const noexcept {}
 4  _LIBCPP_HIDE_FROM_ABI constexpr void await_resume() const noexcept {}
 5};
 6
 7struct suspend_always {
 8  _LIBCPP_HIDE_FROM_ABI constexpr bool await_ready() const noexcept { return false; }
 9  _LIBCPP_HIDE_FROM_ABI constexpr void await_suspend(coroutine_handle<>) const noexcept {}
10  _LIBCPP_HIDE_FROM_ABI constexpr void await_resume() const noexcept {}
11};

协程注意事项

  1. 协程的生命周期:协程的生命周期由协程句柄 coroutine_handle 管理。协程句柄可以用来启动、挂起和恢复协程。开发者需要注意协程的生命周期,以避免悬空指针和资源泄露等问题。 协程句柄一定要妥善处理,一个协程句柄只能调用一次 resume()destroy() 方法。而且在协程结束后,协程句柄会被销毁,因此开发者需要确保在协程结束前完成对协程句柄的所有操作。如果没有调用 destroy() 方法,协程的资源可能无法被释放,从而导致内存泄漏。如果多次调用 resume()destroy() 方法,可能会导致未定义行为。

  2. 异常处理:协程中的异常处理与普通函数有所不同。协程可以通过 promise 对象的 unhandled_exception() 方法来处理未捕获的异常。开发者需要在 promise 类型中实现这个方法,以确保协程中的异常能够被正确处理。

协程例子

说了这么多,通过这些文字可能还是难以理解,下面通过一个简单的例子来帮助理解协程的使用。

我感觉真正适合协程的场景是需要等待某个操作完成后再继续执行后续逻辑的情况,比如网络请求、文件读写等。最好的搭档就是异步IO的场景。但是直接拿 iouring 这种高性能的异步IO库来做协程的调度,可能会比较复杂,增加理解成本。

所以就先拿一个简单的例子来说明协程的使用。这个例子是一个简单的协程函数,它会打印一些数字。

 1#include <coroutine>
 2#include <iostream>
 3#include <optional>
 4
 5template <typename T>
 6struct Task {
 7    struct promise_type {
 8        std::optional<T> current_value;
 9
10        auto get_return_object() {
11            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
12        }
13
14        auto initial_suspend() { return std::suspend_always{}; }
15        auto final_suspend() noexcept { return std::suspend_always{}; }
16
17        void unhandled_exception() { std::terminate(); }
18        void return_void() {}
19
20        auto yield_value(T value) {
21            current_value = value;
22            return std::suspend_always{}; // 挂起,等待 next() 恢复
23        }
24    };
25
26    std::optional<T> next() {
27        if (!handle_ || handle_.done()) return std::nullopt;
28        handle_.resume();
29        if (handle_.done()) return std::nullopt;
30        return std::move(handle_.promise().current_value);
31    }
32
33    Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
34    Task(Task&& other) noexcept : handle_(other.handle_) { other.handle_ = nullptr; }
35    Task& operator=(Task&& other) noexcept {
36        if (this != &other) {
37            if (handle_) handle_.destroy();
38            handle_ = other.handle_;
39            other.handle_ = nullptr;
40        }
41        return *this;
42    }
43
44    ~Task() { if (handle_) handle_.destroy(); }
45
46    std::coroutine_handle<promise_type> handle_;
47};
48
49struct Awaiter {
50    bool await_ready() const noexcept { return false; }
51
52    void await_suspend(std::coroutine_handle<> h) {
53        std::cout << "Awaiter suspend " << input << std::endl;
54        // 这里必须恢复协程
55        h.resume();
56    }
57
58    int await_resume() noexcept {
59        std::cout << "Awaiter resume " << input << std::endl;
60        return input * 10;
61    }
62
63    int input = 0;
64};
65
66Task<int> AsyncFunction(int n) {
67    for (int i = 0; i < n; ++i) {
68        auto ret = co_await Awaiter{i + 1};
69        co_yield ret;
70    }
71    co_return;
72}
73
74int main() {
75    auto t = AsyncFunction(5);
76    while (auto v = t.next()) {
77        std::cout << "Received: " << *v << std::endl;
78    }
79    return 0;
80}

程序输出如下:

 1Awaiter suspend 1
 2Awaiter resume 1
 3Received: 10
 4Awaiter suspend 2
 5Awaiter resume 2
 6Received: 20
 7Awaiter suspend 3
 8Awaiter resume 3
 9Received: 30
10Awaiter suspend 4
11Awaiter resume 4
12Received: 40
13Awaiter suspend 5
14Awaiter resume 5
15Received: 50

通过打印的信息可以看到,协程函数 AsyncFunction 在每次迭代时都会挂起,并通过 co_yield 返回一个值。每次调用 next() 方法时,协程会恢复执行,直到下一个 co_yield 或协程结束。

这里是为了展示 co_yield 的用法。才会在协程中使用 co_yield 来返回值。这样写有点画蛇添足了,如果不需要返回值,可以直接使用 co_await 来等待异步操作的完成。这样可以简化代码,更符合协程的使用场景。

下面让我们来简化一下这段代码

 1#include <coroutine>
 2#include <iostream>
 3#include <thread>
 4
 5struct Task
 6{
 7    struct promise_type
 8    {
 9        auto get_return_object()
10        {
11            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
12        }
13
14        auto initial_suspend() { return std::suspend_always{}; }
15        auto final_suspend() noexcept { return std::suspend_always{}; }
16
17        void unhandled_exception() { std::terminate(); }
18
19        void return_void()
20        {
21        }
22    };
23
24    Task(std::coroutine_handle<promise_type> h) : handle_(h)
25    {
26    }
27
28    Task(Task&& other) noexcept : handle_(other.handle_) { other.handle_ = nullptr; }
29
30    Task& operator=(Task&& other) noexcept
31    {
32        if (this != &other)
33        {
34            if (handle_) handle_.destroy();
35            handle_ = other.handle_;
36            other.handle_ = nullptr;
37        }
38        return *this;
39    }
40
41    ~Task() { if (handle_) handle_.destroy(); }
42
43    std::coroutine_handle<promise_type> handle_;
44};
45
46struct Awaiter
47{
48    bool await_ready() const noexcept { return false; }
49
50    void await_suspend(std::coroutine_handle<> h)
51    {
52        std::cout << "Awaiter suspend " << input << std::endl;
53        std::thread t([h]()
54        {
55            //把协程挂起一段时间,模拟异步操作
56            std::this_thread::sleep_for(std::chrono::seconds(1));
57            h.resume();
58        });
59        t.detach(); // 分离线程,避免阻塞
60    }
61
62    int await_resume() noexcept
63    {
64        //协程恢复时执行的操作
65        std::cout << "Awaiter resume " << input << std::endl;
66        return input * 10;
67    }
68
69    int input = 0;
70};
71
72Task AsyncFunction(int n)
73{
74    for (int i = 0; i < n; ++i)
75    {
76        auto ret = co_await Awaiter{i + 1};
77        std::cout << "Awaiter returned " << ret << std::endl;
78        std::cout << "------------------------" << std::endl;
79    }
80    co_return;
81}
82
83int main()
84{
85    auto t = AsyncFunction(5);
86    t.handle_.resume();//手动唤醒协程
87    while (!t.handle_.done())
88    {
89    }
90    return 0;
91}

上面就是去掉 co_yield 后简化的代码。

程序执行的输出如下:

 1Awaiter suspend 1
 2Awaiter resume 1
 3Awaiter returned 10
 4------------------------
 5Awaiter suspend 2
 6Awaiter resume 2
 7Awaiter returned 20
 8------------------------
 9Awaiter suspend 3
10Awaiter resume 3
11Awaiter returned 30
12------------------------
13Awaiter suspend 4
14Awaiter resume 4
15Awaiter returned 40
16------------------------
17Awaiter suspend 5
18Awaiter resume 5
19Awaiter returned 50
20------------------------

main 函数中,我们手动唤醒协程,并通过循环等待协程完成。

1while (!t.handle_.done())
2{
3}

这里通过 while 循环不断地检查协程是否完成。为什么需要这样做,是因为如果没有检查,而是在协程挂起时直接返回,会导致程序直接退出。

这也是协程 异步 的体现。普通函数只要调用了,就会一直执行,直到整个函数都执行完成才会退出,然后由函数调用者继续执行。 协程函数会在调用 co_await 时挂起,让出CPU资源,由协程函数的调用者(这里就是main函数)继续执行。等到异步操作完成(也就是执行了 resume)后再恢复执行。

我们在 await_suspend 中创建了一个新的线程来模拟异步操作。在这个线程中,我们使用 std::this_thread::sleep_for 来让当前线程暂定1秒,模拟异步操作的延迟。

通过这种方式,我们可以在协程中使用 co_await 来等待异步操作的完成,而不需要使用 co_yield 来返回值。这使得代码更加简洁,也更符合协程的使用场景。

差不多能想到的就是这些了,当然这只是C++协程中的九牛一毛,想要更深入了解协程的使用,还要通过更多的例子来实践。

下期将使用 io_uring + 协程来实现真正的异步IO操作。发挥出协程的优势。

发表了61篇文章 · 总计147.03k字
本博客已稳定运行
© QX
使用 Hugo 构建
主题 StackJimmy 设计