上一篇文章中我们介绍了C++ Coroutine的基本原理和用法,并且在最后实现了一个通过promise_type来传递参数的例子,本文作为本系列的第2篇,我们将探索一个简单的问题:如何更优雅的实现将counter函数里的参数传递给调用者,并且正确的释放coroutine的资源。
从co_await到co_yield
熟悉python的朋友对yield关键字应该非常的熟悉了,简直是创建一个可迭代对象的神器,也是用于异步编程的神器。C++过去一直缺少类似的能力,stackoverflow有很多咨询“如何在C++中实现类似Python yield的能力?”类似的问题。现在来到C++20,我们终于可以说:是的,C++终于具备的这个能力。
这一章我们将继续改写上一篇文章中最后的一个例子(已经忘记的朋友可以先点回去复习一下),我们将引入一个新的关键字co_yield,实现一个非常简洁的版本。
co_yield和co_await的作用类似,除了具备co_await相同的能力和行为以外,还有一个特别的点就是co_yield会调用promise_type::yield_value函数,并根据这个函数的返回值来判定是否要暂停执行。简单的说co_yield i;基本等价于:
// co_yield i;基本等价于
co_await promise.yield_value(i);
promise_type::yield_value的返回值是一个满足awaiter约束的对象,你可以通过这个值来自定义co_yield调用后的行为(暂停执行 or 继续执行等)。下面我们来看完整的代码:
#include
#include
#include
#include
class ReturnType {
public:
// Define the promise type
class Promise {
public:
ReturnType get_return_object() {
return {
.handle_ = std::coroutine_handle::from_promise(*this),
};
}
// Look at here: each call of 'co_yield i' equals to 'co_await promise.yield_value(i)'
std::suspend_always yield_value(size_t value) {
value_ = value;
return {};
}
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
size_t value_;
};
using promise_type = Promise; // Required by c++ standard
std::coroutine_handle handle_; // Use 'Promise' type argument
};
ReturnType counter() {
std::cout << "counter: start" << std::endl;
for (size_t i = 0;; ++i) {
co_yield i;
}
std::cout << "counter: end" << std::endl;
}
int main() {
auto return_object = counter();
auto& promise = return_object.handle_.promise();
for (size_t i = 0; i < 3; ++i) {
std::cout << "main:" << promise.value_ << std::endl;
return_object.handle_();
}
return_object.handle_.destroy();
return 0;
}
/*
Outputs:
counter: start
main:0
main:1
main:2
*/
我们观察看到代码又大大缩减了!GetPromiseAwaiter类不见了,非常好!除了这个差异以外还有一个关键的差异就是第17到20行:promise_type::yield_value的实现,这一步将co_yield的值写入到promise_type实例的value_变量中,然后返回suspend_always表示暂停执行(当然要暂停执行,因为此时promise::value_已经有了新的值,需要切换到main继续执行来读取这个值)。
其他部分的代码变化不大,就不特别的解释了。这一步的关键就是在利用co_yield会调用promise_type::yield_value的特点,简化了大量代码。
inital_suspend 和 final_suspend
观察上面的代码,counter部分的逻辑以及非常简略了,精简到其实只需要两行。我们接下来将继续改进counter函数,让他从无限循环变成有限循环,同时引出有两个细节点需要讲清楚,否则大家会感到难以理解。这两个细节点就是之前刻意忽略的两个概念:
- promise_type::initial_suspend 和 promise_type::final_suspend 是什么意思?
- coroutine_handle::destroy 什么时候调用?
这两个问题处理不好是很容易发生崩溃或者逻辑错误的,他们涉及到coroutine资源回收职责和coroutine何时中断等问题。
promise_type::initial_suspend 的返回值告诉编译器是否要在coroutine函数开始时暂停执行;
promise_type::final_suspend 的返回值告诉编译器是否要在coroutine函数即将结束时暂停执行。用一个伪代码来表示那么整个coroutine的生存周期就是这样的:
// coroutine函数编译器实际生成的逻辑
{
编译器:构造promise_type的实例: promise
try {
co_await promise.initial_suspend();
【进入你定义的函数体执行】
} catch (...) {
if (promise.initial_suspend没有暂停函数执行)
throw;
promise.unhandle_exception();
}
// 执行回收操作
co_await promise.final_suspend();
编译器:回收coroutine_handle对应的所有资源
}
从上面的伪代码看出,实际上你在coroutine函数中定义的逻辑只是最终编译器生成的代码中的一部分。
initial_suspend的目的是确保在任何coroutine函数中定义的代码执行之前,暂停coroutine执行,以便于编译器可以保证在coroutine函数中定义的代码抛出的任何异常都会被
promise_type::unhandle_exception所处理。这一点很容易理解,因为在coroutine函数第一次暂停执行之前,其和正常函数没有任何差异,依然是在调用者的栈上继续执行的,如果此时抛出了异常并且代码并没有catch住那么异常就会在栈上回溯。如果调用者同时调用了多个coroutine函数,那么当其中一个抛出异常时如何正确的清理其它coroutine的资源就会相对麻烦一些,并且容易出现bug。
因此一个比较安全的做法就是initial_suspend返回suspend_always来暂停执行(相对应的返回suspend_never就是不暂停)。这里需要注意的是需要额外一次恢复执行(调用coroutine_handle)才能让函数真正执行到你定义的函数体。如果你对coroutine的代码相当自信那么也可以忽略这个约束。
final_suspend的目的是明确在函数执行完成后是否还需要一次暂停来更新coroutine_handle对应的状态,这个细节需要非常小心,其涉及到你在调用coroutine时判断coroutine是否执行完毕以及coroutine的资源需要被谁释放的问题,如果处理错误就会造成程序崩溃。不过你也不需要担心,我总结了一个简单的判断方法:
- 如果你需要判断coroutine函数是否执行完成 —— 那么你需要让final_suspend返回一个暂停信号,比如suspend_always并且在coroutine执行完成后调用coroutine_handle::destory()来释放资源
- 如果你不需要判断coroutine函数的执行状态 —— 那么你可以让final_suspend返回不暂停信号,比如suspend_never。此时你不需要考虑何时释放coroutine的资源,当其执行完毕后会自动释放。
怎么样,这个判断方法非常简单了吧?为了让大家更好的理解这个细节以及引入 return_void / return_value 两个函数,我们来看一段有bug的代码:
#include
#include
#include
#include
class ReturnType {
public:
// Define the promise type
class Promise {
public:
~Promise() { std::cout << "Promise destroyed" << std::endl; }
ReturnType get_return_object() {
return {
.handle_ = std::coroutine_handle::from_promise(*this),
};
}
// Look at here: each call of 'co_yield i' equals to 'co_await promise.yield_value(i)'
std::suspend_always yield_value(size_t value) {
value_ = value;
return {};
}
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
// void return_void() {} // Without this function, the behaviour is undefined...
size_t value_;
};
using promise_type = Promise; // Required by c++ standard
std::coroutine_handle handle_; // Use 'Promise' type argument
};
ReturnType counter(size_t num) {
std::cout << "counter: start" << std::endl;
for (size_t i = 0; i < num; ++i) {
co_yield i;
}
std::cout << "counter: end" << std::endl;
}
int main() {
auto return_object = counter(3);
auto& promise = return_object.handle_.promise();
while (!return_object.handle_.done()) {
std::cout << "main:" << promise.value_ << std::endl;
return_object.handle_();
}
return_object.handle_.destroy();
return 0;
}
/*
Outputs:
counter: start
main:0
main:1
main:2
counter: end
Promise destroyed
main:0
[1] xxxx segmentation fault
*/
这段代码最后将会导致一个段错误,看到段错误相信大家都会非常抓狂,造成段错误的原因我稍后解释。还是先看这一段代码的变化:
- counter函数增加了一个参数,表示循环i变量的最大值。之前例子中的counter函数是无限循环的,这里增加了循环退出条件,使得函数可以执行到末尾
- main函数改成通过coroutine_handle::done来判断coroutine是否执行完毕
我们看控制台输出可以看到 counter: end ,说明counter函数确实跳出了循环执行到了末尾。这里我需要介绍一个(大概也是我介绍的最后一个coroutine的概念了)C++ coroutine的约束:
当coroutine通过 co_return关键字返回 或者 执行到用户定义的函数体末尾 时,需要promise_type上定义函数:
- 当co_return没有返回值,或者自然执行到末尾时,需要调用promise_type::return_void函数
- 当co_return具有返回值,需要调用promise_type::return_value函数
如果没有定义return_void / return_value并且代码执行到了需要调用它们的时候会发生什么?答案是行为是未定义,这句话基本上C++标准里的必杀技,一旦触发基本等于崩溃(当然这并不绝对,和不同的编译器实现和运气有关,绝对不要触发这种情况),我们之前所有的例子都没问题是因为counter从来没有执行到末尾。
所以上面的代码问题很明确了,我们让counter函数执行到了末尾,但是promise_type没有定义return_void(我注释掉了),因此大家可以尝试一下把注释去掉、定义return_void空函数。
那么恭喜你,你会发现即使你修了这个bug,这个程序还是有问题!而且依然是段错误!
实际上我非常推荐你去运行一下这个代码,因为这一步对于你理解final_suspend是非常重要的,只看我写的是不够的。如果你运行一下你会发现程序的输出很奇怪:
counter: start
main:0
main:1
main:2
counter: end
Promise destroyed
main:0
[1] xxxx segmentation fault
我们仔细看会发发现 counter: end 之后,又输出了 main:0 ,这是什么意思?这代表第46行main函数里判断coroutine是否执行完毕这个逻辑是失效的!按照正常人理解,counter执行完毕,coroutine_handle::done 返回true,那么while循环就退出了,程序正常结束。但事实上不是这样的,while循环继续执行,最终执行到48行,再次尝试继续counter执行时程序崩溃(因为此时counter已经执行完毕,资源都被回收了,不可能继续执行了)。
那么问题究竟出在哪里了?我们搬出 cppreference 查阅一下 coroutine_handle::done 的详细定义:
Checks if a suspended coroutine is suspended at its final suspended point.
1) Returns true if the coroutine to which *this refers is suspended at its final suspend point, or false if the coroutine is suspended at other suspend points. The behavior is undefined if *this does not refer to a suspended coroutine.
2) Always returns false.
我们可以清楚的看到,coroutine_handle::done 的实际作用是判断coroutine是否停止在了final_suspend处!这种情况下会返回true,否则其它任何请求都会返回false,而当coroutine_handle本身已经不合法时返回值是未定义的。我们上面遇到的情况就是 coroutine_handle 已经不合法了(资源被释放掉了)但是依然调用了done方法,此时返回了false导致程序继续执行,从而发生崩溃。
看到这里,请大家再次耐心的回忆一下我上面提到的“如何判断final_suspend是否需要暂停”的原则。对!因为我们要检查coroutine是否执行完毕,因此必须让final_suspend返回一个暂停标志,但是实际上代码的23行返回了suspend_never,表示不暂停执行,main函数无法正确判断coroutine是否执行完毕进而导致崩溃。
修复这个bug很简单,在第23行返回suspend_always即可。如同开始我说的,修改这个bug很简单,但是理解为什么这么修复就需要搞清楚上面提到的这些原理了。以下是正确的代码:
#include
#include
#include
#include
class ReturnType {
public:
// Define the promise type
class Promise {
public:
~Promise() { std::cout << "Promise destroyed" << std::endl; }
ReturnType get_return_object() {
return {
.handle_ = std::coroutine_handle::from_promise(*this),
};
}
// Look at here: each call of 'co_yield i' equals to 'co_await promise.yield_value(i)'
std::suspend_always yield_value(size_t value) {
value_ = value;
return {};
}
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; } // Haha, here is the issue.
void unhandled_exception() {}
void return_void() {} // Without this function, the behaviour is undefined...
size_t value_;
};
using promise_type = Promise; // Required by c++ standard
std::coroutine_handle handle_; // Use 'Promise' type argument
};
ReturnType counter(size_t num) {
std::cout << "counter: start" << std::endl;
for (size_t i = 0; i < num; ++i) {
co_yield i;
}
std::cout << "counter: end" << std::endl;
}
int main() {
auto return_object = counter(3);
auto& promise = return_object.handle_.promise();
while (!return_object.handle_.done()) {
std::cout << "main:" << promise.value_ << std::endl;
return_object.handle_();
}
return_object.handle_.destroy(); // NOTE: Please try to comment out this line and run again. You will find it's
// necessary to destroy the handle explicitly
return 0;
}
/*
Outputs:
counter: start
main:0
main:1
main:2
counter: end
Promise destroyed
*/
注意看23行和25行,修复了上面提到的两个问题,运行这个程序我们将得到一个满意的结果。
实际上没有定义 return_void 在我测试的环境里(clang 15)并不会引发什么错误,但是触发未定义的行为依然是不推荐的。
这里额外留一个练习题:第50行代码是在coroutine执行完毕后释放coroutine资源的逻辑。如果我们不执行这一步会有什么结果?程序在终止(main函数结束)后会自动释放资源吗?你可以自己试试看。
好,这就是C++ coroutine系列第2篇文章的全部内容了,我们基本介绍完了全部的coroutine概念(co_return没有细讲,但是很简单了,大家应该能自己理解),并且实现了一个简单的Generator的功能。
不过虽然counter的代码看起来实现的很简洁了,但是main仍然比较冗余,在下一篇中我们将实现一个真正有用的Generator模版类,并让main看起来“非常的简洁”然后再实现一个结合Generator和Iterator机制的类型让用法更加的简单(对,就是for它就行!),并且展示如何实现类似python generator send一样的机制,即在迭代generator的过程中向coroutine传递参数。
喜欢大家就点个赞、收个藏、关个注吧!