C++ 协程——实战演示

Blog
Author:
Amir KirshAmir Kirsh
Published On:
3月 23, 2022
Estimated reading time:
4 minutes

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,部分协程限制:

协程:

  • 不能使用 return,只能使用 co_return
  • 不能使用可变参数(如:like printf)
  • 不能为常量表达式 (constexpr)
  • 不能为构造函数或析构函数
  • 不能为主体函数
  • 不能使用 auto 或概念作为返回类型(程序员需指定返回类型,以便编译器知道使用何种句柄类型,如 generator<int>;而该类型显然不能通过函数主体内容推断)

通过引用将参数传递给协程的危害

来看一个取自 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++中的协程是极为复杂的程序。协程实现者应管理产生时待创建的帧,但我们使用了一个外部库来管理它。对于临时对象的挂起引用,协程分外敏感,甚至可以说比简单函数还要敏感,就好像我们使用的临时对象活着一样。但是,复制到协程帧的引用则并非如此。如果你听说过按值将对象传递给协程的建议,在高代价的情况下就不会有尝试的想法(这与普通函数调用的建议一致。按值传递要比常量引用更为安全,但对于大型非平凡类型而言,则极为昂贵)。本文讨论了临时引用的危害和避免方法。

资源与补充材料