跟我学c++中级篇——再谈C++20中的协程

发布时间:2023年12月31日

一、协程

在前面分析过协程是什么,也对c++20中的协程的应用进行了举例说明。所以这次重点分析一下c++20中的整体构成及应用的方式。等明白了协程是如何动作的,各种情况下如下何处理相关的事件,那么在以后写协程时就不会死搬硬套了。

二、整体说明

在C++20中的协程中,首先要明白几个主要的类型和关键字。在协程中可以划分为三种概念以及一种状态,这三种类型(或者说表现出来的数据结构)是:
1、promise(promise_type)
这个在C++11中就有,此处和其意义相似,协程内部通过此对象来获取结果及异常。协程中要求返回对象必须是promis_type,这是一个接口类型。它一般要实现:

get_return_object: 获取协程的返回对象
initial_suspend:协程初始化的时挂起
final_suspend:协程结束时挂起的操作

initial_suspend和final_suspend 有点象钩子函数,也有点像JAVA的Spring中的拦截器,前者在执行协程(函数)前运行,后者在协程返回前执行。这里需要注意的是,如果final_suspend 运行了协程挂起的动作(比如使用了std::suspend_always{}),就需要记得在协程结束后手动destroy来销毁,否则的话,协程将自动销毁。这个和线程的detach和join有点类似。
另外它还有其它一些方法:

void return_void(): 如果没有实现co_return或单纯调用co_return以及co_return expr中expr返回值为void,必须有,否则报错
void return_value():co_return expr中expr返回值为非void
void yield_value():如果程序中调用了co_yield,则必须有
unhandled_exception(): 异常控制

同时,promise_type还会管理协程的相关栈空间,即一些临时变量、参数、返回值及一些寄存器的上下文等。

2、awaitable(awaiter)
awaitable和awaiter是比较让初学者有些感到迷惑的,有人可能觉得为什么要有两个类似的对象。其实这个非常好理解,前者是用来处理co_wait的expr的对象,后者是具体的处理对象或者等待器。而awaitable可以理解为居中的一层抽象虽然co_wait awaiter,但直接使用awaiter便不再依赖于抽象而是依赖于具体实现。所以便把awaiter抽象出来,具体由awaitable来实现。所以expr类型需要是一个awaitable类型。而awaitable可以转换成awaiter。
它其中需要实现的接口包括:

await_ready():是否挂起协程。返回 true,co_await不挂起函数,否则挂起(一般异步都是false)并调用await_suspend
await_resume():co_await expt的返回值,一般为空(如果有返回值则 auto ret = co_await expt)
await_suspend(handle):协程挂起时的行为,可能通过handle得到当前协程的状态,以此来处理挂起时的动作

在c++20的协程说明中有详细的说明哪几种情况可以转换成awaitable。如果没有什么复杂的动作,可以使用在标准库里实现的两个此类型的结构体:

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 {}
};

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 {}
};

代码很简单,注意noexcept,必须有。
这里重点需要说明一下await_suspend,其实异步处理最主要的就是在此处,它可以通过传入的外部包裹的协程handle来管理协程动作。所以在网上或者资料上的一些例程在此处直接调用resume,其实是没有展现出异步的作用。
另外在await_suspend中的参数如果有操作promise_type则可以写成std::coroutine_handle<>,这就是默认(void)或自动推导就可以了。

3、coroutine_handle
这个就是刚刚提到的std::coroutine_handle<>,它一般常用的有两个方法:

handle.resume():协程恢复执行
handle.destroy():销毁协程(在前文提到的final_suspend挂起时)

当然还done,from_address等接口,大家可以参考相关资料文档。
需要注意的是,这个句柄类似于浅拷贝,析构函数不处理相关的协程内部状态的内存,需要操作destroy函数,但大多数情况下都是自动销毁,只有在前面提到的那种情况下,需要手动调用destroy函数。同样,销毁掉句柄后,被复制的句柄成为了类似于悬垂指针的存在。

而一种状态是协程状态coroutine state,它主要有包含以下几点:
1、the promise object
2、the parameters (all copied by value)
3、some representation of the current suspension point, so that a resume knows where to continue, and a destroy knows what local variables were in scope(这个有点类似于线程上下文的意思)
4、local variables and temporaries whose lifetime spans the current suspension point.
协程的状态有点类似于线程的上下文,这个需要大家自己去体会。

在协程中主要有三个关键字co_wait,co_yield和co_return:

1、co_wait
它是一个一元运算符,用来处理协程的暂停并将控制权返回给调用者直到重新恢复协程,其操作的是一个表达式,即co_wati expr(即awaiter) 。和上面的awaitable对应。
2、co_yield
这个相当于暂停协程并返回一个值,而这个值可以被重新利用即前面提到的yield_value函数。
3、co_return
这个好理解,它直接就返回值并结束协程。

这样把三种类型的概念和协程的状态以及三个关键字的关系搞清楚,是不是协程的用法已经呼之欲出,小的细节可以在实际应用中踩踩坑并多看文档来处理。

三、协程的执行流程

看完上面的分析,下面再把它们的关系用流程串边一下,即协程启动后,它们之间如何配合工作的:
1、首先分配内存并初始化协程状态
2、相关参数复制到协程
3、构造promise对象
4、通过调用 promise.get_return_object()并保存结果供协程首次挂起返回值给调用者
5、执行co_await promise.initial_suspend(),如果无复杂需求可使用STL中定义的 std::suspend_always (始终挂起)和std::suspend_never(永不挂起)
6.1 协程函数执行至 co_return expr语句:
若 expr 为 void 则执行 promise.return_void(),否则执行 promise.return_value(expr)
按照创建顺序的倒序销毁局部变量和临时变量
执行 co_await promise.final_suspend()

