C++ 20 协程的探索

Masimaro
2026-05-25 / 0 评论 / 2 阅读 / 正在检测是否收录...

C++ 20 中新增的协程特性可以说是近几年更新的最重要的一个特性。早在我使用python的时候,我就体会到python中的协程和它在处理list时使用yield 的优雅。现在C++ 也支持了协程,我们可以实现类似python中的功能了

完整的协程例子

在python中,我们可以实现如下的函数

def fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        yield a  # 暂停函数,返回a
        a, b = b, a + b

# 使用生成器
for num in fibonacci(5):
    print(num)

早期的C++ 中我们只能使用

std::vector<int> fibonacci(int n)
{
    vector<int> res;
    int a = 0, b = 1;
    for(int i = 0; i < n; i++)
    {
        int c = a + b;
        a = b;
        b = c;
        res.push_back(c);
    }
}

for(auto i : fibonacci(5))
    std::cout << i << std::endl;

上面两个对比,python示例的特点在于它不是每次调用都计算指定范围内的所有结果,而是每调用一次接着上一次的进行调用,能极大的提升运行效率。

一个好的消息是,在C++20 中新增了协程,我们也能实现类似的功能。而坏消息就是C++20 的协程设计成了底层的“语言特性骨架”,没有提供开箱即用的高层库,所以它的概念相对复杂。

#include <coroutine>

struct Generator {
    struct promise_type {
        int currentValue;
        std::suspend_always initial_suspend() { return std::suspend_always{}; }
        std::suspend_always final_suspend() noexcept { return std::suspend_always{}; }

        auto yield_value(int value)
        {
            currentValue = value;
            return std::suspend_always{};
        }

        Generator get_return_object()
        {
            return Generator{ std::coroutine_handle<promise_type>::from_promise(*this) };
        }

        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle<promise_type> h;

    Generator(std::coroutine_handle<promise_type> handle) :
        h(handle)
    {}

    ~Generator()
    {
        if (h)
            h.destroy();
    }

    bool move_next()
    {
        if (!h || h.done())
            return false;

        h.resume();
        return !h.done();
    }

    int get_value()
    {
        return h.promise().currentValue;
    }
};

Generator fibonacci(int n)
{
    int a = 0;
    int b = 1;

    int i = 0;
    while (i < n)
    {
        co_yield a;
        int next = a + b;
        a = b;
        b = next;
        i++;
    }
}

int _tmain(int argc, TCHAR* argv[])
{
    Generator generator = fibonacci(10);
    while (generator.move_next())
    {
        std::cout << generator.get_value() << std::endl;
    }
        return 0;
}

比起python的版本,它确实复杂了不少。但是我们可以从逻辑上一一拆解

协程实现原理

首先我们知道,普通的函数是通过栈来存储数据的,在调用时首先经过参数压栈,然后在栈中初始化函数的局部变量,接着执行函数代码。在函数退出时要在接收返回值的内存处初始化返回值对象,最后执行出栈操作清理整个函数栈。协程函数是可以随时挂起并回退到调用处,或者由外部直接恢复执行的,所以在回到调用位置时并没有执行函数栈的清理操作,而且需要保存函数当前执行的上下文环境,在需要的时候进行恢复。C++的协程采用的是无栈的设计,也就是说协程函数本身并没有产生栈,它的所有信息由编译器生成处理。到这里我们知道针对每个协程,编译器会生成处理它的代码包括保存上下文环境,真正执行挂起和恢复。编译器中通过coroutine_handle 对象来管理每个协程对象。我们无需知道编译器具体是如何实现管理协程的,我们只需要获取它的对象,并调用对象的方法就能操作对象。这也是面向对象的体现。

我们知道编译器是通过生成一个协程对象来管理协程的。那么它是什么时候生成这个对象的呢?答案是它在发现某个函数为协程函数时会生成一个协程对象,对象生成的时机早于协程函数开始执行的时候,主要是因为它需要接管协程函数的栈并保存上下文环境。那么编译器是如何知道某个函数是协程函数的呢?主要是通过识别里面是否有 co_awaitco_yieldco_return 这么几个关键字。

到此我们先梳理一下,首先编译器在编译时扫描到了函数中有 co_awaitco_yieldco_return 这几个关键字,因此它会生成协程对象接管函数栈和上下文,并且不会为函数本身生成栈帧。

对于协程函数,为什么不能直接返回具体的返回值类型,而需要利用结构体包装一层呢?例如对于 fibonacci,我们知道它实际返回的是int类型,为什么不能直接返回int 呢?如果我们直接返回int会得到这样的错误:

error C2039: "promise_type": 不是 "std::coroutine_traits<int,int>" 的成员

也就是说,协程的类型已经定义好了,它必须是 std::coroutine_traits<T> 的类型,我们可以在标准库中看到,这个类型的定定义如下:

template <class _Ret, class = void>
struct _Coroutine_traits {};

template <class _Ret>
struct _Coroutine_traits<_Ret, void_t<typename _Ret::promise_type>> {
    using promise_type = typename _Ret::promise_type;
};

_EXPORT_STD template <class _Ret, class...>
struct coroutine_traits : _Coroutine_traits<_Ret> {};

从上面可以看到,返回值必须要包含一个名为 promise_type 的结构。这个结构实际上是一个接口,在编译器帮我们管理协程,实现协程的创建、挂起、恢复、结束时它给我们提供了一系列的接口,方便我们在这些事件发生时执行一些自定义的代码。上面我们实现了这么两个接口:

  • initial_suspend: 协程对象被创建时执行
  • final_suspend: 协程对象被销毁时执行

这两个函数的返回值也是一个接口,需要返回一个 awaiter(等待体)。这个 awaiter 我们也可以将它看作一个接口。它主要有三个函数需要实现:

  • bool await_ready(): 如果返回 true,则表示已经就绪,无需挂起;否则表示需要挂起
  • await_suspend(coroutine_handle<>) 当协程被挂起时,协程对象会保存当前协程的对象,此时 await_suspend 会被调用
  • await_resume() 协程恢复执行时,该函数被调用

需要注意,上面三个函数除了 await_ready 外,另外两个都没有给出返回值类型,标准并没有规定它们的返回值类型。
这里我用了标准库实现的简单的等待体 std::suspend_always,它的实现如下:

_EXPORT_STD struct suspend_always {
    _NODISCARD constexpr bool await_ready() const noexcept {
        return false;
    }

    constexpr void await_suspend(coroutine_handle<>) const noexcept {}
    constexpr void await_resume() const noexcept {}
};

还有一个类似的实现

_EXPORT_STD struct suspend_never {
    _NODISCARD constexpr bool await_ready() const noexcept {
        return true;
    }

    constexpr void await_suspend(coroutine_handle<>) const noexcept {}
    constexpr void await_resume() const noexcept {}
};

到此为止,我们知道了4个接口,分别是协程对象被创建和销毁时调用,协程被挂起和恢复时调用。

到这里,读者可能有疑问,这些接口为什么不设计到promise_type 里面,而非要放到两个结构里面,非要凭空多出一个概念呢?是不是这样更难理解,显得逼格更高呢?原因与难度和逼格无关,这是因为等待体主要是用来控制协程是挂起还是执行,以前我陷入了一个误区,我以前认为每次调用co_yield、co_await 一定会陷入等待。但是实际上它们只是操作等待体,与协程实际进行等待还是继续执行没有关系。我们是通过具体的等待体来实现此时协程是该继续执行还是等待。等待体与协程本身的控制应该分开,协程根据等待体的返回值来决定该等待还是继续执行。

到这里我们再总结一次:

