1.协程简介
1.协程简介
什么是从cpp无栈协程?保留协程自己的状态和值,切换到另一个协程,不进行堆栈的切换
- co_await表示协程的挂起,操作对象是awaiter等待体
- 协程体内await_ready决定是否挂起,返回false就挂起,true无需挂起
- await_ready 返回 false 时,协程就挂起了。这时候协程的局部变量和挂起点都会被存入协程的状态当中,await_suspend 被调用到
- 协程恢复执行之后,等待体的 await_resume 函数被调用
自己实现一个很简单的等待体,功能是,调用co_await,看await_ready为false,始终挂起,挂起后新建一个线程,等待1s后再调用resume,resume返回一个值
1 | struct Awaiter { |
co_await 后面的对象也可以不是等待体,这类情况需要定义其他的函数和运算符来转换成等待体。这个我们后面再讨论。
协程的返回值
区别一个函数是不是协程,是通过它的返回值类型来判断的。如果它的返回值类型满足协程的规则,那这个函数就会被编译成协程。
规则就是返回值类型能够实例化下面的模板类型 _Coroutine_traits
:
1 | template <class _Ret, class = void> |
简单来说,就是返回值类型 _Ret
能够找到一个类型 _Ret::promise_type
与之相匹配。这个 promise_type
既可以是直接定义在 _Ret
当中的类型,也可以通过 using
指向已经存在的其他外部类型。
此时,我们就可以给出 Result
的部分实现了:
1 | struct Result { |
协程返回值对象的构建
看一个协程函数的示例
1 | Result Coroutine(int start_value) { |
协程体当中并没有给出 Result 对象创建的代码。
实际上,Result 对象的创建是由 promise_type 负责的,我们需要定义一个 get_return_object
函数来处理对 Result 对象的创建
1 | struct Result { |
不同于一般的函数,协程的返回值并不是在返回之前才创建,而是在协程的状态创建出来之后马上就创建的。也就是说,协程的状态被创建出来之后,会立即构造 promise_type
对象,进而调用 get_return_object
来创建返回值对象。
promise_type
类型的构造函数参数列表如果与协程的参数列表一致,那么构造 promise_type
时就会调用这个构造函数。否则,就通过默认无参构造函数来构造 promise_type
。
协程体的执行
initial_suspend
为了方便灵活扩展,协程体执行的第一步是调用 co_await promise.initial_suspend()
,initial_suspend
的返回值就是一个等待对象(awaiter),如果返回值满足挂起的条件,则协程体在最一开始就立即挂起。这个点实际上非常重要,我们可以通过控制 initial_suspend 返回的等待体来实现协程的执行调度。有关调度的内容我们后面会专门探讨。
接下来执行协程体。
协程体的执行
协程体当中会存在 co_await、co_yield、co_return 三种协程特有的调用,其中
- co_await 我们前面已经介绍过,用来将协程挂起。
- co_yield 则是 co_await 的一个马甲,用于传值给协程的调用者或恢复者或被恢复者,我们后面会专门用一篇文章给出例子介绍它的用法。
- co_return 则用来返回一个值或者从协程体返回。
协程体的返回值
对于返回一个值的情况,需要在 promise_type 当中定义一个函数
1 | ??? return_value(); |
例如:
1 | struct Result { |
此时,我们的 Coroutine 函数就需要使用 co_return 来返回一个整数了:
1 | Result Coroutine() { |
1000 会作为参数传入,即 return_value 函数的参数 value 的值为 1000。
协程体返回 void
除了返回值的情况以外,C++ 协程当然也支持返回 void。只不过 promise_type 要定义的函数就不再是 return_value 了,而是 return_void 了:
1 | struct Result { |
这时,协程内部就可以通过 co_return 来退出协程体了:
1 | Result Coroutine() { |
协程体抛出异常
协程体除了正常返回以外,也可以抛出异常。异常实际上也是一种结果的类型,因此处理方式也与返回结果相似。我们只需要在 promise_type 当中定义一个函数,在异常抛出时这个函数就会被调用到:
1 | struct Result { |
final_suspend
当协程执行完成或者抛出异常之后会先清理局部变量,接着调用 final_suspend 来方便开发者自行处理其他资源的销毁逻辑。final_suspend 也可以返回一个等待体使得当前协程挂起,但之后当前协程应当通过 coroutine_handle 的 destroy 函数来直接销毁,而不是 resume。
小结
本章介绍了协程的基本概念,两个重要的结构体 协程体和result结构体,协程体负责什么时候挂起,挂起后执行什么操作,恢复时执行什么操作
Result结构体内的promise_type可以定义返回值和异常的一系列操作
2. 实现一个序列生成器
实现目标
序列生成器通常的实现就是在一个协程内部通过某种方式向外部传一个值出去,并且将自己挂起,外部调用者则可以获取到这个值,并且在后续继续恢复执行序列生成器来获取下一个值。
显然,挂起和向外部传值的任务就需要通过 co_await
来完成了,外部获取值的任务就要通过协程的返回值来完成。
由此我们大致能想到最终程序的样子:
1 | Generator sequence() { |
注意到 generator 有个 next 函数,调用它时我们需要想办法让协程恢复执行,并将下一个值传出来。
1 | struct Generator { |
调用者获取值
第一个是我们想要在 Generator 当中 resume 协程的话,需要拿到 coroutine_handle,这个要怎么做到呢?
这时候我希望大家一定要记住一点,promise_type 是连接协程内外的桥梁,想要拿到什么,找 promise_type 要。标准库提供了一个通过 promise_type 的对象的地址获取 coroutine_handle 的函数,它实际上是 coroutine_handle 的一个静态函数:
1 | template <class _Promise> |
获取返回值时就可以先获取coroutine handle
1 | struct Generator { |
接下来就是如何获取协程内部传出来的值的问题了。同样,本着有事儿找 promise_type 的原则,我们可以直接给它定义一个 value 成员:
1 |
|
协程内部挂起并传值
现在的问题就是如何从协程内部传值给 promise_type 了。
我们再来观察一下最终实现的效果:
1 | Generator sequence() { |
特别需要注意的是 co_await i++;
这一句,我们发现 co_await
后面的是一个整型值,而不是我们在前面的文章当中提到的满足等待体(awaiter)条件的类型,这种情况下该怎么办呢?
实际上,对于 co_await <expr>
表达式当中 expr
的处理,C++ 有一套完善的流程:
- 如果 promise_type 当中定义了 await_transform 函数,那么先通过
promise.await_transform(expr)
来对 expr 做一次转换,得到的对象称为 awaitable;否则 awaitable 就是 expr 本身。 - 接下来使用 awaitable 对象来获取等待体(awaiter)。如果 awaitable 对象有
operator co_await
运算符重载,那么等待体就是operator co_await(awaitable)
,否则等待体就是 awaitable 对象本身。
听上去,我们要么给 promise_type 实现一个 await_tranform(int)
函数,要么就为整型实现一个 operator co_await
的运算符重载,二者选一个就可以了。
方案 1:实现 operator co_await
这个方案就是给 int 定义 operator co_await 的重载:
1 | auto operator co_await(int value) { |
当然,这个方案对于我们这个特定的场景下是行不通的,因为在 C++ 当中我们是无法给基本类型定义运算符重载的。
不过,如果我们遇到的情况不是基本类型,那么运算符重载的思路就可以行得通。operator co_await
的重载我们将会在后面给出例子。
方案 2:await_transform
运算符重载行不通,那就只能通过 await_tranform 来做转换了。
代码比较简单:
1 | struct Generator { |
定义了 await_transform
函数之后,co_await expr
就相当于 co_await promise.await_transform(expr)
了。
至此,我们的例子就可以运行了:
1 | Generator sequence() { |
运行结果如下:
1 | 0 |
协程的销毁
怎么知道协程有没有被销毁?
当协程体执行结束后,协程就会被销毁,再访问协程就相当于访问野指针
如
1 | Generator sequence() { |
会得到结果
1 | 0 |
为了解决这个问题,我们需要增加一个 has_next 函数,用来判断是否还有新的值传出来,has_next 函数调用的时候有两种情况:
- 已经有一个值传出来了,还没有被外部消费
- 还没有现成的值可以用,需要尝试恢复执行协程来看看还有没有下一个值传出来
这里我们需要有一种有效的办法来判断 value 是不是有效的,单凭 value 本身我们其实是无法确定它的值是不是被消费了,因此我们需要加一个值来存储这个状态:
1 | struct Generator { |
我们定义一个成员 state 来记录协程执行的状态,状态的类型一共三种,只有 READY 的时候我们才能拿到值。
接下来改造 next
函数,同时增加 has_next
函数来描述协程是否仍然可以有值传出:
1 | struct Generator { |
这样外部使用时就需要先通过 has_next 来判断是否有下一个值,然后再去读取了:
1 | ... |
问题 2:协程状态的销毁比 Generator 对象的销毁更早
我们前面提到过,协程的状态在协程体执行完之后就会销毁,除非协程挂起在 final_suspend
调用时。
我们的例子当中 final_suspend
返回了 std::suspend_never
,因此协程的销毁时机其实比 Generator 更早:
1 | auto generator = sequence(); |
这看上去似乎问题不大,因为我们在前面通过 has_next
的判断保证了读取值的安全性。
但实际上情况并非如此。我们在 has_next
当中调用了 coroutine_handle::done
来判断协程体是否执行完成,判断之前很可能协程已经销毁,coroutine_handle
这时候都已经是无效的了:
1 | bool has_next() { |
因此为了让协程的状态的生成周期与 Generator
一致,我们必须将协程的销毁交给 Generator
来处理:
1 | struct Generator { |
问题 3:复制对象导致协程被销毁
这个问题确切地说是问题 2的解决方案不完善引起的。
我们在 Generator 的析构函数当中销毁协程,这本身没有什么问题,但如果我们把 Generator 对象做一下复制,例如从一个函数当中返回,情况可能就会变得复杂。例如:
1 | Generator returns_generator() { |
这段代码乍一看似乎没什么问题,但由于我们把 g
当做返回值返回了,这时候 g
这个对象就发生了一次复制,然后临时对象被销毁。接下来的事儿大家就很容易想到了,运行结果如下:
1 | 0 |
为了解决这个问题,我们需要妥善地处理 Generator 的复制构造器:
1 | struct Generator { |
我们只提供了右值复制构造器,对于左值复制构造器,我们直接删除掉以禁止使用。原因也很简单,对于每一个协程实例,都有且仅能有一个 Generator 实例与之对应,因此我们只支持移动对象,而不支持复制对象。
使用 co_yield
序列生成器这个需求的实现其实有个更好的选择,那就是使用 co_yield
。co_yield
就是专门为向外传值来设计的,如果大家对其他语言的协程有了解,也一定见到过各种 yield
的实现。
C++ 当中的 co_yield expr
等价于 co_await promise.yield_value(expr)
,我们只需要将前面例子当中的 await_transform
函数替换成 yield_value
就可以使用 co_yield
来传值了:
1 | struct Generator { |
可以看到改动点非常少,运行效果与前面的例子一致。
尽管可以实现相同的效果,但通常情况下我们使用 co_await
更多的关注点在挂起自己,等待别人上,而使用 co_yield
则是挂起自己传值出去。因此我们应该针对合适的场景做出合适的选择。
3. 序列生成器的泛化和函数式变换
泛化非常简单,加一个模板参数就可以
创建 Generator 的便捷函数
前面的代码创建Generator需要一个函数来确定生成器生成的范围,如果能够从一个数组或者序列中得到一个Generator会方便很多
使用数组创建 Generator 的版本实现比较简单,我们直接给出代码:
1 | template<typename T> |
注意到 C++ 的数组作为参数时相当于指针,需要传入长度 n。用法如下:
1 | int array[] = {1, 2, 3, 4}; |
显然,这个写法不能令人满意。
我们把数组改成 std::list 如何呢?
1 | template<typename T> |
相比数组,std::list
的版本少了一个长度参数,因为长度的信息被封装到 std::list
当中了。用法如下:
1 | auto generator = Generator<int>::from_list(std::list{1, 2, 3, 4}); |
这个虽然有进步,但缺点也很明显,因为每次都要创建一个 std::list
,说得直接一点儿就是每次都要多写 std::list
这 9 个字符。
这时候我们就很自然地想到了初始化列表的版本:
1 | template<typename T> |
这次我们就可以有下面的用法了:
1 | auto generator = Generator<int>::from({1, 2, 3, 4}); |
不错,看上去需要写的内容少很多了。
不过,如果这对花括号也不用写的话,那就完美了。想要做到这一点,我们需要用到 C++ 17 的折叠表达式(fold expression)的特性,实现如下:
1 | template<typename T> |
注意这里的模板参数包(template parameters pack)不能用递归的方式去调用 from,因为那样的话我们会得到非常多的 Generator 对象。
用法如下:
1 | auto generator = Generator<int>::from(1, 2, 3, 4); |
这下看上去完美多了。
实现 map 和 flat_map
实现 map
map 就是将 Generator 当中的 T 映射成一个新的类型 U,得到一个新的 Generator<U>
。下面我们给出第一个版本的 map 实现:
1 | template<typename T> |
参数 std::function<U(T)>
当中的模板参数 U(T)
是个模板构造器,放到这里就表示这个函数的参数类型为 T
,返回值类型为 U
。
接下来我们给出用法:
1 | // fibonacci 是上一篇文章当中定义的函数,返回 Generator<int> |
通过 map 函数,我们将 Generator<int>
转换成了 Generator<std::string>
,外部使用 generator_str
就会得到字符串。
当然,这个实现有个小小的缺陷,那就是 map 函数的模板参数 U 必须显式提供,如上例中的 <std::string>
,这是因为我们在定义 map 时用到了模板构造器,这使得类型推断变得复杂。
为了解决这个问题,我们就要用到模板的一些高级特性了,下面给出第二个版本的 map 实现:
1 | template<typename T> |
注意,这里我们直接用模板参数 F
来表示转换函数 f 的类型。map 本身的定义要求 F
的参数类型是 T
,然后通过 std::invoke_result_t<F, T>
类获取 F
的返回值类型。
这样我们在使用时就不需要显式的传入模板参数了:
1 | Generator<std::string> generator_str = fibonacci().map([](int i) { |
实现 flat_map
在给出实现之前,我们需要先简单了解一下 flat_map 的概念。
前面提到的 map 是元素到元素的映射,而 flap_map 是元素到 Generator 的映射,然后将这些映射之后的 Generator 再展开(flat),组合成一个新的 Generator。这意味如果一个 Generator 会传出 5 个值,那么这 5 个值每一个值都会映射成一个新的 Generator,,得到的这 5 个 Generator 又会整合成一个新的 Generator。
由此可知,map 不会使得新 Generator 的值的个数发生变化,flat_map 会。
下面我们给出 flat_map 的实现:
1 | template<typename T> |
为了加深大家的理解,我们给出一个小例子:
1 | Generator<int>::from(1, 2, 3, 4) |
这个例子的运行输出如下:
1 | * |
我们来稍微做下拆解。
Generator<int>::from(1, 2, 3, 4)
得到的是序列1 2 3 4
- flat_map 之后,得到
0 0 1 0 1 2 0 1 2 3
由于我们在 0 的位置做了换行,因此得到的输出就是 * 组成的三角形了。
flat_map的参数是个协程函数,返回协程,相当于传入一个根据自己协程返回新协程的函数,然后再flat_map内执行这个函数,从而实现每个元素生成一个新协程