6.2 当协程执行到 co_yield expr语句:
调用co_await promise.yield_value(expr)

6.3 当协程执行到 co_await expr语句:
通过 expr 获得 awaiter 对象。
执行 awaiter.await_ready(),若为 true 则直接返回awaiter.await_resume(),否则将协程挂起并保存状态,执行 awaiter.await_suspend(),若其返回值为 void 或者 true 则成功挂起,将控制权返还给调用者,直到 handle.resume() 执行后该协程才会恢复执行,将 awaiter.await_resume() 作为表达式的返回值

6.4 当协程因为某个未捕获的异常导致终止:
捕获异常并调用 promise.unhandled_exception()
调用 co_await promise.final_suspend() 并 co_await 它的结果(例如,恢复某个继续或发布某个结果)。此时开始恢复协程是未定义行为。

6.5 当协程状态销毁时(通过协程句柄主动销毁 / co_return 返回 / 未捕获异常):
调用promise 对象的析构函数
调用传入参数的析构函数
释放协程状态占用内存
转移执行回调用方/恢复方

以上这些在官方文档中有更详细的说明,大家可以在实际开发中去查阅相关信息。
是不是有点复杂,是有点复杂啊。这个样子还是不能让普通程序员用得自由,也就是没有解决简单的问题,所以协程还需要继续努力(网上有各种基于此的协程库)。

四、例程

这里的例程重点是对上面的分析的一种代码的说明,请仔细看代码并和上面的分析说明对照,会有更深的理解,先看一个例子:

#include <coroutine>
#include <iostream>
#include <stdexcept>
#include <thread>

auto switch_to_new_thread(std::jthread& out)
{
    struct awaitable
    {
        std::jthread* p_out;
        bool await_ready() { return false; }
        void await_suspend(std::coroutine_handle<> h)
        {
            std::jthread& out = *p_out;
            if (out.joinable())
                throw std::runtime_error("jthread 输出参数非空");
            out = std::jthread([h] { h.resume(); });
            // 潜在的未定义行为:访问潜在被销毁的 *this
            // std::cout << "新线程 ID:" << p_out->get_id() << '\n';
            std::cout << "新线程 ID:" << out.get_id() << '\n'; // 这样没问题
        }
        void await_resume() {}
    };
    return awaitable{&out};
}

struct task
{
    struct promise_type
    {
        task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {} //和上文中的说明对照
        void unhandled_exception() {}
    };
};

task resuming_on_new_thread(std::jthread& out)
{
    std::cout << "协程开始,线程 ID:" << std::this_thread::get_id() << '\n';
    co_await switch_to_new_thread(out);
    // 等待器在此销毁
    std::cout << "协程恢复,线程 ID:" << std::this_thread::get_id() << '\n';
}

int main()
{
    std::jthread out;
    resuming_on_new_thread(out);
}

可能的输出:

协程开始,线程 ID:139972277602112
新线程 ID:139972267284224
协程恢复,线程 ID:139972267284224

怎么样,协程进行了线程的自动调度,方便很多吧。但是这种在不同线程间调度时需要注意,不能在另外一个线程内恢复协程否则容易出现数据处理的问题,这个就看应用的场景了。用不好,就直接挂了。这个例子中也有风险提示,大家看一下就明白了。
再看一个简单的例子:

#include <coroutine>
#include <iostream>

struct MyCoroutine {
    struct MyPromise {
        MyCoroutine get_return_object() {
            return std::coroutine_handle<MyPromise>::from_promise(*this);
        }
        std::suspend_never initial_suspend() { return {}; }
        // 使用suspend_always,需要手动 destroy,noexcept必须
        auto final_suspend() noexcept{ return   std::suspend_always{}; }
        void unhandled_exception() {}
        void return_void() {} //返回空必须有
    };

    using promise_type = MyPromise;
    //尖括号中MyPromise类型可自动推导
    MyCoroutine(std::coroutine_handle<> h) : handle(h) {}

    std::coroutine_handle<MyPromise> handle;

};

MyCoroutine first() {
    std::cout << "this is my  first \n" ;
    co_await std::suspend_always{};
    std::cout << "coroutine!\n";
}

int main() {
    MyCoroutine my = first();

    my.handle.resume();
    my.handle.destroy();//手动释放

    return 0;
}

看到上面的第一个例子中的风险,那么如何安全的调度协程实现await_suspend的异步操作情况,两种方法,一种是在主线程中写一个任务处理函数,不断的去探查协程挂起后的操作,并把await_suspend的恢复放到主线程中去。第二种方法就是使用和线程池类似的方式,将任务注册到队列,通过消息来驱动协程工作并最终将任务结果返回。
github上有阿里开源的协程库,应用起来会更简单(https://github.com/alibaba/async_simple)。
源码面前,了无秘密。

五、总结

协程最大特点是它可以跨越线程来进行操作,而在线程中一般数据处理要么在线程中独自控制要么需要加锁。所以协程应用起来更灵活,这也是为什么协程能更好的发挥线程的作用并同时呈现更好异步操作的原因。这个在GO的协程测试中已经有验证,协程可以开出几百万个,但线程一般到几百个就达到瓶颈了。
协程的开发会在大多数场景下替代线程的开发,只有一个最大原因,简单,功能强大倒是其次。希望c++中的协程在后面会变得更简单好用。

文章来源:https://blog.csdn.net/fpcc/article/details/135311653
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。