协程
协程提供了一种协作式的多任务模型,在并发计算领域,它通常比多线程或多进程要高效得多。C++20中的协程仅提供了机制,而没有提供标准库的支持,这可能会在下一个标准C++23中提供,这里将主要介绍协程的机制。
函数只有两个行为:调用与返回。一旦函数返回后,它在栈上所拥有的状态将被销毁。协程相比函数多了两个动作:挂起与恢复。当协程主动挂起时,它的控制权将转交给另一个协程,这时它所拥有的状态仍被保留着,另一个协程获取控制权后,在将来某个时间点也可能选择挂起,从而使原协程的控制权得以恢复,一旦协程像函数一样返回,它所拥有的状态将被销毁。
协程是能暂停执行以在之后恢复的函数。
函数与协程理论
- C++的协程它要求库开发者实现协程机制所要求的接口规约,编译器为协程生成代码时将会对用户定制的这些接口进行调用。
一个函数调用时将产生压栈动作,根据系统ABI协议,一般地,调用者会将参数压入栈中紧接着返回地址,然后跳转到被调函数的首地址
协程是一个可恢复的函数,它比函数多了挂起与恢复两个动作,如果一个协程被挂起,那么它的状态(如局部变量等)必须被保存,以确保后续恢复时这些状态能够正常访问,因此一个协程帧不能简单地使用栈内存来表达,它必须借助其他内存来保存这些状态,例如堆内存。
图是一个普通函数与协程进行交互的场景,图中每一步使用标号标记,它们的含义如下
- 对一个协程进行调用,这个过程会创建协程的控制块,也被称为coroutine_handle(协程句柄),它包含了用户自定义的协程数据promise_type、协程的实参、当前保存的局部变量,以及协程内部的状态如挂起点等,这些数据通常存放于堆内存上,在这个场景中,协程创建完成后便返回给它的调用者。
- 得到协程的句柄后,它可以被传递到其他地方,并传递给它进行后续协程的恢复动作。
- 协程的恢复过程,恢复者调用协程句柄的resume()函数
- 在协程内部,它可以选择挂起或返回动作,通过C++20引入三个新的关键字实现:co_await、co_yield和co_return。只要函数存在这三个中的任意一个关键字,它便被当成协程处理。
- 经过前两步的循环,最后调用者通过协程句柄的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对象,它需要用户提供如下三个接口的实现
- await_ready接口,判断当前协程是否需要在此处挂起
- await_suspend接口,接受当前协程的句柄coroutine_handle,用户可以在此处发起一个异步动作,在完成异步动作后,对这个coroutine_handle执行resume函数即可恢复当前协程的执行。这个函数拥有3个版本,通过不同的返回类型来区分:
- 返回类型为void,则直接返回给当前协程的调用者或恢复者
- 返回类型为bool,如果返回true,则挂起当前协程并返回给当前协程的调用者或恢复者,否则直接恢复当前协程
- 返回类型为coroutine_handle,则挂起当前协程并返回给当前协程的调用者或恢复者,随即恢复它所返回的协程句柄
- 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,将控制转移给调用方/恢复方。
=》 用协程实现猜数字《===