2

一篇不严谨的 C++ Coroutine 简明快速教程

 10 months ago
source link: https://hsiaofongw.notion.site/C-Coroutine-68692896052a4a1296ee98150e5e7f89
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
met_william_turner_1835.jpg
Drag image to reposition

一篇不严谨的 C++ Coroutine 简明快速教程

Created
February 22, 2023 2:13 PM
coroutine
Description
本文介绍了如何使用C++20的协程特性实现一个简单的generator,通过实现一个无限序列生成器来熟悉协程的特性,包括协程的定义、协程的状态保存在堆上、协程函数体的修改可以得到不同的生成器等。同时也学习了如何实现一个iterator,包括实现判等运算符、自增运算符、指针提领操作符等。最后,还介绍了如何使用协程实现斐波那契数列生成器。总而言之,协程是一个非常灵活、实用的语言特性。
Updated
July 16, 2023 5:58 AM
3 more properties
在 C++ 中,coroutine 是可暂停以及可恢复执行的函数,在定义一个函数时,如果函数体中出现了 co_yield, co_return 或者 co_await 关键字,则该函数会被编译器视为一个 coroutine, 并且按照 coroutine 的方式来翻译,然而,在定义 coroutine 时需要注意几个限制:
一是co_yield, co_return, 和 co_await 这些关键字并不是能出现在任何地方;
二是 coroutine(作为函数)的返回值类型有要求,而且返回值类型不能是 auto , decltype(auto) 这样的 type placeholder;
具体这些限制和要求我们限于篇幅不会详细讲述,但是感兴趣的读者可以参考 cppreference 上的 coroutine 资料页面
本文是初级的并且面向初级读者,本文的目标是教会读者如何快速写好一个 coroutine 的返回值类型的定义。
这是一篇力求快速,但是极其不严谨的教程,后续如果有机会,我们会提供一篇比这个稍微严谨一些的文章做 coroutine 的原理解释,可能会从编译器如何翻译 coroutine 的角度来说,其中可能会涉及到源码/机器码分析。

定义并实现一个 coroutine

我们不希望从 coroutine 的执行过程、原理或者是编译器如何翻译 coroutine 这种角度开始讲,我觉得最快的学习方式是上手去做,那么我们可以先打开 IDE(推荐 CLion),新建一个项目,C++ 标准选 C++20,然后直接在 main.cpp 文件中开始编辑。

无限序列生成器 (generator)

我们注意到在 Python 中有类似这样的代码:
Python
在 Python 中像 my_range 这样定义的函数称为 generator, 特点就是它的函数体中有 yield 语句。
事实上有了 C++20 引入了 coroutine 语法的支持,在 C++ 中也能实现类似的效果,我们就首先来尝试在 C++ 中实现这个 my_range 函数。

用 coroutine 实现 generator

