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_await、co_yield、co_return 这么几个关键字。
到此我们先梳理一下,首先编译器在编译时扫描到了函数中有 co_await、co_yield、co_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_yield、co_await、co_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_void 和 return_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)