【C++20】协程

协程

协程提供了一种协作式的多任务模型,在并发计算领域,它通常比多线程或多进程要高效得多。C++20中的协程仅提供了机制,而没有提供标准库的支持,这可能会在下一个标准C++23中提供,这里将主要介绍协程的机制。

函数只有两个行为:调用与返回。一旦函数返回后,它在栈上所拥有的状态将被销毁。协程相比函数多了两个动作:挂起与恢复。当协程主动挂起时,它的控制权将转交给另一个协程,这时它所拥有的状态仍被保留着,另一个协程获取控制权后,在将来某个时间点也可能选择挂起,从而使原协程的控制权得以恢复,一旦协程像函数一样返回,它所拥有的状态将被销毁。

协程是能暂停执行以在之后恢复的函数。

函数与协程理论

  • C++的协程它要求库开发者实现协程机制所要求的接口规约,编译器为协程生成代码时将会对用户定制的这些接口进行调用。

一个函数调用时将产生压栈动作,根据系统ABI协议,一般地,调用者会将参数压入栈中紧接着返回地址,然后跳转到被调函数的首地址

在这里插入图片描述

协程是一个可恢复的函数,它比函数多了挂起与恢复两个动作,如果一个协程被挂起,那么它的状态(如局部变量等)必须被保存,以确保后续恢复时这些状态能够正常访问,因此一个协程帧不能简单地使用栈内存来表达,它必须借助其他内存来保存这些状态,例如堆内存。

图是一个普通函数与协程进行交互的场景,图中每一步使用标号标记,它们的含义如下

在这里插入图片描述

  1. 对一个协程进行调用,这个过程会创建协程的控制块,也被称为coroutine_handle(协程句柄),它包含了用户自定义的协程数据promise_type、协程的实参、当前保存的局部变量,以及协程内部的状态如挂起点等,这些数据通常存放于堆内存上,在这个场景中,协程创建完成后便返回给它的调用者。
  2. 得到协程的句柄后,它可以被传递到其他地方,并传递给它进行后续协程的恢复动作。
  3. 协程的恢复过程,恢复者调用协程句柄的resume()函数
  4. 在协程内部,它可以选择挂起或返回动作,通过C++20引入三个新的关键字实现:co_await、co_yield和co_return。只要函数存在这三个中的任意一个关键字,它便被当成协程处理。
  5. 经过前两步的循环,最后调用者通过协程句柄的destroy()销毁一个协程,并释放它的内存

C++的协程是无栈协程,它比有栈协程占用的内存更少,拥有更强的性能。无栈协程只能在协程中挂起协程,而不能在普通函数挂起协程,因为无栈协程不会保存协程的调用栈;有栈协程没有这个限制,因为它保存了整个调用栈

  • co_await 表达式——用于暂停执行,直到恢复:
task<> tcp_echo_server()
{
    char data[1024];
    while (true)
    {
        std::size_t n = co_await socket.async_read_some(buffer(data));
        co_await async_write(socket, buffer(data, n));
    }
}
  • co_yield 表达式——用于暂停执行并返回一个值:
generator<int> iota(int n = 0)
{
    while (true)
        co_yield n++;
}
  • co_return 语句——用于完成执行并返回一个值:
lazy<int> f()
{
    co_return 7;
}
  • 限制
    协程不能使用变长实参,普通的 return 语句,或占位符返回类型(auto 或 Concept)。
    consteval 函数、constexpr 函数、构造函数、析构函数及 main 函数 不能是协程。

若要理解协程,其中最为重要的两个概念分别为Promise和Awaitable,这两者都是用户自定义类型

  • Promise类型能够让用户定制一个协程的调用、返回行为
  • Awaitable类型能够让用户定制co_await表达式的语义

关于yield和await语义,其中yield的含义为让出控制权,在C++中yield接受一个值,表明它将让出当前控制权,并将值传递给它的调用者
await更多地用在协程与协程之间的协作上,当父协程await子协程时,表明它将让出控制权,交给子协程进行处理,当子协程完成了处理并返回结果后,控制权将返回给父协程继续处理

