初探协程

212 阅读11分钟

协程是一个很早的概念,但是很可惜直到C++20,协程才正式进入标准。其他语言比如Go,早已经提供了不错的协程库,但是C++20的协程标准只提供了语法,并没有实现具体的语义。如果想用好C++20的协程,还需要我们做很多工作。

为了能更好地学习C++20的协程,我们必须先了解一些基础知识,在了解了这些基础知识后,我们能对协程有更深刻的认识,明白了基础知识后,接下来我们将讲解C++20中协程的一些细节。

协程、线程、进程的关系

认识协程,我们先从搞清楚它和线程、进程的关系开始:

进程(Process) :

  • 是操作系统分配资源的基本单位,具有独立的内存空间。
  • 进程之间的通信较为复杂,通常需要通过操作系统提供的机制(如管道、共享内存、消息队列等)。
  • 进程切换的代价较高,因为需要切换内存页和执行环境

线程(Thread) :

  • 是操作系统调度的基本单位,共享同一个进程的内存空间。
  • 线程之间共享进程的资源,通信相对容易,但需要注意同步问题。
  • 线程切换的代价低于进程切换,但仍需进行上下文切换。

协程(Coroutine) :

  • 允许函数在中断点暂停和恢复执行,执行流程由程序控制,而不是操作系统调度。
  • 协程可以在执行过程中暂停,并在之后恢复执行。
  • 相比线程,协程的切换代价更低,因为不涉及操作系统的上下文切换,通常只需少量的CPU指令。
  • 协程通常用于实现异步编程和事件驱动的系统。

协程的种类和特点

协程可以分为对称协程和非对称协程,又可以根据实现方式分为有栈协程和无栈协程。

对称协程(Symmetric Coroutine) :

  • 协程之间可以相互调用和切换,切换是双向的。
  • 没有固定的“主协程”,每个协程可以自由地切换到另一个协程。

非对称协程(Asymmetric Coroutine) :

  • 存在一个“主协程”或“调用方”,其他协程只能返回到这个主协程。
  • 切换是单向的,从协程切回主协程。

有栈协程(Stackful Coroutine)

有栈协程是指每个协程拥有自己独立的栈空间,用于存储协程执行时的局部变量和调用栈。切换协程时,保存和恢复的上下文包括整个栈的状态。

特点

  1. 独立的栈空间:

    • 每个协程都有自己的栈,可以保存局部变量和函数调用状态。
    • 允许协程进行嵌套的函数调用,这使得有栈协程可以实现更复杂的控制流。
  2. 上下文切换:

    • 切换时需要保存和恢复整个栈的内容,因此切换开销较大。
    • 操作系统线程的切换机制相似,但由于协程是在用户空间完成的,所以通常开销会小一些。
  3. 灵活性:

    • 可以处理复杂的控制流,例如递归调用或深度嵌套的函数调用。
    • 允许在协程之间自由切换,适合实现对称协程。
  4. 内存管理:

    • 由于每个协程有独立的栈,内存使用量可能较大,尤其是在有大量协程的情况下。
    • 需要考虑栈的大小和栈溢出的问题。

无栈协程(Stackless Coroutine)

无栈协程没有独立的栈空间,而是通过编译器的转换机制,将协程分解为状态机,依靠程序中的状态保存和恢复机制来实现协程的暂停和恢复。

特点

  1. 状态机转换:

    • 编译器将协程分解成一系列状态,每次协程被恢复时,只需要切换到相应的状态。
    • 不保存整个栈,只保存当前的执行状态和必要的局部变量。
  2. 上下文切换:

    • 切换的开销很小,只涉及少量的状态信息。
    • 比有栈协程更高效,但牺牲了部分灵活性。
  3. 受限的调用结构:

    • 由于没有独立的栈,不能直接进行嵌套的函数调用。
    • 通常适用于非对称协程模式,即只能在固定的上下文中进行切换。
  4. 内存占用少:

    • 每个协程的内存占用非常小,因为只需要存储少量的状态信息。
    • 适合大量小而简单的并发任务。

也有人说协程是用户态线程,那是因为有其他语言对协程的实现是有栈协程,有栈协程相当于用户态线程,它切换的成本是用户态线程切换的成本。但是C++20中的协程是无栈协程,无栈协程只能被线程调用,本身并不抢占内核调度,同时它的切换的成本则相当于函数调用的成本。而C++是一门追求高效率的语言,这也是为什么C++20标准中对协程的实现的方式为无栈协程。一句话总结,C++20中的协程是可以暂停和恢复的函数。

实现细节

C++ 协程系统涉及相当多的独立数据类型,我们将一一认识。 C++ 实现本身提供了一种数据类型,即协程句柄。它标识协程代码的特定实例、数据类型及其所有内部变量及其执行状态的摘要(例如,当前是否正在运行,如果没有,则下次重新启动时将从何处恢复)。它提供了一个resume()方法,您可以调用该方法来实际启动协程运行,或者在挂起后重新启动它。所有剩余的数据类型均由协程设置的实现者提供。因此,我们可以指定协程设置的行为方式。最明显的类型是协程函数被声明为返回的类型。这是协程设置的用户将看到的唯一类型(无论他们是通过您的设置定义协程、调用它们还是两者)。

c++20规定了三个关键字co_awaitco_yieldco_return,在函数中,出现这三个关键字,C++会将这个函数试图理解为协程。

co_await: 用于等待一个异步操作。协程在执行到co_await时,会暂停执行,直到所等待的操作完成。

co_yield: 用于生成一个值并暂时挂起协程。它类似于生成器的yield,允许协程生成多个值。