在 main.cpp 中首先定义一个结构体:
这里的 generator 这个名字不是固定的,可以修改。
然后仿照着 Python 代码的 my_range 函数,这样定义:
编译器会提示一些错误信息:
https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F6b9a4568-5acc-4e9a-9e37-4b10669c1dce%2F%25E6%2588%25AA%25E5%25B1%258F2023-02-22_%25E4%25B8%258B%25E5%258D%258810.31.04.png?table=block&id=d4b2d10c-d63a-4707-a1d9-560909fa39fa&spaceId=8551cdf7-056d-46dc-82ad-7ab1ba2283ed&width=1600&userId=&cache=v2
首先 include <experimental/coroutine> 这个头文件,然后点开 IDE 右上方的错误提示入口按钮,会看到下方出现 “Problems” 窗口,有详细的错误提示:
https%3A%2F%2Fs3-us-west-2.amazonaws.com%2Fsecure.notion-static.com%2F9fa84404-45f8-45a2-a1a8-b9d00a97dc5a%2F%25E6%2588%25AA%25E5%25B1%258F2023-02-22_%25E4%25B8%258B%25E5%258D%258810.33.01.png?table=block&id=d631ab89-6b9b-4d09-8da8-39ebe41f41b1&spaceId=8551cdf7-056d-46dc-82ad-7ab1ba2283ed&width=1600&userId=&cache=v2
我们可以根据错误提示的指引,快速写好脚手架代码:
首先,在 generator 结构定义着定义一个成员类型叫做 promise_type :
这时我们发现错误提示信息立马就变了,现在在 promise_type 结构体中定义成员函数initial_suspend :
这样做,可以让 coroutine 被首次调用时不立刻执行,而是先暂停 (suspend), 正如这个成员函数的返回值类型的字面意思表达的那样。
接下来我们需要定义 yield_value 成员函数,同样也是在 generator::promise_type 结构体中,当浏览器遇到 co_yield expr 这样的语句时,它会对它进行翻译,但不管编译器具体如何翻译,我们可以这样简单地理解:编译器会把
其中的 promise 正是与这个 coroutine 关联的 promise 对象,至于为什么会突然提到「与这个 coroutine 关联的 promise 对象」,个中原理如何,解释起来已经超出本文范围,请参考 cppreference 的有关页面.
那么我们知道这个 yield_value 成员函数它“应该”需要返回一个支持 co_await 操作符的对象(实际上不一定),于是,我们先暂且这样定义 yield_value:
1)promise 是一个安全的地方,它事实上不会那么早的销毁也不会频繁地创建以及销毁;
2)之前我们说过 coroutine 是可以暂停的函数,而事实上函数暂停之后这个 value 实际上仍是有效的;
因此我们可以选择把 value 的地址存在 promise 对象中。
这就实现了 co_yield expr 的「语义」,从而使得一句 co_yield 1 这样的语句是有意义的。
现在我们可以这么理解,当 CPU 执行到 co_yield some_value 这句的时候,大概会有两件事情发生:
首先,这个值 some_value 的内存地址会保存到 promise 对象中,这是一个安全的地方;
其次,因为 co_yield some_value 会被编译器先翻译成 co_await promise.yield_value(some_value), 因此这个 coroutine 会暂停(因为它遇到了 co_await 语句);
接下来我们要根据 IDE 界面给出的其它提示进一步完善其余的代码,就像做填空题那样:首先补充 final_suspend 函数,我们前面写过 initial_suspend 函数,final_suspend 函数也是一个钩子函数,会在特定的时刻执行,完全可以仿照 initial_suspend 函数来做,只不过它不能抛异常,这意味着我们需要给 final_suspend 函数加 noexcept 标记,否则编译器会报错:
按照编译器的提示我们知道 final_suspend 函数是要加在 generator::promise_type 里面。
然后在 generator::promise_type 中实现 get_return_object, 这同样是来自提示。
然而这个 get_return_object 函数它和其它 promise 钩子函数不一样,它的意义稍微特殊。
具体来说,CPU 在第一次执行一个 coroutine 时(coroutine 也是函数,只不过被编译器添加了一些额外代码),会先执行像操作系统申请堆内存区域的代码,申请来的堆内存区域用来存储 coroutine state(其中就包括了存储 promise 对象的内存区域,所以我们说 promise 是安全的);
然后,就执行到构造 promise 对象的代码(因为空间已经申请到了),然后调用 promise.get_return_value() (注意不带任何参数),而这个返回的结果会先存在栈上,然后在 coroutine 初次暂停时,这个值会返回给 caller, 换言之,可以认为直接调用 coroutine 得到的返回值就是 get_return_value 的返回值。
在实现 generator 时,这个返回值是重要的,因为,拿着 promise 对象的引用可以构造出一个 coroutine_handle, 而有了 coroutine_handle, 就具备了恢复执行一个coroutine 的能力。
具体来说,我们是这样实现 get_return_value 成员函数的:
如果不细究下去,我们至少知道,现在在 generator 里边也能够按需恢复 coroutine 的执行了,实际上我们要的就是 generator 的 begin 成员方法能够返回一个 iterator, 这个 iterator 支持前置自增运算符(也就是前置的 ++ 运算符),然后在执行 operator ++ 的时候,就恢复执行这个 coroutine, 现在我们再回顾一遍 my_range 这个 coroutine 的定义(函数体):
也就是说,当这个 coroutine 恢复执行时,就会从 ++x 开始,然后再 while 循环里面从头再来,再执行到 co_yield x, 然后暂停。
接下来还需实现 generator::promise_type::unhandle_exception:
遇到 unhandled_exception 就直接 terminate 是一种直接的方法,但可能不是最合适的。
要让代码通过编译,还需要在 promise_type 中实现一个 return_value 或者 return_void , 我们选择实现 return_void, 因为我们不需要 co_return some_value
以下是 coroutine 部分的完整代码:
加上 main 函数的定义,我们发现代码已经可以通过编译了,说明编译器翻译 coroutine 得出的那些函数调用语句的语义都被实现了。