co_await 表达式

表达式转换

一元运算符 co_await 暂停协程并将控制返回给调用方。它的操作数是一个表达式,它的类型要么必须定义 operator co_await,要么能以当前协程的 Promise::await_transform 转换到这种类型

co_await expr
  • 首先是检查协程用户定义的Promise对象是否存在成员函数await_transform,如果存在,则令Awaitable对象为await_transform(expr);如果不存在,则令Awaitable对象为expr
  • 经过上一步得到Awaitable对象后,检查该对象是否重载了operator co_await()操作符,如果重载则使用该函数调用后的结果作为最终的Awaiter对象,否则直接作为Awaiter对象

上述两步转换过程是编译时多态,而不是由运行时进行决策,最终得到的Awaiter对象可能就是最初的expr

Awaitable对象

co_await关联的对象经过两步转换后,得到最后的Awaiter对象,它需要用户提供如下三个接口的实现

  1. await_ready接口,判断当前协程是否需要在此处挂起
  2. await_suspend接口,接受当前协程的句柄coroutine_handle,用户可以在此处发起一个异步动作,在完成异步动作后,对这个coroutine_handle执行resume函数即可恢复当前协程的执行。这个函数拥有3个版本,通过不同的返回类型来区分:
    • 返回类型为void,则直接返回给当前协程的调用者或恢复者
    • 返回类型为bool,如果返回true,则挂起当前协程并返回给当前协程的调用者或恢复者,否则直接恢复当前协程
    • 返回类型为coroutine_handle,则挂起当前协程并返回给当前协程的调用者或恢复者,随即恢复它所返回的协程句柄
  3. await_resume接口,当前协程恢复时,其返回值将作为整个co_await表达式的值

编译器对co_await表达式进行的处理:

在这里插入图片描述

标准库中的Awaitable

C++标准库中提供了两个最基本的Awaiter

  • suspend_always,挂起当前协程并返回到它的调用者或恢复者
  • suspend_never,不挂起当前协程

尽管它们的接口实现使用constexpr修饰,但目前协程机制无法在编译时环境中使用

co_await 示例

#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);
}

Promise概念

每个协程都与下列对象关联

  • 承诺(promise)对象,在协程内部操纵。协程通过此对象提交其结果或异常。
  • 协程句柄 (coroutine handle),在协程外部操纵。这是用于恢复协程执行或销毁协程帧的不带所有权句柄。
  • 协程状态 (coroutine state),它是一个动态存储分配(除非优化掉其分配)的内部对象

当协程开始执行时,它进行下列操作:

  • 用 operator new 分配协程状态对象
  • 将所有函数形参复制到协程状态中:按值传递的形参被移动或复制,按引用传递的参数保持为引用(因此,如果在被指代对象的生存期结束后恢复协程,它可能变成悬垂引用)
  • 调用承诺对象的构造函数。如果承诺类型拥有接收所有协程形参的构造函数,那么以复制后的协程实参调用该构造函数。否则调用其默认构造函数。
  • 调用 promise.get_return_object() 并将结果保存在局部变量中。该调用的结果将在协程首次暂停时返回给调用方。至此并包含这个步骤为止,任何抛出的异常均传播回调用方,而非置于承诺中。
  • 调用 promise.initial_suspend() 并 co_await 它的结果。典型的承诺类型 Promise 要么(对于惰性启动的协程)返回std::suspend_always,要么(对于急切启动的协程)返回std::suspend_never。
  • 当 co_await promise.initial_suspend() 恢复时,开始协程体的执行

当协程抵达暂停点时

  • 将先前获得的返回对象返回给调用方/恢复方

当协程抵达 co_return 语句时,它进行下列操作:

  • co_return;
  • co_return expr;,其中 expr 具有 void 类型
  • 控制流抵达返回 void 的协程的结尾。此时如果承诺类型 Promise 没有 Promise::return_void() 成员函数
  • 以创建顺序的逆序销毁所有具有自动存储期的变量
  • 调用 promise.final_suspend() 并 co_await 它的结果