  • 编译器扫描到了协程函数,会定义一个协程对象来管理协程
  • 协程对象创建时执行 initial_suspend 函数,对象根据函数的返回值来决定是否要执行协程函数
  • 协程函数被执行时会依次执行,直到遇到 co_yield、co_await、co_return
  • 遇到上面的关键字之后它通过关键字操作的等待体中的 await_ready 的返回值来决定这个时候要不要接着执行协程
  • 如果需要挂起协程,那么它会调用等待体中的 await_suspend
  • 如果在外部恢复了协程,此时会调用等待体中的 await_resume
  • 如果协程函数结束了,会调用 final_suspend

有读者可能会问了,你说 co_yieldco_awaitco_return,我在上面的例子中也没看到有等待体呀。这是因为编译器对我们 co_yield a; 这个语句中执行了类型转换,将int转化为了等待体。

我们在实现这个转化上有两种方式,第一个就是我自己定义一个等待体,然后通过构造函数实现int到等待体的转化,第二个就是实现接口, 对于co_yield 来说这个接口是一个名为 yield_value 的函数。编辑器在执行类型转化时,会进行下面的流程:

  • 如果在 promise_type 中定义了对应的转换函数,那么会调用转换函数来进行转换
  • 否则在全局查找能否有办法将后面的数据类型转化为一个等待体

这里我没有自定义等待体,就采用最简单的 yeild_value了。对于 co_await 来说,可以通过 await_transform 函数来进行转化

在上面的例子中,还有三个函数没有介绍到:

  • get_return_object
  • return_void
  • unhandled_exception

首先是 get_return_object,从名字上看,它是用来获取返回值对象的,我们是要用它来获取哪个返回值对象呢?是协程函数的吗?当然不是,这个函数是在协程对象自身被创建之后会调用,也就是说这里的返回对象其实是协程对象。

return_void / return_value函数,这个函数是协程体结束之后如果协程函数自身返回时被调用,对于第一个函数,它调用的时机是在协程函数结束之前,而return_value 则是在调用 co_return 返回一个值。它们与 co_yield 类似,外部都是通过这种机制来实现获取协程执行到此的一些值,但是调用 return_voidreturn_value 之后意味着协程函数已经执行完毕,没有机会再恢复了。

最后一个函数是:unhandled_exception, 这个函数表示,如果协程函数中发生异常,此函数将被调用。

总结

到此我们根据上述代码来总结一下协程的执行流程:

  • 编译器扫描到了协程函数,会定义一个协程管理对象来管理协程
  • 协程管理对象被创建之后,执行 get_return_object 此时外界有机会获取到管理对象,此时我们将handle 对象保存到了 Generator 对象中
  • 协程管理对象执行 initial_suspend 函数,对象根据函数的返回值来决定是否要执行协程函数。这里我们通过返回suspend_always 来将协程挂起
  • 外界通过执行 move_next 调用里面的 h.resume() 来恢复协程的执行,协程会按顺序执行直到遇到 co_yield、co_await、co_return
  • 这里遇到了关键字 co_yield 它会调用promise_type 中的 yield_value 执行整形数据到awaiter 的转化,这里返回suspend_always 来将协程挂起
  • 此时协程被挂起,那么它会调用等待体中的 await_suspend
  • 如果在下一次循环中又一次调用 move_next 恢复了协程,此时会调用等待体中的 await_resume
  • 就这样每调用一次 move_next 就执行一次计算,获取数列中的下一个数字
  • 循环结束,协程函数执行到函数体尾部或者执行了 co_return ,会调用 return_value或者 return_void 让外界有机会获取到协程函数本身的返回值,这个函数本身不返回值,所以这里会调用 return_void
  • 协程函数结束了,执行 final_suspend,此时可以做一些清理工作。这里我们返回 suspend_always 来暂停协程,主要是为了协程结束之后,仍然可以获取协程计算的结果。
  • 主函数结束,Generator 对象被销毁,此时调用析构函数中的 h.destroy() 销毁协程管理对象
0

评论 (0)

取消