自学内容网 自学内容网

初识C++20协程

初识C++20 Coroutine

什么是协程?个人的理解就是一个可调用对象,其具备在某处中断执行,然后再其他地方接着恢复执行的能力,这就是C++的协程。(这里之后所提到的,都是指C++的协程,而不是普遍意义上或者是其他地方的协程。)
下面是cppref上文档的说明:
A coroutine is a function that can suspend execution to be resumed later. Coroutines are stackless: they suspend execution by returning to the caller, and the data that is required to resume execution is stored separately from the stack. This allows for sequential code that executes asynchronously (e.g. to handle non-blocking I/O without explicit callbacks), and also supports algorithms on lazy-computed infinite sequences and other uses.
翻译:大致就是一个挂起后可以恢复执行的执行体,这允许本来顺序的代码异步执行。支持惰性计算和无限序列。

协程的基本构成

我们知道一个可调用对象需要的是参数,执行体, 返回值这三样,协程一样是具备这三点的,但是协程额外需要具备的是中断跳出这个执行体和继续这个执行体。

  • 协程的参数:其中协程本身参数和普通的函数或者可调用对象没有什么区别,但是其影响了协程界定返回值的东西,promise_type。因此不需要过多了解,只需要知道参数会影响promise_type。
    C++原文如下

The Promise type is determined by the compiler from the return type of the coroutine using std::coroutine_traits.
下文会详细说明。

  • 协程跳出处理:promise_type,我叫其协程结束处理,是因为promise_type里面不仅仅要有结束处理,其实是整个协程在中断,第一次执行,最后一次执行,返回值,异常处理等多个凡是结束当要跳出当前执行体操作,就是和这个promise_type有关的,其中协程的处理主要就由promise_type以及其中提供的协程句柄来管理整个协程.

  • 协程的执行体:基本和普通函数执行体没有什么区别,里面使用了 co_await,co_yield,co_value关键字就认为这个执行体是协程。没有使用任何一个就会处理成普通函数。

调用co_await的协程

试着运行下面代码,记得在支持协程的编译器下面编译,可以的话在IDE中尝试调试。

#include <chrono>
#include <thread>
#include <iostream>
#include <coroutine>
struct Task {
    struct promise_type {
        Task get_return_object() { 
            std::cout<<"get_return_object\n";
            return {}; }
        std::suspend_never initial_suspend() { 
            std::cout<<"initial_suspend\n";
            return {}; }
        std::suspend_never final_suspend() noexcept { 
            std::cout<<"final_suspend\n";
            return {}; }
        void return_void() {

            std::cout<<"return_void\n";

        }
        void unhandled_exception() { 
            std::cout<<"unhandled_exception\n";
            std::terminate(); }
    };
};

struct Awaitable {
    bool await_ready() { 
        std::cout << "Checking if ready...\n";
        return false; // 表示需要挂起
    }

    void await_suspend(std::coroutine_handle<> handle) {
        std::cout << "Suspending coroutine...\n";
        // 模拟异步操作:1秒后恢复协程
        std::thread([handle]() {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            std::cout << "Resuming coroutine...\n";
            handle.resume(); // 恢复协程
        }).detach();
        std::cout<<"await_suspend function over\n";
    }
    void operator()()
    {
        std::cout<<"函数对象\n";
    }
    void await_resume() { 
        std::cout << "Resumed coroutine.\n"; 
    }
};



Task example() {
    std::cout << "Before co_await\n";
    co_await Awaitable{};
    std::cout << "After co_await\n";
}

int main() {
    example();
    std::cout<<"主线程开始睡眠\n";
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 确保主线程等待协程完成
    return 0;
}

输出结果:

get_return_object
initial_suspend
Before co_await
Checking if ready…
Suspending coroutine…
await_suspend function over
主线程开始睡眠
Resuming coroutine…
Resumed coroutine.
After co_await
return_void
final_suspend
子线程结束

我会顺着上面的执行结果,接下来详细的解释协程具体是如何执行的.

1.协程的创建,其实根据协程的特性,执行一会之后先放着,过一段时间再去执行的特点你就知道协程一定是不在栈上面的。
上面之所以第一句是get_return_object的原因就是,这是创建协程时就需要调用的函数,这个函数需要返回协程的promise_type,以供后面协程的各个处理。

我们看C++ref的描述:

allocates the coroutine state object using operator new.
copies all function parameters to the coroutine state: by-value parameters are moved or copied, by-reference parameters remain references (thus, may become dangling, if the coroutine is resumed after the lifetime of referred object ends — see below for examples).
calls the constructor for the promise object. If the promise type has a constructor that takes all coroutine parameters, that constructor is called, with post-copy coroutine arguments. Otherwise the default constructor is called.
calls promise.get_return_object() and keeps the result in a local variable. The result of that call will be returned to the caller when the coroutine first suspends. Any exceptions thrown up to and including this step propagate back to the caller, not placed in the promise.