当经由 co_return 或未捕获异常而终止协程导致协程状态被销毁,或经由它的句柄而导致它被销毁时,它进行下列操作:

  • 调用承诺对象的析构函数。
  • 调用各个函数形参副本的析构函数。
  • 调用 operator delete 释放协程状态所用的内存。
  • 转移执行回到调用方/恢复方。

协程句柄

这里需要了解协程句柄的结构,因为Promise概念存储于协程句柄中

图为协程帧的大致结构,它存储了协程恢复、销毁两个函数指针,以及协程实参、内部状态,还有保存的局部变量,**其中promise_type需要用户实现,它需要满足Promise概念。**coroutine_handle是标准库提供的模板类,它封装了协程帧的指针,并提供恢复协程、销毁协程,以及获取协程的Promise类型等接口。

在这里插入图片描述
在这里插入图片描述

恢复者通过resume接口可对一个挂起后的协程进行恢复,直到该协程被再次挂起,resume接口得以返回。当协程结束后,不能再通过该接口进行恢复,此时可通过destroy接口对协程进行销毁。done接口可查询协程是否处于结束状态。

std::coroutine_traits

编译器用 std::coroutine_traits 从协程的返回类型确定承诺类型(Promise)。
默认情况下,编译器会查找Future的类型成员promise_type作为Promise,若promise_type不存在则会编译报错。如果程序员无法为已存在的类型添加类型成员,可以通过为该元函数提供的特化版本来扩展其promise_type。

co_yield

yield 表达式向调用方返回一个值并暂停当前协程:它是可恢复生成器函数的常用构建块

它等价于

co_await promise.yield_value(表达式)

典型的生成器的 yield_value 会将其实参存储(复制/移动或仅存储它的地址,因为实参的生存期跨过 co_await 内的暂停点)到生成器对象中并返回 std::suspend_always,将控制转移给调用方/恢复方。


=用协程实现猜数字===


  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
很抱歉,我无法提供代码示例,因为作为AI语言模型,我无法编写代码。但我可以为您解释一下协程的概念和用法。 协程(Coroutine)是一种用户级的轻量级线程,也称为协作式多任务。它不同于操作系统内核级的线程,协程不会被操作系统内核调度,而是由用户代码控制。在协程中,一个线程可以有多个执行流,这些执行流在适当的时候可以相互切换,从而实现多任务并发。 协程是一种非常有用的编程技术,用于编写高效、可读性强、可维护的代码。协程通常用于异步编程,因为它可以在不阻塞主线程的情况下执行耗时的操作。 以下是一个可能的协程示例: ```python import asyncio async def coroutine_1(): print('Coroutine 1 started') await asyncio.sleep(1) print('Coroutine 1 finished') async def coroutine_2(): print('Coroutine 2 started') await asyncio.sleep(2) print('Coroutine 2 finished') async def main(): task1 = asyncio.create_task(coroutine_1()) task2 = asyncio.create_task(coroutine_2()) print('Main started') await asyncio.gather(task1, task2) print('Main finished') asyncio.run(main()) ``` 在上面的示例中,我们定义了两个协程函数 coroutine_1 和 coroutine_2,这些函数用于执行一些异步任务。然后我们定义了一个主函数 main,它创建了两个任务 task1 和 task2,这些任务会在协程函数中执行。最后,我们使用 asyncio.run() 函数来运行主函数,从而启动协程并等待它们完成。 在上面的示例中,我们使用了 asyncio 库来实现协程。asyncio 是 Python 3 中的一个标准库,它提供了一些工具和函数来编写协程代码。asyncio 库的主要组件是事件循环(Event Loop),它负责调度协程的执行。我们使用 asyncio.run() 函数来创建一个新的事件循环并运行协程。然后我们使用 asyncio.create_task() 函数来创建任务,这些任务会在协程函数中执行。最后,我们使用 asyncio.gather() 函数来等待所有任务完成。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值