C++20 添加了一项万众期待的新特性——协程。(在另一篇文章中,我们会谈到 C++20 发布的其他特性;而在先前的文章中,我们已讨论过相关话题:C++ 代码现代化与 C++ 演变。)
本篇文章,我们将对 C++ 协程进行一些实战演示。
先从一段代码开始。
template<typename T>
unique_generator<T> range(T fromInclusive, T toExclusive) {
for (T v = fromInclusive; v < toExclusive; ++v) {
co_yield v;
}
}
int main() {
for (auto val : range(1, 10)) {
std::cout << val << '\n';
}
}
可在编辑器浏览器 (Compiler Explorer) 获取上述代码,链接:
https://coro.godbolt.org/z/zK3E9TEce
解释一下上述代码。
协程是一个特殊函数,可暂停执行,随后可在暂停执行的确切位置继续执行。协程执行暂停时,该函数能返回(产生)一个值。协程执行结束时,该函数亦能返回一个值。
协程暂停时,其状态会复制于代表协程状态的分配对象(不在堆栈中,我们称其为协程“帧”)。协程暂停时,会返回某种“句柄”。返回值本身则由句柄生成。
上述主体代码中,我们使用“range”作为协程函数。“range”作为协程函数的方法是,使其包含“co_yield”,“co_return”或“co_await”。
上述函数中使用了“co_yield”,它在保持函数“框架”的同时,会返回一个值,因此我们能在下一次迭代中返回,该函数将保留其状态。
请注意,这与使用静态变量保留状态不同,因为我们能从不同线程调用或递归调用协程,且每次调用都将独立保留协程自身的“帧”。要想实现这一点,必须将函数状态分配到通过协程返回值管理的“帧”。
协程返回“句柄”由返回类型设置。该“句柄”持有一个内部 promise_type(请注意,它与 std::promise 无关)。promise_type 必须具有 get_return_object() 函数。promise_type 的其他要求,请参考 cppreference 的协程 promise_type 的相关内容。
处理 promise_type 及协程“帧”生存期的机制确实是负担。为了避免这种情况,可以使用现有实现,并关注协程本身的实现。cppreference std::coroutine_handle 使用示范为生成器类提供了这样一个实现。上述实例中,我们使用了另一个库中的类似生成器。该库可通过上述 cppreference 链接中生成器的类似方式,为用户提供作为迭代器的 unique_generator 类型(即,可使用返回值类型 unique_generator,迭代协程中产生的值)。
unique_generator 的用处不容小觑。应由它处理协程帧分配与释放。如需详细了解协程帧处理,请查看 unique_generator 的程序错误修复程序。
到达 co_return 或函数末尾时,协程将结束执行。在当前示例中,到达循环中的 toExclusive 值后, range 函数将停止运行。
截至 C++20,部分协程限制:
协程:
来看一个取自 Arthur O’Dwyer 博客的实例:
unique_generator<char> explode(const std::string& s) {
for (char ch : s) {
co_yield ch;
}
}
int main() {
for (char ch : explode("hello world")) {
std::cout << ch << '\n';
}
}
上述代码在调用协程函数“explode”时创建了一个临时字符串。然而,因为临时字符串生存期无法扩展为协程帧创建的一部分,该临时字符串将在首次实际使用协程前停止运行。
正如上述代码中展示的那样,运行地址错误检查器 (-fsanitize=address) 时,可以发现程序错误。没有该 flag,则无法检测到相应程序错误。这意味着这是一个可以在环境中运行,并在生产中崩溃的程序错误。
请注意,即使试图将临时字符串复制到另一个超过协程生存期的字符串,该问题也无法得到解决:
unique_generator<char> explode(const std::string& s) {
auto ps = std::make_unique<std::string>(s);
for (char ch : *ps) {
co_yield ch;
}
}
因为首次调用协程只是创建,甚至未执行代码主体的第一行,致使上述代码仍然存在未定义行为。随后,首次执行时的临时字符串已然无法运行,那就试着从一个无法运行的临时字符串中创建一个堆分配的字符串(通过调用 make_unique)。请再次注意,运行地址错误检查器 (-fsanitize=address) 时,可发现此示例中的程序错误。而在本例中,如果没有该操作,则无法检测到相应程序错误。
为了更好地理解创建协程和实际调用协程之间的分离,可将代码主体行一分为二:
auto coro = explode("hello world"); // (1) coroutine being created
for (char ch : coro) { // (2) coroutine being called
std::cout << ch << '\n';
}
第一行可以标记为 (1),但第二行标记为执行 coro (2),协程中临时字符串(创建于“hello world”)的位置则无法运行。行 (2) 的首次调用可从临时字符串中创建 unique_ptr,然而为时已晚,因为届时临时字符串早已无法运行。
可通过发送一个非临时字符串变更代码使其生效:
int main() {
std::string s = "hello world";
// may_explode is a coroutine getting const string&
for (char ch : may_explode(s)) { // ok doesn't explode now
std::cout << ch << '\n';
}
}
然而上述修改仅能改变调用而非函数本身,因此函数仍可临时调用字符串,其间依然存在未定义行为用法。
可更改函数以期实现比协程更具生存期的效果,例如 unique_ptr:
unique_generator<char> doesnt_explode(std::unique_ptr<std::string> ps) {
for (char ch : *ps) {
co_yield ch;
}
}
int main() {
for (char ch : doesnt_explode(std::make_unique<std::string>("good"))) {
std::cout << ch << '\n';
}
}
但是,有人认为上述 API 并不友好。
也可按值传递字符串,本文将在后续讨论该选项。
如上所述,如果实际发送的左值引用超过了协程生存期,或者转而发送右值,那么接受常量左值引用的协程则可运行。下列代码正是这种情况,预计通过 std::string_view 实现:
unique_generator<char> extract(std::string_view s) {
for (char ch : s) {
co_yield ch;
}
}
int main() {
// this works ok
for (char ch : extract("hello world")) {
std::cout << ch << '\n';
}
// this doesn't
using namespace std::string_literals;
for (char ch : extract("hello world"s)) {
std::cout << ch << '\n';
}
}
同样,未定义行为可通过地址错误检查器 (-fsanitize=address) 显现,而在本代码示例中,如果没有该操作,则无法显现。
一些来源(如 SonarSource)建议,涉及协程时,出于安全和避免上述挂起引用场景的考虑,最好按值获取参数。
我不认同这样的说法。
第一,按值获取并非总是有效,正如我们在上述string_view示例中看到的那样。(有人认为,视图是一种引用-语义类型,类似于“const T&”,因此按值传递 string_view 实际上并不是“按值”传递。确实如此。然而,从技术层面来说,“按值传递可避免麻烦”的说法并不总是成立。)
第二,问题不在于我们所期望的参数,而在于发送一个临时参数,这在推出协程之前就是一个已知的问题。
第三,该过程极其低效,尤其是通过协程实现。
编写更为通用的协程,以便能从任一容器中或是出于“安全考虑”(持怀疑态度)提取项目,我们将按值获取容器:
template<typename T>
unique_generator<const typename T::value_type&> extract(T s) {
for (const auto& val : s) {
co_yield val;
}
}
请注意,由于协程不支持对其返回类型使用 auto,至少在 C++20 中,我们需要明确表达返回类型。
在主体代码中,将使用 MyString 类型对象的简单循环同协程循环做比较,作为容器的内部值。因此可以在其构造函数和析构函数中添加打印输出:
int main() {
std::array arr{MyString("Hello"), MyString("World"), MyString("!!!") };
std::cout << "========================\n";
std::cout << "coroutine loop:\n";
std::cout << "------------------------\n";
for (const auto& val : extract(arr)) {
std::cout << val << '\n';
}
std::cout << "========================\n";
std::cout << "simple loop:\n";
std::cout << "------------------------\n";
for (const auto& val : arr) {
std::cout << val << '\n';
}
}
按值获取容器协程的作用可以在打印输出中清楚地看到:
========================
coroutine loop:
------------------------
MyString copy ctor: Hello (0x7ffefe1f5790)
MyString copy ctor: World (0x7ffefe1f57b0)
MyString copy ctor: !!! (0x7ffefe1f57d0)
MyString copy ctor: Hello (0x610000000070)
MyString copy ctor: World (0x610000000090)
MyString copy ctor: !!! (0x6100000000b0)
~MyString: !!! (0x7ffefe1f57d0)
~MyString: World (0x7ffefe1f57b0)
~MyString: Hello (0x7ffefe1f5790)
Hello (0x610000000070)
World (0x610000000090)
!!! (0x6100000000b0)
~MyString: !!! (0x6100000000b0)
~MyString: World (0x610000000090)
~MyString: Hello (0x610000000070)
========================
simple loop:
------------------------
Hello (0x7ffefe1f5710)
World (0x7ffefe1f5730)
!!! (0x7ffefe1f5750)
在该示例中,因为发送了生存期超出协程的实际左值引用,我们可以通过引用获取容器。此为变更内容(注意参考 T):
template<typename T>
unique_generator<const typename T::value_type&> extract(const T& s) {
for (const auto& val : s) {
co_yield val;
}
}
现在,对于协程而言,输出将变得更好:
========================
coroutine loop:
------------------------
Hello (0x7fff7b224350)
World (0x7fff7b224370)
!!! (0x7fff7b224390)
========================
simple loop:
------------------------
Hello (0x7fff7b224350)
World (0x7fff7b224370)
!!! (0x7fff7b224390)
但是,当前代码仍然允许获取临时代码,这将导致未定义行为:
for (const auto& val : extract(std::array{MyString("Hi"), MyString("!!")})) {
std::cout << val << '\n';
}
通过输出可以很清楚地发现存在未定义行为,因为我们在析构后打印字符串:
========================
coroutine loop:
------------------------
MyString ctor from char*: Hello (0x7ffe650e0fc0)
MyString ctor from char*: World (0x7ffe650e0fe0)
MyString ctor from char*: !!! (0x7ffe650e1000)
~MyString: !!! (0x7ffe650e1000)
~MyString: World (0x7ffe650e0fe0)
~MyString: Hello (0x7ffe650e0fc0)
Hello (0x7ffe650e0fc0)
World (0x7ffe650e0fe0)
!!! (0x7ffe650e1000)
同样,代码将随 -fsanitize=address 一起崩溃,且无地址错误检查器。在这种情况下,它将作为一个隐藏的程序错误等待生产。
我的解决方案是避免挂起引用程序错误,同时实现引用效率,这对协程而言并不新鲜。实施常量引用,删除右值引用:
void extract(const std::string&& s) = delete;
unique_generator<char> extract(const std::string& s) {
for (char ch : s) {
co_yield ch;
}
}
int main() {
std::string s = "hello world";
for (char ch : extract(s)) {
std::cout << ch << '\n';
}
// doesn't compile! Good!!
// for (char ch : extract("temp")) {
// std::cout << ch << '\n';
// }
}
请注意,在这种情况下,上述删除右值版本的想法得以解决未定义行为,但并非无懈可击,且有人认为这是一种不良做法(参考Abseil 第 149 周的提示:对象生存期与= delete,以便就该主题展开有趣讨论)。虽然有争议,也并非无懈可击,但我任然觉得该解决方案很有意义。
该示例受Adi Shavit 在 CppCon 2019 的发言——协程启发。
假设要按这样的顺序遍历二叉树:
BinaryTree<int> t1{5, 3, 14, 2, -3, 100, 56, 82, 72, 45};
for (auto val : t1.inorder()) {
std::cout << val << '\n';
}
我们能在二叉树类中实施成员协程函数吗?答案是:是的,我们能!
请看这里:
template<typename T>
class BinaryTree {
struct TreeNode {
T value;
TreeNode* left = nullptr;
TreeNode* right = nullptr;
// [...]
unique_generator<T> inorder() {
if(left) {
for(auto v: left->inorder()) {
co_yield v;
}
}
co_yield value;
if(right) {
for(auto v: right->inorder()) {
co_yield v;
}
}
}
};
TreeNode* head = nullptr;
// [...]
public:
auto inorder() {
return head->inorder();
}
// [...]
};
对于一个空二叉树,上述操作会失败,如下所示:
BinaryTree<int> t2{};
for (auto val : t2.inorder()) { // crashes here, head is null
std::cout << val << '\n';
}
几种有效简单的方法可以解决空树遍历的问题,保持协程方法。请参阅此处。
我们已经演示了几个简单协程,特别是生成器协程。协程的主要思想就是向调用对象释放控制时,借助函数保留状态。C++中的协程是极为复杂的程序。协程实现者应管理产生时待创建的帧,但我们使用了一个外部库来管理它。对于临时对象的挂起引用,协程分外敏感,甚至可以说比简单函数还要敏感,就好像我们使用的临时对象活着一样。但是,复制到协程帧的引用则并非如此。如果你听说过按值将对象传递给协程的建议,在高代价的情况下就不会有尝试的想法(这与普通函数调用的建议一致。按值传递要比常量引用更为安全,但对于大型非平凡类型而言,则极为昂贵)。本文讨论了临时引用的危害和避免方法。