协程是 C++ 中非常新的一项特性:在 C++20 中引入,目前的状态更适合作为构建库和框架的基础,而不是直接在应用程序代码中使用的功能。这是一个复杂的特性,包含许多细节,要完全解释清楚需要一整章的篇幅。
简而言之,协程是能够自行暂停和恢复的函数。它们不能被强制暂停 —— 协程会持续执行,直到主动自行挂起。它们用来实现所谓的协作式多任务处理,即多个执行流自愿地相互交出控制权,而不是用操作系统强制抢占。
本章介绍的每一种执行模式,以及更多其他模式,都可以使用协程来实现。然而,现在还很难断言这是否会成为 C++ 中协程的常见用法,因此我们还不能说“主动式协程”是否会成为一个模式。不过,协程的一种应用已经很有希望成为 C++ 中的新模式:协程生成器。当用复杂循环完成的某些计算重写为迭代器形式时,这种模式就派上用场了。假设有一个三维数组,并希望遍历其所有元素并对它们进行一些计算。使用循环很容易实现:
size_t*** a; // 3D 数组
for (size_t i = 0; i < N1; ++i) {
for (size_t j = 0; j < N2; ++j) {
for (size_t k = 0; k < N3; ++k) {
... do work with a[i][j][k] ...
}
}
}
但以这种方式编写可重用的代码很困难:如果需要自定义对每个数组元素执行的操作,就必须修改内层循环。如果有一个能够遍历整个三维数组的迭代器,事情就会容易得多。不幸的是,要实现这样的迭代器,必须将循环“翻转”:首先,递增 k 直到它达到 N3;然后,将 j 增加一,并回到递增 k 的操作,如此往复。结果是代码变得非常复杂,让许多开发者不得不靠手指计数来避免出现“差一”错误:
// Example 28
class Iterator {
const size_t N1, N2, N3;
size_t*** const a;
size_t i = 0, j = 0, k = 0;
bool done = false;
public:
Iterator(size_t*** a, size_t N1, size_t N2, size_t N3) :
N1(N1), N2(N2), N3(N3), a(a) {}
bool next(size_t& x) {
if (done) return false;
x = a[i][j][k];
if (++k == N3) {
k = 0;
if (++j == N2) {
j = 0;
if (++i == N1) return (done = true);
}
}
return true;
}
};
我们甚至走了一个捷径,为迭代器提供了一个非标准的接口:
// Example 28
Iterator it(a, N1, N2, N3);
size_t val;
while (it.next(val)) {
... val 是当前的数组元素 ...
}
如果想符合 STL 迭代器的接口规范,其实现会更加复杂。
像这样的问题 —— 一个复杂的函数(例如嵌套循环)必须在执行中途暂停,以便调用者可以执行一些任意代码后再恢复该函数 —— 正是协程的理想应用场景。事实上,一个能生成与上述迭代器相同序列的协程,看起来非常简洁自然:
// Example 28
generator<size_t>
coro(size_t*** a, size_t N1, size_t N2, size_t N3) {
for (size_t i = 0; i < N1; ++i) {
for (size_t j = 0; j < N2; ++j) {
for (size_t k = 0; k < N3; ++k) {
co_yield a[i][j][k];
}
}
}
}
有一个函数,它接收遍历三维数组所需的参数,使用一个常规的嵌套循环,并对每个元素执行某些操作。关键在于最内层执行“某些操作”的那行代码:C++20 关键字 co_yield 会暂停协程,并将值 a[i][j][k] 返回给调用者。它与 return 操作符非常相似,但 co_yield 不会永久退出协程:调用者可以恢复协程的执行,程序将继续从 co_yield 之后的下一行代码开始运行。
这个协程的使用也非常简单:
// Example 28
auto gen = coro(a, N1, N2, N3);
while (true) {
const size_t val = gen();
if (!gen) break;
... val 是当前的数组元素 ...
}
协程的魔力发生在协程返回的 generator 对象内部。其内部实现远非简单,如果想自己编写一个,就必须成为 C++ 协程方面的专家。可以在示例28中找到一个非常精简的实现,在协程相关参考资料的帮助下,可以逐行理解其内部工作原理。如果只想编写前面展示的这类代码,实际上并不需要深入学习协程的细节:已经有多个开源库提供了诸如 generator 这样的实用类型(接口可能略有不同),并且在 C++23 中,std::generator 将添加到标准库中。
虽然,编写带有循环和 co_yield 的协程,显然比编写复杂且逻辑反转的迭代器循环要容易得多,但这种便利性需要付出什么代价呢?显然,需要自己编写一个生成器,或者从库中找到一个。但当完成这一步,协程是否还有其他缺点?协程涉及的工作量比普通函数更多,但最终代码的性能在很大程度上取决于编译器,并且可能因代码中看似微不足道的改动而产生巨大差异(这与编译器优化的情况类似)。协程仍然相当新,编译器尚未针对它们提供全面的优化。话虽如此,协程的性能仍可能与手工编写的迭代器相媲美。对于我们的示例28,当前(撰写本文时)Clang 17 的发布版本给出了以下结果:
Iterator time: 9.20286e-10 s/iteration
Generator time: 6.39555e-10 s/iteration
另一方面,GCC 13 则更青睐迭代器:
Iterator time: 6.46543e-10 s/iteration
Generator time: 1.99748e-09 s/iteration
我们可以期待编译器在未来对协程的优化会越来越好。
协程生成器的另一种变体在以下场景中非常有用:想要生成的值序列在事先没有限定范围,并且希望仅在需要时才生成新元素(即惰性生成器)。同样,协程的优势在于可以从循环内部简单地将结果返回给调用者。
以下是一个作为协程实现的简单随机数生成器:
// Example 29
generator<size_t> coro(size_t i) {
while (true) {
constexpr size_t m = 1234567890, k = 987654321;
for (size_t j = 0; j != 11; ++j) {
if (1) i = (i + k) % m; else ++i;
}
co_yield i;
}
}
这个协程永远不会结束:会自行挂起以返回下一个伪随机数 i,每次恢复时,执行流程都会跳回无限循环中。同样,generator 是一个相当复杂的对象,包含大量样板代码,最好直接从某个库中获取现成的实现(或者等到 C++23 标准发布)。但当解决了这个问题,生成器的使用就非常简单了:
// Example 29
auto gen = coro(42);
size_t random_number = gen();
每次调用 gen() 时,都会得到一个新的随机数(由于我们实现的是最古老、最简单的伪随机数生成器,其质量相当差,因此请仅将此示例视为演示用途)。可以根据需要调用生成器任意多次;当生成器最终销毁时,协程也随之销毁。
在未来的几年里,我们很可能会看到更多利用协程优势的设计模式涌现出来。目前,生成器是唯一一个已经确立的模式,而且也是最近才确立的。因此,以这个我们模式工具箱中的最新成员,作为本书最后一章关于 C++ 设计模式的结尾,再合适不过了。