完成 generator 部分

前面我们实际上已经完成了 coroutine 部分,但是要实现 generator, 我们还要写 generator 有关的逻辑以及 iterator 的实现。
我们知道,编译器遇到 coroutine 时会对它进行翻译,就比如说会把 co_yield expr 翻译成 co_await promise.yield_value(expr), 又把 co_await some_expr 翻译成什么别的东西。
类似的道理,编译器遇到形如:
这样的语句时也会做翻译,实际上,这样的语法结构叫做 range-based for loop(简称 range-for),cppreference 有一篇专门的资料,简单来说,编译器可能会这样翻译 range-for:
具体来说,把
Plain Text
这样的语句结构,翻译为
Plain Text
就比如,假如我们写下:
那么(我们可以猜测)编译器可能首先会把代码翻译成这样:
严格来说,编译器会把代码解析、翻译成「中间表示」或者汇编(根据命令行参数),并且在「中间表示」的层面上对代码进行转换,我们这么写只是为了便于理解,因为这个教程为了易读性牺牲了严谨性,所以希望读者不必死抠细节。
现在我们知道了要让 my_range(some_arg) 的返回值支持 range-for, 需要实现 :
generator::begin;
generator::end;
还需要让 begin() 返回的 iterator 支持:
operator++ (前置自增操作符);
operator* (指针提领操作符);
operator== (等性判定操作符);
为了鲁棒性,建议在实现 operator++ 时顺便把后置自增运算符标记为 delete.
随着 generator 的扩张, *__begin 的声明类型有可能会是指针类型,所以 operator-> 也是需要实现的,因为只做支持 int 一种类型的 generator 没什么意义。
因为在执行 operator++ 的时候需要恢复执行 coroutine, 所以 iterator 需要持有一个 coroutine_handle, 所以 iterator 需要支持从 handle_type 构造。
先实现 begin 和 end :
还记得之前在实现 generator::promise_type 的时候,有一个成员函数叫做 initial_suspend ,它的返回值类型是 suspend_always, 这就说明 coroutine 初始化完成之后不是立刻开始执行代码,而是立刻进入暂停状态,也就是 coroutine 一开始是暂停状态的。因此 promise_type::valuePtr 也就没有一个有意义的值(仍是初始值 nullptr),直到 coroutine 开始执行它函数体中的语句,valuePtr 才会被置为一个有意义的值。因为 iterator 的提领操作是在 generator 的 begin 操作之后,所以才需要在 begin 返回 iterator 之前就 resume 一下 coroutine,这样 iterator 被提领的时候,iterator 的 handle 的 promise 的 valuePtr 刚好已经就有了有意义的值,提领操作才能顺利进行。这就是要在 begin 的函数体插入一个 h_.resume() 的原因。
然后开始补充实现 iterator 的细节部分:
实现判等运算符:

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK