看过了几个古老的有栈协程库实现,深入理解bthread的设计思想之后还是觉得不完美。决定学习一下最新的相关技术来补充思路。这里主要参考邓子峰大佬https://codesire-deng.github.io/2021/11/18/C-20-Coroutine/的开源框架
回顾一下无栈协程的实现
相较于有栈协程自己管理的函数运行栈空间(独立栈或者共享栈),无栈协程不需要对这些空间做管理,但是无栈协程需要一个frame(协程帧)保存该协程的运行状态。协程帧主要保存参数和局部变量的值,在让出执行权的时候能保存运行状态和执行流节点。像邓子峰的协程视频中使用fib_frame保存ab变量值还有state状态,每次重新进入fib根据state值跳转到对应的执行流。
c++20协程的相关理论参考bennyhuo大佬的文章https://www.bennyhuo.com/book/cpp-coroutines/01-intro.html
有栈协程的理解对于开发者来说只是定义个栈空间来运行函数,而无栈协程在c++中,使用和理解难度都大了不少。
先通过一个简单的示例了解生成器和promise对象是什么东西
#include <coroutine>
#include <iostream>
#include <optional>
// 定义一个生成器,它将产生整数序列
template <typename T>
struct Generator {
struct promise_type {
std::optional<T> value;
Generator<T> get_return_object() {
return { std::coroutine_handle<promise_type>::from_promise(*this) };
}
std::suspend_always initial_suspend() {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
void unhandled_exception() {
std::terminate();
}
std::suspend_always yield_value(T value) {
this->value = value;
return {};
}
void return_void() {}
};
std::coroutine_handle<promise_type> handle;
Generator(std::coroutine_handle<promise_type> handle) : handle(handle) {}
~Generator() {
if (handle) {
handle.destroy();
}
}
bool next(T& value) {
if (!handle || handle.done()) {
return false;
}
handle.resume();
if (handle.promise().value) {
value = handle.promise().value.value();
handle.promise().value.reset();
return true;
}
return false;
}
};
// 使用生成器的协程函数
Generator<int> countUpTo(int n) {
for (int i = 0; i < n; ++i) {
co_yield i; // 产生下一个值
}
}
int main() {
Generator<int> gen = countUpTo(5); // 创建一个生成器,产生0到4的整数
int value;
while (gen.next(value)) { // 循环获取生成器产生的值
std::cout << "Generated value: " << value << std::endl;
}
return 0;
}
直接看这个程序的执行流
Generator gen = countUpTo(5);
调用countUpTo,实例化Generator。分配必要的内存来存储协程的状态,创建一个与这个协程关联的 promise_type 对象。promise_type 是协程框架的一部分,它管理着协程的生命周期,包括挂起、恢复和销毁。
countUpTo 函数通过调用其 promise_type 的 get_return_object 方法来获取一个表示该协程的句柄(在这个例子中是 Generator 类型的对象)。这个句柄允许外部代码控制协程的执行,比如恢复挂起的协程或检查协程是否已经完成。
协程句柄赋值给gen变量
在这一步中,因为initial_suspend()设置的返回值是std::suspend_always ,countUpTo 函数的函数体并未开始执行(除了与协程初始化相关的部分)。协程的实际执行会在稍后通过调用 gen.next(value) 并进而调用 handle.resume() 时开始。
gen.next(value)
bool next(T& value) {
if (!handle || handle.done()) {
return false;
}handle.resume(); if (handle.promise().value) { value = handle.promise().value.value(); handle.promise().value.reset(); return true; } return false;
}
检查协程状态
handle.resume();恢复协程
协程执行到co_yield i会挂起,并将值存储在promise对象的value中,执行流回到resume的调用方
引用的value被赋值promise的value值
handle.promise().value.reset();调用 std::optional 类型的 reset() 方法。这个方法将 optional 对象重置为不包含值的状态,即使其变为“空”状态。在这个上下文中,它用于在每次从生成器中取出值之后清空 value,以便为下一次 yield_value 做准备。(
在协程生成器的设计中,清空 promise 中的 value 而不是在下次存储时直接覆盖,有几个重要的原因:
- 状态明确性:通过显式地清空 value,生成器的状态变得更加明确。在每次 yield 之后,value 被重置为一个明确的不包含任何值的状态(对于 std::optional 来说就是“空”状态)。这有助于减少错误和混淆,因为代码的读者可以清楚地看到 value 在每次使用之后都被重置。
- 防止旧值残留:如果不清空 value,而是直接在下次存储时覆盖,那么在某些情况下可能会出现旧值意外地被使用的情况。特别是在协程的执行流程变得复杂或者存在多个分支时,这种残留的旧值可能导致难以追踪的错误。
- 资源管理:虽然在这个特定的例子中 value 是一个简单的整数,但如果 value 是一个需要手动管理资源的复杂对象(比如动态分配的内存或文件句柄),那么及时清空它就显得尤为重要。通过清空 value,可以确保相关资源在不再需要时被正确释放,从而避免资源泄漏。
- 逻辑清晰性:从逻辑上讲,每次从生成器中获取一个值后,该值就已经被“消费”了。因此,将 value 重置为未初始化状态符合这种“消费”后的直观期望。这有助于保持代码的清晰性和可读性。
- 异常安全:在协程中处理异常时,确保状态的一致性是非常重要的。如果在 yield_value 之后和下次 resume 之前发生异常,那么未清空的 value 可能会处于一个不一致或不可预测的状态。通过显式清空 value,可以减少这种状态不一致导致的问题。
总的来说,清空 promise 的 value 是一种良好的编程实践,它有助于确保生成器的正确性和健壮性。这种做法遵循了“初始化后立即使用,使用后立即清理”的原则,从而减少了潜在的错误和资源泄漏风险。
虽然不清空value,大多数情况下都程序都能正常运行,但是像if (handle.promise().value)这种判断就会失效,为了避免这些情况导致可能存在的bug,还是建议规范执行。
当协程执行完成后
for循环条件不满足 协程执行完成到 void return_void(){},std::suspend_always final_suspend() noexcept
- for循环条件不满足:当驱动协程的for循环发现其条件不再满足时,它会停止迭代。这意味着没有更多的外部请求来恢复(resume)协程的执行。
- 协程执行完成:如果此时协程已经到达了其逻辑终点(例如,执行到了协程函数的末尾,或者显式地通过某种方式标记为完成),那么协程被认为是执行完成的。
- 执行流到void return_void(){}:这通常表示协程的返回语句被执行。在C++协程中,你可以有一个特殊的返回对象或函数,用于处理协程完成时的清理工作。return_void可能是一个表示协程正常结束的辅助函数。
- 回到generator协程:这里的“generator协程”可能指的是协程的具体实现或包装器。在协程完成执行后,控制流通常会返回到这个层面,以便进行必要的后处理。
- 到std::suspend_always final_suspend() noexcept:这是C++协程中的一个关键部分。final_suspend是协程承诺对象(promise object)的一个成员函数,它在协程即将完成时被调用。std::suspend_always是一个指示器,告诉运行时系统总是挂起协程(在这种情况下,实际上是表示协程已经完成,并且不会再被恢复)。
- 返回调用resume方:一旦final_suspend被调用并且返回了std::suspend_always,控制流将返回到最初调用resume的地方。这通常意味着协程的执行已经完全结束,并且调用者可以继续执行后续的代码。
struct promise_type {
std::optional<T> value;
Generator<T> get_return_object() {
return {std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
void unhandled_exception() {
std::terminate();
}
std::suspend_always yield_value(T value) {
this->value = value;
return {};
}
void return_void() {}
};
get_return_object():
- 这个函数返回一个表示协程的对象,通常是一个封装了std::coroutine_handle的自定义类型。这个对象允许外部代码与协程交互,比如恢复(resume)协程或检查其状态。
initial_suspend():
- 这个函数在协程开始时被调用,决定了协程是立即执行还是挂起。返回std::suspend_always会导致协程在开始时挂起,而返回std::suspend_never则允许协程立即执行。在你的例子中,协程会立即执行。
final_suspend():
- 当协程执行到终点时(例如,执行了co_return语句或函数正常结束),会调用这个函数。它决定了协程在结束前是否挂起。通常,这个函数返回std::suspend_always,表示协程已经结束。
unhandled_exception():
- 如果协程中抛出了一个未被捕获的异常,这个函数会被调用。在你的代码中,它调用了std::terminate(),这会导致程序终止。这是一种简单的异常处理策略,但在实际应用中,你可能需要更复杂的错误处理逻辑。
yield_value(T value):
- 当协程执行co_yield语句时,会调用这个函数。它允许你将一个值“产出”(yield)给协程的调用者,并且通常会保存这个值以供外部访问。在你的代码中,产出的值被保存在value成员变量中。
return_void():
- 当协程通过co_return;(没有返回值的情况)结束时,会调用这个函数。它通常用于执行一些清理工作。在你的代码中,这个函数是空的,因为没有特定的清理工作需要执行。
这些函数共同定义了协程的行为和生命周期。虽然它们不是全部都必须的,但缺少某些函数可能会导致编译错误或运行时问题,因为编译器和运行时系统期望这些函数存在并正确实现。在自定义协程时,你应该根据你的具体需求来实现这些函数。
这些函数的名称和返回值类型在C++协程的上下文中是由C++标准规定的。编译器期望在协程的promise_type中找到这些特定名称的函数,并且它们必须具有与标准相符的签名。
C++20引入了协程作为语言的一部分,并定义了一套用于支持协程的底层机制。promise_type是这套机制中的关键组件,它充当了协程与其外部环境之间的桥梁。编译器使用这些函数来控制协程的执行流程,处理异常,以及管理协程的状态。
具体来说:
- 函数名称(如get_return_object, initial_suspend, final_suspend, unhandled_exception, yield_value, return_void)是固定的,编译器会查找这些名称的函数。
- 函数的返回值类型也是由标准规定的。例如,initial_suspend和final_suspend必须返回一个表示挂起操作的类型,通常是std::suspend_always、std::suspend_never或std::suspend_if。yield_value函数返回一个表示挂起的类型,通常也是std::suspend_always,但它还接受一个参数,该参数的类型与协程产生的值的类型相同。
- 函数的参数列表(对于接受参数的函数,如yield_value)同样是固定的。
这些规定确保了编译器能够正确地与协程的promise_type交互,从而实现协程的语义。如果你正在实现自己的协程类型或库,你需要遵循这些规定来确保你的协程能够与C++的协程机制兼容。