co_return: 用于从协程返回结果并结束协程的执行。

promise_type 细节

当我们定义了一个返回类型为Task的协程后(一般都这么叫),编译器就会去找与它关联的 promise_type类型。promise_type 是一个用户定义的类型,用于管理协程的状态和返回值。每个协程都关联一个promise_type对象。

promise_type 需要实现以下几个成员函数,编译器会在适当的时机调用它们:

  • get_return_object: 返回协程的结果对象(通常是包含coroutine_handle的对象)。
  • initial_suspend: 在协程开始执行前,决定是否立即挂起(返回std::suspend_neverstd::suspend_always)。
  • final_suspend: 在协程执行完co_return或到达结尾时,决定是否挂起等待清理(返回std::suspend_neverstd::suspend_always或自定义的等待体类型)。
  • return_voidreturn_value: 设置协程的返回值,只能同时存在一个。return_void用于没有返回值的协程,return_value用于有返回值的协程。
  • unhandled_exception: 在协程中出现未捕获的异常时调用。

下面我们将用一个实例代码进行说明:

#include <coroutine>
#include <iostream>
#include <optional>// 代表一个可以被等待的任务
template <typename T> struct Task {
    struct promise_type {
        std::optional<T> value;
​
        // 返回一个包含协程句柄的任务对象
        Task get_return_object() {
            return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
​
        std::suspend_always initial_suspend() { return {}; }        // 协程启动时先挂起
        std::suspend_always final_suspend() noexcept { return {}; } // 协程结束时挂起等待清理
​
        void unhandled_exception() { std::terminate(); } // 未捕获的异常导致程序终止
​
        void return_value(T v) { value = v; }
    };
​
    std::coroutine_handle<promise_type> handle;
​
    Task(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~Task() {
        if (handle)
            handle.destroy();
    }
​
    // 获取协程的结果
    T get() { return handle.promise().value.value(); }
};
​
​
// 使用协程的函数,返回一个 Task<int>
Task<int> function() {
    std::cout << "开始执行function...\n";
    co_return 100;
}
​
int main() {
    std::cout << "开始构建task\n";
    auto task = function();
    std::cout << "构建task完成\n";
    while (!task.handle.done()) {
        task.handle.resume();
        int result = task.get(); // 获取协程的返回结果
        std::cout << "结果为 : " << result << std::endl;
    }
    return 0;
}

运行结果为:

开始构建task
构建task完成
开始执行function...
结果为 : 100
  • tip: 在上面代码中,Task对象直接包含内部类promise_type,这是一种简单的方法。但是如果promise_type类太过复杂,那么Task类就会变得很长,不方便编码和观察。因此也可以分别定义Promise类型和Task类型,在Task类中再通过using promise_type = Promise来让编译器能够找到promise_type类型。

当协程被实例化时,C++ 实现将负责创建 promise_type 类型的实例,并为其分配存储空间。但由开发者来创建调用者将收到的实际面向用户的对象 (指的是协程的返回对象Task)。这通过 Promise 类型中的 get_return_object() 方法完成。

指定协程是否应暂停启动或立即运行,这是通过initial_suspend() 方法完成的。该方法必须返回一个充当等待体的类型。可以看到,在执行 auto task = function(); 这行代码后,程序并没有去运行function()函数,而是在后续代码遇到 task.handle.resume() 后,才开始执行function()函数内部的代码。这是因为我们promise_type类型中的initial_suspend()函数返回的是std::suspend_always(),这表示先挂起协程,而不执行,当后续遇到resume()时候,才会执行协程内部的代码。如果initial_suspend()返回的是std::suspend_never(),那么协程将立即执行,而不是挂起。

awaiter(等待体) 和 awaiterable(可等待对象)

awaitable 是一个表示可以被co_await等待的对象。

awaiter 对象则需要实现以下接口:

  • await_ready: 返回true表示不需要挂起,false表示需要挂起。
  • await_suspend: 在协程挂起时被调用。通常用来注册回调或将协程句柄保存到事件循环中。
  • await_resume: 在协程恢复时被调用,返回等待结果。

二则的区别是awaier一定是awaiterable,即等待体可以被co_await。但是awaiterable不一定是awaiter,即可等待对象不一定是等待体,也就是说它并不一定实现这三个接口。但awaitable 对象的这些接口通常由一个awaiter对象实现。

协程对象如何工作?首先执行get_return_object()方法构建协程对象,然后执行initial_suspend()方法判断协程是否立即执行,返回std::suspend_always()代表挂起,返回std::suspend_never()代表立即执行。当协程co_await 一个对象时,首先调用等待体的await_ready(),返回true代表立即恢复执行,转而运行await_resume()await_ready()返回false代表挂起,转而执行await_suspend()awaiter_suspend()返回 void 类型或者返回 true,表示当前协程挂起之后将执行权还给当初调用或者恢复当前协程的函数;返回false则恢复执行当前协程,即执行await_resume(),返回其他协程的句柄则将控制权交给其他协程,程序将运行其他协程。后续协程运行完毕将执行final_susend()final_suspend决定是否要自动销毁协程,返回 std::suspend_never 就自动销毁协程,否则需要用户手动去销毁。

总结

C++20 协程提供了一种高效管理异步任务的机制。通过 promise_typecoroutine_handleawaitable 对象的配合,可以实现复杂的异步逻辑。协程的关键在于理解它们的生命周期和状态管理,这些由 promise_typeawaitable 的实现来控制。

在实际使用中,编写自己的 promise_typeawaitable 对象,可以完全自定义协程的行为,满足各种异步编程需求。

在下一篇中,我们将会实现一个基本的Task类。