1.协程初始化时:分配对应空间,值参数要么移动或者赋值,引用保持引用。调用对应promise_type得构造函数。
接着就是调用对应得promise.get_return_object(),并返回这个对象给调用者。
promise.initial_suspend(),就是到这个线程得初始挂起,执行完这个之后,会根据结果,suspend_never执行这个协程的线程,就会接着执行这个协程执行下去,否则协程就挂起,从当前位置跳回之前的调用协程的地方接着执行了。

当然我代码之中,initial_suspend 返回的是从不挂起(suspend_never),就是说执行完这个协程之后,不要跳出这个协程,而是接着运行这个协程。

2.开始执行协程的执行体部分:
打印的不多说,我们来看co_await部分,这也是对C++协程执行流理解的最重要部分,包含两个关键问题
A.从哪里开始跳出协程取执行其他部分的
B.协程下次是由哪个协程(或者说叫执行流)去执行的。

这部分现象,建议打开IDE进行调试,跟随线程调试,你就能看见一些事情。我就从结果直接分析了。
我们来看,Before co_await之后:执行的语句

Checking if ready...
Suspending coroutine...
await_suspend function over

从上面我们就看出来,调用co_wait的时候,执行这个协程的当前线程,也就主线程,直接跳到await_ready()去判断是否行了挂起,什么意思呢?其实就是co_wait等待的这个操作,执行完没有,因为我这里搞演示,我希望其进入await_suspend,所以总是没有准备好。
大家要理解这一步的价值,有时候协程等待的资源,大概率是另外一个执行体(线程之类的)的资源,有可能你去之前就已经访问好了,所以确定挂起之前,先await_ready一下看看好没有,我这里实际上是没有做那么多,因为我总是需要额外再开一个线程,所以就刮起了。如果你直接返回true,就不会有挂起线程的步骤。整个代码输出如下:

get_return_object
initial_suspend
Before co_await
Checking if ready...
Resumed coroutine.
After co_await
return_void
final_suspend
主线程开始睡眠

接下来我们回到主线,询问了await_ready之后了,告知要等待的资源没有准备好,然后我们开始执行
await_suspend这个函数(值得注意的是,现在在执行这个协程的依然是主线程):
1.你会发现一个结果如下,为什么出现下面结果,就是因为执行整个suspend_never之后,这个协程自己就被挂起,也就是协程其实已经暂停了,现在协程就没有执行。

Suspending coroutine…
await_suspend function over
主线程开始睡眠

接下来就是重点了,协程是如何恢复执行的呢?主线程已经跳出去了,代码里面已经说明问题了,接着执行恢复线程执行的其实是子线程。如果你有调试,并且在sunpend_nerver里面去跟着子线程调试,其实就已经发现了,接着执行的是子线程。

子线程通过句柄,恢复执行协程,实际上是子线程跑过去,接着执行之前中断之后也就是,co_wait的部分,这个时候,会先调用await_resume(),之后就是接着执行协程了。
协程跑完了,就完了吗?答案是要做协程的生命周期管理。
实际上任何一个协程返回都需要return,虽然上面没有加,但是实际上已经有了,编译器给你默认加上了co_return;,关于co_return后面有详细介绍。

于是乎协程结束,开始释放协程的所有资源,包括协程在对上开辟的空间,协程给参数变量的空间等等,因此这里尤其值得注意就是协程的返回的值也会被销毁,这个后面在co_return; 里面会有所介绍。
这个过程就是先调用return_void处理返回值,接着调用final_suspend这个函数,如果返回suspend_never,那么当调用完这个函数之后,这个执行体就回去销毁当前协程的所有资源,包括promise_type和以及协程句柄,以及所以的协程相关资源,包括返回值,因此要使用返回值要尤其注意。
结束之后,会发现才有子线程结束的输出信息。

上面的整体过程,已尽力描述,但是肯定遗漏了十分多的细节,想要仔细了解最好就是自己在IDE中开多线程调试看现象是最合适的。

调用co_return 的协程

在协程的末尾,这个co_return ,返回的对象和你在promise_type里面使用了 return_void/return_val(这两只能二选一,这很关键)有关。
具体规则如下:
使用return_void,那么你就不能co_return 后面接值,就只能用co_return;
如果你使用了return_value,也不一定需要有返回值。

因为co_return 实际上知识把你的返回的那个用来调用 return_void或者return_value而已。
这就有了下面的操作,就是你return_void 那里可以给全缺省的情况,那么后面 使用 co_return;其实也是ok的。但是依然不允许在使用return_void的情况下co_return 后面给值否则报错。
但是也不是说,用了 return_value的情况,就不能使用 co_return;,只要你那里是全缺省,你使用co_return;其实也是ok的。这个就是具体规则。
下面给代码:大家可以自己去尝试理解,我这里是给的有返回值但是全缺省的情况。

