Preface
要掌握 C++20 协程,仅仅看完文档是远远不够的。C++ 和其他语言的协程的最大不同点在于,C++ 提供了最少量的编译器实现和极大量的「可定制点」,而其他语言可能连「调度器」都给用户准备好了。
在茫茫多的可定制点面前,如果你能一眼看出「为什么 C++ 要这样设计」,那么我愿称你为天才。否则,还是从常见的范式下手,逐渐体会设计者的思想吧。
我们将从最简单的范式——Generator 下手。
本文力求概念定义的权威性,但无法做任何保证。
What is Generator
Generator 泛指一段序列(有限或无限长)的生成器。在协程的语境下,Generator 特指一个懒惰的协程,它每次运行时会在调用者线程同步地计算一个值,然后暂停自己,同时把值返回给调用者。
因为 Generator 在是在调用者线程下立即同步执行的,所以它不需要任何调度器。
Fibonacci 数列就是 Generator 的 hello-world。我们来看一看效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| Generator<int> fibonacci(int a0, int a1) { co_yield a0; co_yield a1;
for (int a2; true;) { a2 = a0 + a1; a0 = a1; a1 = a2; co_yield a2; } };
int main() { for (int len=10; int i : fibonacci(1, 1)) { std::cout << i << std::endl; if (--len == 0) break; } return 0; }
|
要实现这个效果,只要实现 Generator<T>
,虽然很麻烦。
Why Generator
上一段例子很愚蠢,我随手写一个 struct Fibo
重载 operator()
,内置一个迭代器不香?为什么要大费周章实现 Generator,还要付出协程的运行时代价?
我也不知道为什么,纯纯为了学而学 Generator 可以免去你每次重新设计结构体的麻烦,无需你重写各种迭代器,可以看作一个又香又臭的语法糖。更进一步,Generator 可以很方便地相互嵌套,相比其他做法大大降低了编程复杂度。
Analyze How-to
希望你已经大致了解 C++ Coroutine 的运作方式,至少了解 co_await
关键字的作用。顺便推销一下我的另一篇文章 C++20 Coroutine,它可以帮助你记住 co_await
。
现在按照协程的生命周期来分析 Generator<T>
,看看它应该具有哪些接口和行为。
get_return_object
协程在初次进入的时候会调用 promise.get_return_object()
,其返回值会在第一次暂停的时候返回给 caller。caller 要向协程索取返回值,通常只能通过这个return_object
去要,所以它相当于一个 handler。
按照 C++ 标准规定 promise.get_return_object()
的返回值必须是协程类型,也就是 Generator<T>
,也好,省得我们思考了。
1 2 3 4 5 6 7 8 9 10 11 12
| template<typename T> class Generator { public: class promise_type { public: Generator get_return_object() noexcept { return Generator{<unspecified>}; } }; };
|
inital_suspend
协程在初次进入的时候(且在上一步之后)会调用 co_await promise.inital_suspend()
。我们希望的是协程不要做任何计算(懒惰),并且直接让出执行权给 caller。所以有如下设计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| template<typename T> class Generator { public: class promise_type { public: Generator get_return_object() noexcept { return Generator{<unspecified>}; }
std::suspend_always initial_suspend() const noexcept { return {}; } }; };
|
别去找 std::suspend_always
的实现了,我已经给你准备好了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| struct suspend_always { constexpr bool await_ready() const noexcept { return false; }
constexpr void await_suspend(coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {} };
struct suspend_never { constexpr bool await_ready() const noexcept { return true; }
constexpr void await_suspend(coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {} };
|
可以看出,std::suspend_always
在被 co_await
的时候总是会让出执行权给 caller/resumer,而且不做任何调度。如果 caller/resumer 未来不将这个协程加入调度,那它就被永远遗忘在堆内存中了。
另一方面,可以发现 co_await std::suspend_always{}
的返回值总是 void
。
caller resume
目前为止,一个协程已经构造好了,而且已经暂停了。用户程序大概长这个样子:
1 2 3 4 5 6 7 8 9 10 11 12
| Generator<int> fibonacci(int a0, int a1) { }
int main() { Generator<int> fibo = fibonacci(1, 1);
}
|
这时,我们想做两件事:
- resume 一下
fibo
,从而计算出第一个值
- 通过
fibo
拿到第一个值
根据 C++ 标准,要恢复一个协程,必须持有对应的 std::coroutine_handle
,换句话说 fibo
必须持有 std::coroutine_handle
:
1 2 3 4 5 6 7 8
| class Generator { public: class promise_type { }; private: std::coroutine_handle<promise_type> m_handle; };
|
那 Generator 是由谁初始化的呢?别忘了,初始化的事是由 promise.get_return_object()
干的。
问题又来了,如何在 promise.get_return_object()
初始化 std::coroutine_handle
?
我们要认识到以下事实:
&promise 和 coroutine_handle 一一对应,它们的偏移量是编译期已知的,可以互相换算。
也就是说,可以根据 promise
的地址算出协程的 std::coroutine_handle
,也可以反过来用 std::coroutine_handle
换算出 promise
的地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| template<typename T> class Generator { public: class promise_type { public: Generator get_return_object() noexcept { return Generator{ std::coroutine_handle<promise_type>::from_promise(*this) }; } };
Generator(std::coroutine_handle<promise_type> handle) noexcept : m_handle(handle) {} private: std::coroutine_handle<promise_type> m_handle; };
|
std::coroutine_handle
的本质是指向协程帧的指针,通常按值传递。
现在,我们可以任意给 Generator 加成员方法,让它利用 m_handle
做任何事情,比如利用 fibo.m_handle.resume()
恢复协程,进行一次计算。
1 2 3 4 5 6 7 8 9 10
| class Generator { public: class promise_type { };
void next() { m_handle.resume(); } private: std::coroutine_handle<promise_type> m_handle; };
|
co_yield
现在,用户调用了 fibo.next()
,协程被恢复执行。现在我们想返回第一个值给用户,意味着必须暂停协程,归还执行权。C++ 给我们提供了关键字 co_yield
,它是一个语法糖,co_yield expr
等价于 co_await promise.yield_value(expr)
。
所以在 promise.yield_value(expr)
里面,我们要做两件事:
- 将
expr
保存在 promise
里面,便于外部访问。
- 返回一个
std::suspend_always{}
,令 caller 重获执行权。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| template<typename T> class Generator { public: class promise_type { public: const T &result() const noexcept { return m_value; }
std::suspend_always yield_value(T value) noexcept { m_value = value; return {}; }
private: T m_value; };
public: const T &result() const noexcept { return m_handle.promise().result(); } void next() { m_handle.resume(); }
private: std::coroutine_handle<promise_type> m_handle; };
|
std::coroutine_handle<Promise>::promise()
可以拿到对应的 promise 对象,从而拿到计算结果。但请特别小心协程可能已经被销毁!
从容器的角度看,T m_value
是一个很烂的设计,仅供示意,请勿模仿!
Finish the leftover
现在,Generator 已经具备我们所需的全部功能,但还有一些手续要办:
- 异常处理
- 定义
co_return
的行为
uncaught exception handler
协程运行时没有被显式 catch 的异常会导致立即调用 promise.unhandled_exception()
,紧接着调用 co_await promise.final_suspend()
。
简单起见,我们先不处理异常,留下一个空函数就行。
1 2 3 4 5 6
| class promise_type { void unhandled_exception() const noexcept {} };
|
define co_return
协程可以显式 co_return [expr]
,或者因为执行完函数体而隐式 co_return
。
我们的 fibonacci 不需要 co_return
任何东西,但是作为一篇负责任的教程,我还是会告诉你:
co_return expr;
会调用 promise.return_value(expr)
,随后析构协程内的自动变量,但 promise 和协程参数仍然存活。
类似地 co_return;
或者 co_return (void)expr;
会调用 promise.return_void()
。
return_value(expr)
和 return_void()
是我们访问协程内自动变量的最后机会。但是 promise 和协程参数仍会存活。
return_xxx()
和 自动变量析构后,协程执行 co_await promise.final_suspend()
,若协程不暂停,则协程帧析构:
- 析构
promise
- 析构协程参数
- 释放堆空间
- 转移执行权
若协程暂停,就只能通过 std::coroutine_handle::destroy()
来显式析构,无论是否已经析构都不能再 resume。
关于协程的各类元素的生命周期,是一大坑点,未来我会撰写一篇文章来表述。
言归正传,我们只要随便写一个 final_suspend() 就好,反正这个 Generator 永远不会 co_return
。
Demo: fibonacci
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
| #include <iostream> #include <coroutine>
template<typename T> class Generator { public: class promise_type { public: Generator get_return_object() noexcept { return Generator{ std::coroutine_handle<promise_type>::from_promise(*this)}; }
void return_void() const noexcept {}
std::suspend_always initial_suspend() const noexcept { return {}; }
std::suspend_always final_suspend() const noexcept { return {}; }
const T &result() const noexcept { return m_value; }
std::suspend_always yield_value(T value) noexcept { m_value = value; return {}; }
void unhandled_exception() const noexcept {}
~promise_type() { std::cout << "~promise_type\n"; }
private: T m_value; };
public: const T &result() const noexcept { return m_handle.promise().result(); }
Generator() noexcept : m_handle(nullptr) {}
Generator(std::coroutine_handle<promise_type> handle) noexcept : m_handle(handle) {}
~Generator() { std::cout << "~Generator\n"; if (m_handle) { std::cout << "m_handle.destroy()\n"; m_handle.destroy(); } }
void next() { m_handle.resume(); }
private: std::coroutine_handle<promise_type> m_handle;
};
Generator<int> fibo(int a0, int a1) { co_yield a0; co_yield a1;
for (int a2; true;) { a2 = a0 + a1; a0 = a1; a1 = a2; co_yield a2; } };
int main() { using namespace std; auto fi = fibo(1, 1); for (int i = 0; i < 10; ++i) { fi.next(); cout << fi.result() << endl; } return 0; }
|
Output:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 1 1 2 3 5 8 13 21 34 55 ~Generator m_handle.destroy() ~promise_type
|
Conclusion and Prospect
我们实现了一个基本能用的 Generator,因为是入门教程,代码故意写得很糙,聪明的你一定知道如何改进。
如果你想自己造一个 Generator,那么还需要改进的地方至少有:
promise_type
内置一个更高效、安全的容器
Generator<T>
内置一个迭代器类,以支持 range for 循环。
- 如果你很熟悉生命周期的坑点,
promise_type
内置的容器可以仅存放指向协程自动变量的指针,进一步压缩空间,提升 yield
效率。
- 利用 execution 来实现异步版本的 Generator。
- 思考递归
yield
的效率问题,以及如何改进(难但秀)。
References
Some Slight Improvements
- 使用
std::optional
来做容器,自动调用正确的构造/析构函数,支持移动语义。(可以进一步改用 std::variant
)。
- 定义了 Generator 的几个构造和赋值函数,增强了安全性(safety)。
- 在
unhandled_exception()
内存放了 std::exception_ptr
。
- 用例改为稍微复杂一些的字符串生成,里面有一些性能问题,读者有兴趣的可以找找茬。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
| #include <optional> #include <coroutine> #include <exception>
#include <iostream> #include <random> #include <cmath>
template<typename T> class Generator { public: class promise_type { public: promise_type() noexcept = default;
Generator get_return_object() noexcept { return Generator{ std::coroutine_handle<promise_type>::from_promise(*this)}; }
void return_void() const noexcept {}
std::suspend_always initial_suspend() const noexcept { return {}; } std::suspend_always final_suspend() const noexcept { return {}; }
const T &result() const noexcept { return *m_value; } T &result() noexcept { return *m_value; }
std::suspend_always yield_value(const T &value) noexcept { m_value = value; return {}; }
std::suspend_always yield_value(T &&value) noexcept { m_value.emplace(std::move(value)); return {}; }
void unhandled_exception() { m_exception = std::current_exception(); }
private: std::optional<T> m_value; std::exception_ptr m_exception; };
public: const T &result() const noexcept { return m_handle.promise().result(); } T &result() noexcept { return m_handle.promise().result(); }
Generator() = delete;
Generator(std::coroutine_handle<promise_type> handle) noexcept : m_handle(handle) {}
Generator(const Generator &) = delete;
Generator &operator=(const Generator &) = delete;
Generator &operator=(Generator &&other) noexcept { if (this != other) { m_handle = other.m_handle; other.m_handle = nullptr; } return *this; }
~Generator() { if (m_handle) m_handle.destroy(); }
void next() { m_handle.resume(); }
private: std::coroutine_handle<promise_type> m_handle;
};
Generator<std::string> lottery(size_t size, unsigned int mod) { std::mt19937 rng(time(nullptr)); std::string winningNumbers; { const size_t numLen = std::to_string(mod - 1).size(); winningNumbers.reserve(size * (numLen + 1)); }
co_yield winningNumbers += std::to_string(rng() % mod);
for (size_t i = 1; i < size; ++i) { winningNumbers += " "; co_yield winningNumbers += std::to_string(rng() % mod); } };
int main() { using namespace std; auto fi = lottery(10, 256); for (int i = 0; i < 10; ++i) { fi.next(); cout << fi.result() << endl; } return 0; }
|
Output:
1 2 3 4 5 6 7 8 9 10
| 253 253 148 253 148 56 253 148 56 2 253 148 56 2 179 253 148 56 2 179 41 253 148 56 2 179 41 96 253 148 56 2 179 41 96 10 253 148 56 2 179 41 96 10 125 253 148 56 2 179 41 96 10 125 123
|