本文授权自知乎用户IceBear,点击文末”阅读原文“查看原文章。
C++20 给我们带来了非常现代化的协程特性,但是主要的增加都集中于语核部分。由于库特性尚未准备充分,所以 C++20 标准库中尚没有多少现成的、组装好的协程设施供我们使用。但是!仅仅通过使用 std::coroutine_handle
(这是一个编译器为之开洞的类)并在你的类中定制好几个规定的接口,我们就可以组合出五花八门的功能。你可以理解为,虽然我们没有现成的飞机、火车,但是我们有沙子和铁矿石!完全可以从沙子和铁矿石出发,造出飞机、火车。我知道很多人诟病 C++ 的这个特点,没有现成的这个、现成的那个,自己造很麻烦。但是这也是我比较喜欢 C++ 的一点——上限非常高,你可以为自己的飞机、火车做定制,加上你想要的功能或去掉你不想要的功能;除此以外,你甚至还可以造出之前还没有问世的东西,比如星舰!在其他语言中,语言和标准库给你提供了什么就是什么了,你几乎没有超越的能力。
在 C++23 周期,LEWG (库特性工作组) 在新冠肆虐的艰难背景下完成了大量的工作,使得 C++23 增添了不少很有益的设施(可参考 C++23特性总结 - 上 - Mick235711的文章 - 知乎 https://zhuanlan.zhihu.com/p/562383157)。但是,对于协程方面的内容还是举棋不定。std::generator
和 std::lazy
在合并的计划里进进出出,而最终只有 std::generator
达成目标。对于更花花绿绿的协程库,像 task
等,则可怜的连提案都没有。
另外,在可预见的将来,哪怕标准库收录了一些基本的协程类,为了探索更加花花绿绿的协程高级功能,我们还是需要从最基本的协程设施出发,也就是理解 std::coroutine_handle
和相应的接口。
本文将向读者展示如何实现一个简单的生成器 (generator) 和一个能支持协程内外双向信息传递的生成器。网上关于什么是协程的讲解很多,哪怕是讲 C++ 协程的中文材料也不少。且因笔者时间精力有限,本文不会详细地介绍协程概念,而是更侧重于展示怎么实现出一个 generator,以填补相关参考资料的不足。
1
感性的了解一下协程
协程可以简单地理解为:一个执行时可以主动打断,程序执行流程可以在调用方和被调用方之间进进出出若干次的函数。
Python 是最早的一批支持协程的语言,我们不妨用 Python 来演示一下协程的神奇。(其实早在 19 年,那时 C++ 编译器还没支持协程的时候,笔者就是利用 Python 来理解协程的)
从这个例子我们可以看出以下几点怪异的现象:
1) myrange
函数中并没有一句 return
语句,我们却调用 myrange
函数得到了一个 gen
对象(第 22 行)
2) 22 行调用 myrange
函数后,这个函数似乎并没有立即开始执行(没有执行第 3 行的 print
语句,倒是执行第 23 行的 print
语句了)
3) 调用 gen
对象的 __next__
方法后,myrange
函数开始执行。执行到第 7 行时,myrange
函数 "yield" 了一个值,然后程序的执行流程又切换到主函数的第 24 行。__next__
方法得到了刚刚 "yield" 的结果。
4) 26 行再次调用 __next__
时,执行流程回到了 myrange
中。而且并不是从 myrange
的开头重新开始执行,而是从上一次 "yield" 的地方,也就是第 7 行继续执行。i 的值似乎也没受到影响。
如果你熟悉了协程的特点,这无非可以概括为,协程执行时可以主动打断(更学术一点叫挂起)自己,将控制权交还给调用方。协程挂起期间,协程的栈上信息都可以得到保留。协程恢复后,从上一次的挂起点继续执行。
经过封装后的 C++ 协程库,也可以向用户展示出和 Python 中几乎完全一致的用法。如下就是我们将要实现的 generator
所展示的应用效果。
当然,C++ 毕竟是一个静态类型的语言,除了 range
函数要写 “返回值” generator<int>
(当然实际上 range
是一个协程,代码 81 行只是借用了原来的函数和返回值语法了,严格来说不能认为这里声明了一个返回 generator<int>
类型的函数) ,以及 90 行需要声明 gen
的类型以外, 可以说和 Python 没什么两样了。
2
std::coroutine_handle
std::coroutine_handle
类模板是为我们实现协程的各种“魔法”提供支持的最底层的设施,其主要负责存储协程的句柄。它分为模板 std::coroutine_handle<Promise>
和模板特化 std::coroutine_handle<void>
,std::coroutine_handle<void>
可以简单理解为就是做了类型擦除的 std::coroutine_handle<Promise>
。
打开 <coroutine>
头文件我们不难看出,std::coroutine_handle<Promise>
中的不少方法是依靠 __builtin 内建函数,也就是编译器开洞实现的。
std::coroutine_handle
中保存了协程的上下文。我们协程执行到哪儿切出去了(协程切换回来后从哪儿开始继续执行)?我们协程的栈上变量在协程切出去期间怎么能得到保留?这些问题的解决都是归功于 std::coroutine_handle
保存了协程的上下文。
std::coroutine_handle
中的方法不多,但是各个都至关重要。由于有些概念还没有铺垫,我们先只罗列三个比较容易理解的方法:
done
方法,可以查询一个协程是否已经结束;
resume
方法可以恢复一个协程的执行;
destroy
方法可以销毁一个协程。
std::coroutine_handle
只是一个很底层很底层的设施,没有 RAII 包裹。它就像裸指针一样(其实它内部也就是一个裸指针),需要靠我们手动创建、手动销毁。我们刚刚谈到,std::coroutine_handle
保存了协程的上下文,其中就有栈上变量的信息。如果一个 handle 被创建出来,用完以后我们忘了对它调用 destroy 了,那么其中存储的上下文信息当然也就没有被销毁——也就是内存泄漏了。如果不小心做了两次 destroy,那么就可能会引发 double free 错误了。所以,我们得写一个 RAII 类将其包装起来,以解决忘记销毁或者其他比如浅拷贝等问题。
modules
、concepts
、ranges
等作了详细介绍。全卷内容质量上乘,精雕细琢,非那些在历史旧版本的基础上草草加两章节新内容圈钱的书可比也!非常感谢清华大学出版社对这篇文章的赞助!本着对我的读者负责的态度,我坚持要求审读完书的大部分内容后才能做推荐,清华大学出版社的编辑对此给予了高度支持。且,从 8 月份联系我开始,到本文落笔,编辑非常宽容地给了我 4 个月的时间 —— 一段非常充足的时间阅读了这两本书,之后才有了这里的精心推荐。再次表示致敬和谢意!
3
generator
我们的 generator
类终于出场了。
首先,C++ 的协程要求 generator
中必须有 promise_type
这个类型。你可以通过 typedef
/using
的方式 alias 一个别名,也可以干脆就定义成 generator
的内部类 —— 本文选择后者。
template <typename T>
struct generator
{
struct promise_type
{
}
};
promise_type
中有这么几个可定制的、会影响协程行为的重要接口,先介绍两个:
1) initial_suspend
—— 它回答的是协程一出生时是否就要马上挂起的问题;
2) final_suspend
—— 它回答的是协程最后一次是否要挂起的问题;
对于一个 generator
而言,这两个问题的回答是:
初始时始终都要挂起,最后一次始终都不挂起。
std::suspend_always
std::suspend_never
是标准库里面已经定义好的类型,可以方便地回答是否要挂起的问题。
struct promise_type
{
...
std::suspend_always initial_suspend() const
{
return {};
}
std::suspend_never final_suspend() const noexcept
// 这里注意一下,由于 final_suspend 在收尾阶段工作,所以必须是 noexcept 的
{
return {};
}
}
在新协程创建完毕好后,C++ 会执行 co_await promise.initial_suspend()
,同样的, 在协程结束前也会 co_await promise.final_suspend()
。当然了,从名字中我们也能看出,co_await
一个 std::suspend_always
时,执行流程永远都会无条件切出去,而对于 std::suspend_never
则是永远也不会切出。
还记得我们刚刚观察的 Python 代码中的现象吗?主函数中调用 myrange
的时候,是不是立马得到一个 gen
对象的?是不是 myrange
里面没有立即得到执行的?在第一次调用 __next__
的时候才会去执行的吧?这其实就是因为 myrange
协程一创建好就挂起自己将程序流程切回到调用方了。
如果 initial_suspend
这里回答的是 suspend_never
,那么协程就会立刻开始执行。
建议等 generator
实现完成后读者自己动手实践下,将 initial_suspend
和 final_suspend
的回答换换,看看结果会有什么改变。
3) promise_type
中第三个定制接口是 unhandled_exception
,它回答的是协程被其里头的没有捕获的异常终止时做何处理的问题。
我们这里只是简单处理一下,调用 exit
提前终止程序。当然这样的做法太简化了,实际应用时可以考虑使用 std::exception_ptr
等设施做更严谨的处理。
struct promise_type
{
...
void unhandled_exception()
{
std::exit(EXIT_FAILURE);
}
}
4) promise_type
中第四个定制接口,也是最核心的一个是 get_return_object
。这个方法也涉及到了如何创建一个 coroutine 的问题 —— 答案就是使用 std::coroutine_handle<promise_type>::from_promise(*this)
,即从自己这个 promise ( 也就是*this
) 创建一个 coroutine (from_promise
得到的就是一个 coroutine_handle
)。generator
中也需要配合,提供一个接受 coroutine_handle
类型的构造函数,将刚刚构造出的 coroutine_handle
保存。
现在,通过get_return_object
得到了 return_object
,就会开始询问是否要做 initial_suspend
了 (刚刚介绍的 initial_suspend
还记得吗?)
template <typename T>
struct generator
{
struct promise_type;
std::coroutine_handle<promise_type> handle;
struct promise_type
{
...
generator get_return_object()
{
return generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
};
generator(std::coroutine_handle<promise_type> handle) :
handle(handle)
{
}
...
};
我们之前也提到,coroutine_handle
是无 RAII 的,因此 generator
中得根据三/五法则,做好 RAII。该析构的析构,禁止拷贝,写好移动构造/移动赋值。
template <typename T>
struct generator
{
struct promise_type;
std::coroutine_handle<promise_type> handle;
...
public:
generator() = default;
generator(const generator &) = delete;
private:
generator(std::coroutine_handle<promise_type> handle) :
handle(handle)
{
}
public:
generator(generator && src) :
handle(src.handle)
{
src.handle = nullptr;
}
generator& operator=(const generator &) = delete;
generator& operator=(generator && src)
{
if (!handle) {
handle.destroy();
}
handle = src.handle;
src.handle = nullptr;
}
~generator()
{
if (!handle) {
handle.destroy();
}
}
...
};
5) 定制 yield_value
接口
接下来的定制则对于 generator
来说至关重要,我们马上就可以让我们的 generator
支持 yield 了!
还是以此举例,co_yield
关键字实际上只是一个语法糖,这一行会被编译器替换为 co_await promise.yield_value(i)
,在有了 initial_suspend
和 final_suspend
的经验后,我们这次也就能很容易地猜测出,我们要在 promise_type
中实现一个 yield_value
方法,而返回值负责回答要不要切出的问题。显然,每次 yield 时总是要挂起协程,所以,yield_value
方法的返回值类型应当是 suspend_always
。你猜对了吗?
struct promise_type
{
....
std::optional<T> opt;
template <typename Arg>
std::suspend_always yield_value(Arg && arg)
{
opt.emplace(std::forward<Arg>(arg));
return {};
}
};
在 promise
中,我们还增加了一个 optional
,用以存放 yield 的结果。注意,很多例子,甚至包括 cppreference 在内,promise
内部都是用的 T
类型的裸值来存放 yield 的结果的。在模板编程中这样做兼容性不太好,我们需要考虑能照顾到不可默认构造的类型。除此以外,我们使用万能引用和完美转发以提升构造值时的性能。
而这个 opt
,当然是在等 generator
来取它的。
template <typename T>
struct generator
{
...
T & next()
{
handle.resume();
if (handle.done()) {
throw generator_done("generator done");
}
return *(handle.promise().opt);
}
};
generator<int> range(int n)
{
for(int i = 0; i < n; ++i) {
co_yield i;
}
}
int main()
{
generator<int> gen = range(4);
for (int i = 0; i < 4; ++i) {
std::cout << gen.next() << std::endl;
}
}
这里需要结合前文介绍过的内容梳理下。由于 initial_suspend
的返回值是 suspend_always
,所以协程刚创建好后就切出,执行流程到了 gen = range(4)
。
再下面,每次对 gen
调用 next
方法时,会执行 handle.resume()
恢复协程。
协程首次恢复运行,当然是从 range
函数的开头开始执行 (如果不是首次恢复运行,当然就是从上一次 yield 出去的地方恢复运行),直到碰上了 co_yield
。这时, 调用 promise.yield_value(i)
,根据 co_yield
后面值 (也就是 i
) 构造好了值保存在 opt
中。随后,由于 promise.yield_value(i)
的结果是 suspend_always
,所以协程切出, 执行流程回到了 handle.resume()
之后。正常情况下 (协程没有执行完毕),next
方法就会从 promise
里的那个 optional
中取出 yield 的结果,返回给主函数中以供输出。如果检测到已经是最后一次 yield 后再调用 next
的 (即 resume 后检测到 done 的话),则抛出 generator_done
异常。
完整的 generator
代码如下:
#include <coroutine>
#include <optional>
#include <iostream>
#include <stdexcept>
#include <cstdlib>
struct generator_done :
std::logic_error
{
private:
typedef std::logic_error super;
public:
using super::super;
};
template <typename T>
struct generator
{
struct promise_type;
std::coroutine_handle<promise_type> handle;
struct promise_type
{
std::optional<T> opt;
std::suspend_always initial_suspend() const
{
return {};
}
std::suspend_never final_suspend() const noexcept
{
return {};
}
void unhandled_exception()
{
std::exit(EXIT_FAILURE);
}
generator get_return_object()
{
return generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
template <typename Arg>
std::suspend_always yield_value(Arg && arg)
{
opt.emplace(std::forward<Arg>(arg));
return {};
}
};
public:
generator() = default;
generator(const generator &) = delete;
private:
generator(std::coroutine_handle<promise_type> handle) :
handle(handle)
{
}
public:
generator(generator && src) :
handle(src.handle)
{
src.handle = nullptr;
}
generator& operator=(const generator &) = delete;
generator& operator=(generator && src)
{
if (!handle) {
handle.destroy();
}
handle = src.handle;
src.handle = nullptr;
}
~generator()
{
if (!handle) {
handle.destroy();
}
}
T & next()
{
handle.resume();
if (handle.done()) {
throw generator_done("generator done");
}
return *(handle.promise().opt);
}
};
generator<int> range(int n)
{
for(int i = 0; i < n; ++i) {
co_yield i;
}
}
int main ()
{
generator<int> gen = range(5);
for (int i = 0; i < 5; ++i) {
std::cout << gen.next() << std::endl;
}
}
4
能双向传递信息的
bigenerator
我们目前完成的 generator
只能做到协程内部向外部 yield 一个值,传递出来信息。能不能做到外部也向协程内部回复一个值,将信息由外部传递到协程内部呢?C++ 的协程机制也是允许的。
其实,co_yield
表达式,当然,我们上面也知道了, 其实就是 co_await promise.yield_value()
这个表达式,其实也是有计算结果的,只不过,我们之前 generator
中的例子,计算结果为 void
类型 —— 没有返回值罢了。
要想整个表达式有返回值,当然我们得从 promise.yield_value()
的返回值入手。我们以前用的是 std::suspend_always
,现在得自己配置了。
先上效果:
再上源码:
#include <coroutine>
#include <variant>
#include <iostream>
#include <stdexcept>
#include <cstdlib>
struct generator_done :
std::logic_error
{
private:
typedef std::logic_error super;
public:
using super::super;
};
template <typename T, typename U>
struct bigenerator
{
struct promise_type;
std::coroutine_handle<promise_type> handle;
struct awaitable : public std::suspend_always
{
std::variant<T, U> * ref;
U & await_resume() const
{
return std::get<U>(*ref);
}
};
struct promise_type
{
std::variant<T, U> var;
std::suspend_always initial_suspend() const
{
return {};
}
std::suspend_never final_suspend() const noexcept
{
return {};
}
void unhandled_exception()
{
std::exit(EXIT_FAILURE);
}
bigenerator get_return_object()
{
return bigenerator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
template <typename Arg>
awaitable yield_value(Arg && arg)
{
var.template emplace<T>(std::forward<Arg>(arg));
return awaitable{.ref = &var};
}
};
public:
bigenerator() = default;
bigenerator(const bigenerator &) = delete;
private:
bigenerator(std::coroutine_handle<promise_type> handle) :
handle(handle)
{
}
public:
bigenerator(bigenerator && src) :
handle(src.handle)
{
src.handle = nullptr;
}
bigenerator& operator=(const bigenerator &) = delete;
bigenerator& operator=(bigenerator && src)
{
if (!handle) {
handle.destroy();
}
handle = src.handle;
src.handle = nullptr;
}
~bigenerator()
{
if (!handle) {
handle.destroy();
}
}
template <typename ... Args>
T & next(Args&& ... args)
{
handle.promise().var.template emplace<U>(std::forward<Args>(args)...);
handle.resume();
if (handle.done()) {
throw generator_done("generator done");
}
return std::get<T>(handle.promise().var);
}
};
bigenerator<int, std::string> range(int n)
{
for(int i = 0; i < n; ++i) {
std::string sunk = co_yield i;
std::cout << sunk << std::endl;
}
}
int main ()
{
bigenerator gen = range(10);
for (int i = 0; i < 5; ++i) {
std::cout << gen.next(i + 1, 'a') << std::endl;
}
}
然后讲解:
主要变动就是一个新的内部类:awaitable
,在 bigenerator
中,yield_value
接口返回的就是这个类型。它继承自 std::suspend_always
,表明我们确实还是需要每次 yield 时都要挂起,但是,这里我们重写了 await_resume
方法,使得协程在恢复时调用这个方法,从 promise 中取出外界传递进去的结果。
struct awaitable : public std::suspend_always
{
std::variant<T, U> * ref;
U & await_resume() const
{
return std::get<U>(*ref);
}
};
下面的代码片段展示了 yield_value
中怎么构造 awaitable
。其实只要告知 promise 中的 variant
的地址就可以了。bigenerator
中改用了 variant
,主要是考虑到 yield 出去的值和 resume 时传递进来的值不会在同一时刻存在,使用 variant
有助于节省空间。
struct promise_type
{
...
std::variant<T, U> var;
template <typename Arg>
awaitable yield_value(Arg && arg)
{
var.template emplace<T>(std::forward<Arg>(arg));
return awaitable{.ref = &var};
}
};
还有 bigenerator
中 next
的变化,其实也就是恢复协程前,在 promise 的 variant
中构造好传进去的对象就好了。
template <typename T, typename U>
struct bigenerator
{
...
template <typename ... Args>
T & next(Args&& ... args)
{
handle.promise().var.template emplace<U>(std::forward<Args>(args)...);
handle.resume();
if (handle.done()) {
throw generator_done("generator done");
}
return std::get<T>(handle.promise().var);
}
};
当然,我们这里没有考虑到 bigenerator<T, T>
这种传出来和传进去的消息类型都一样的情况,但其实只要做一下偏特化就可以了。由于不是协程部分的技术难点,就不再多介绍了。
全文完。