#include <iostream>
#include <coroutine>

struct ReturnInt {
    struct promise_type {
        int value;

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

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

        // void return_value() {
        //     value =100;
        // }
        void return_void(int val = 200)
        {
            value = val;
        }
        void unhandled_exception() {
            std::exit(1); // 处理异常
        }
    };

    std::coroutine_handle<promise_type> handle;

    ReturnInt(std::coroutine_handle<promise_type> h) : handle(h) {}
    ~ReturnInt() { handle.destroy(); }

    int result() {
        return handle.promise().value;
    }
};

ReturnInt simpleCoroutine() {
    co_return ;
}

int main() {
    auto coro = simpleCoroutine();
    coro.handle.resume(); // 启动协程
    std::cout << "Coroutine returned: " << coro.result() << std::endl; // 输出: Coroutine returned: 42

    return 0;
}

在你理解了上面之后,我们来解决协程的生命周期,也就是上面提及的返回值有可能不能直接用的情况。
不妨把上面份代码稍作修改,

std::suspend_always final_suspend() noexcept { return {}; } 修改成
std::suspend_never final_suspend() noexcept { return {}; }

运行代码查看现象,理论上来说,应该要出现代码直接报错,或者是输出的值不是200.
为什么出现这种情况?原因就是因为写成的生命周期,已经结束了,调用完final_suspend,已经把资源释放了,此时coro对象里面的句柄就是野指针,既然是野指针一访问往往就出现问题。
如果采用std::suspend_always final_suspend() noexcept { return {}; } ,就要注意记得释放掉对应的协程资源,否则就造成了内存泄漏,一般外部能拿到包含句柄的对象,需要管理和释放。
因此如果需要在外面管理,就需要带上句柄在外面管理对应的协程生命周期。

调用co_yield的线程

其实这个基本没什么难度,已经解决大部分C++文档里的内容,co_yield实际上就是通过promise_type和包含promise_type的一些操作来对外提供返回值。
一样得到,我们看看C++官方文档里的说法:

co_yield expression returns a value to the caller and suspends the current coroutine: it is the common building block of resumable generator functions.

co_yield表达式调用后,会给协程调用者(这里是值得注意的,我们说过协程挂起实际上可以给其他线程执行的)一个值后挂起当前协程,常用于生成器函数。
等效于co_await promise.yield_value(expr)。
我们讲co_await promise.yield_value(expr)这句的执行流程。
下面是C++官方文档的代码,可以自行在IDE中调式看看结果,这个是单线程的。

#include <coroutine>
#include <cstdint>
#include <exception>
#include <iostream>
#include <semaphore>
#include <thread>


template <typename T>
struct Generator
{
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;
    struct promise_type // required
    {
        T value_;
        std::exception_ptr exception_;
        Generator get_return_object()
        {
            return Generator(handle_type::from_promise(*this));
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void unhandled_exception() { exception_ = std::current_exception(); }
        template <std::convertible_to<T> From>
        std::suspend_always yield_value(From &&from)
        {
            value_ = (T)std::forward<From>(from);
            return {};
        }
        void return_void() {}
    };

    handle_type h_;

    Generator(handle_type h) : h_(h) {}
    ~Generator() { h_.destroy(); }
    explicit operator bool()
    {
        fill();
        return !h_.done();
    }
    T operator()()
    {
        fill();
        full_ = false;
        return std::move(h_.promise().value_);
    }

private:
    bool full_ = false;

    void fill()
    {
        if (!full_)
        {
            // h_.resume();
            h_();//这个是协程恢复执行,等价于于用h_.resume();
            
            if (h_.promise().exception_)
                std::rethrow_exception(h_.promise().exception_);
            // propagate coroutine exception in called context

            full_ = true;
        }
    }
};

Generator<std::uint64_t>
fibonacci_sequence(unsigned n)
{
    if (n == 0)
        co_return;

    if (n > 94)
        throw std::runtime_error("Too big Fibonacci sequence. Elements would overflow.");

    co_yield 0;

    if (n == 1)
        co_return;

    co_yield 1;

    if (n == 2)
        co_return;

    std::uint64_t a = 0;
    std::uint64_t b = 1;

    for (unsigned i = 2; i < n; ++i)
    {
        std::uint64_t s = a + b;
        co_yield s;
        a = b;
        b = s;
    }
}

int main()
{
    try
    {
        auto gen = fibonacci_sequence(10); // max 94 before uint64_t overflows
 
        for (int j = 0; gen; ++j)
            std::cout << "fib(" << j << ")=" << gen() << '\n';
    }
    catch (const std::exception& ex)
    {
        std::cerr << "Exception: " << ex.what() << '\n';
    }
    catch (...)
    {
        std::cerr << "Unknown exception.\n";
    }
}

如果你调试过,之前说的都有所理解那么上面代码就是依舀画葫芦。

  • 首先调用对应的协程函数,传入参数10,调用对应的Generator(也就是promise_type所在对象)的构造函数,然后走initial_suspend,函数,接着其返回值是要挂起,协程暂停推出,主线程回到执行流。
  • 接着在判断里调用fill(),fill里面的执行流程就是h_()(句柄的仿函数,就是调用resume,我在代码里有标注)
  • 接着主线程从对应的恢复里面,跳转到刚才协程呗中断的地方继续执行,也就是执行体最开始。
  • 一路运行到,co_yield s; 等于执行,co_await promise.yield_value(s),因此这个时候,主线程来到,对应的yield_value处,yield_value拿到的值就是s这个值,然后这赋值给value_,再让gen()返回给外面。这就是获取了一次数列。
  • 往后重复就行。

上面比较简单,我曾说过co_yield的返回值是拿给调用者的,这一点我会魔改官方的代码给与证明。
下面代码主要是为了这展示,谁调用哪个线程就能获取这个协程的值,当然协程肯定在多线程中不是我下面这种用法。


#include <coroutine>
#include <cstdint>
#include <exception>
#include <iostream>
#include <semaphore>
#include <thread>
#include <concepts>
template <typename T>
struct Generator
{
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;
    struct promise_type // required
    {
        T value_;
        std::exception_ptr exception_;
        Generator get_return_object()
        {
            return Generator(handle_type::from_promise(*this));
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept
        {
            return {};
        }
        void unhandled_exception() { exception_ = std::current_exception(); }
        template <std::convertible_to<T> From>
        std::suspend_always yield_value(From &&from)
        {
            value_ = (T)std::forward<From>(from);
            return {};
        }
        void return_void() {}
    };

    handle_type h_;

    Generator(handle_type h) : h_(h) {}
    ~Generator() { h_.destroy(); }
    explicit operator bool()
    {
        if (over)
        {
            return false;
        }
        fill();
        return true;
    }
    T operator()()
    {
        if (over)
        {
            return false;
        }
        fill();
        full_ = false;
        return std::move(h_.promise().value_);
    }

    bool full_ = false;
    bool over{false};
    void fill()
    {

        if (!full_)
        {
            // h_.resume();
            h_(); // 这个是协程恢复执行,等价于于用h_.resume();
            if (h_.done())
            {
                this->over = true;
                full_ = false;
                return;
            }
            if (h_.promise().exception_)
                std::rethrow_exception(h_.promise().exception_);
            // propagate coroutine exception in called context

            full_ = true;
        }
    }
};

Generator<std::uint64_t>
fibonacci_sequence(unsigned n)
{
    if (n == 0)
        co_return;

    if (n > 94)
        throw std::runtime_error("Too big Fibonacci sequence. Elements would overflow.");

    co_yield 0;

    if (n == 1)
        co_return;

    co_yield 1;

    if (n == 2)
        co_return;

    std::uint64_t a = 0;
    std::uint64_t b = 1;

    for (unsigned i = 2; i < n; ++i)
    {
        std::uint64_t s = a + b;
        co_yield s;
        a = b;
        b = s;
    }
}

int main()
{
    std::binary_semaphore MainUse{0}, SecUse{1};
    auto gen = fibonacci_sequence(10);
    std::thread t(
        [&]()
        {
            while (gen)
            {
                SecUse.acquire();
                if (!gen)
                {
                    MainUse.release();
                    break;
                }
                std::cout << "子线程获取值:" << gen() << "\n";
                MainUse.release();
            }
        });

    while (gen)
    {
        MainUse.acquire();
        if (!gen)
        {
            SecUse.release();
            break;
        }
        std::cout << "MainThreadGet:" << gen() << "\n";
        SecUse.release();
    }
    t.join();
    return 0;
}

总结

协程什么特点呢?简单概括就是可挂起和重新执行的执行体,非常适合用于什么呢?
根据到现在了解的特点,主要还是用于异步执行里面,类似多业务处理需要将某些任务分出去执行,同时自己这个执行流又去做其他任务然后再会来接着执行。
也可以添加协程执行流,多个协程任务,然线程选择执行,形成一种具备优先级的调度模式,哪个任务执行多少这种,这样其实有点类似于多路转接的思想。
归根结底就是因为协程能够暂停执行后再回来执行。至于更多其他用法,目前也还在了解中,可以自行查看文档或者找寻其他教程。


原文地址:https://blog.csdn.net/choose_heart/article/details/144354620

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!