写这篇文章是最近在学习c++20的内容,发现coroutine和我预想的不太一样,记录一下。
1.协程是什么
首先我们需要回答一个问题,协程是什么?
协程(协同例程)是一种计算机程序的组件,它协作式的执行子例程,允许被挂起和恢复。通常被在用在协作式多任务,事件循环,管道等场景中。
协程并不是某一天突发奇想出来的,而是程序天然就需要可以运行“程序的子程序”的功能,于是便有人提出了可以这样进行控制抽象,我们需要一种组件,它可以做到:
- 子例程在局部数据的连续调用之间保持值
- 子例程的执行在控制离开它时暂停,直到稍后控制重新进入协程,执行才从暂停处继续。
于是便把这一类的组件称为协程。
我们考虑一个简单的场景,有一个生成器,它会一直不停的生成数据,我们同时需要对生成的数据进行一定的处理,下面是一个简单的示例代码:
// 生成器,不停的生成数据
void gen() {
while(1) {
Data data = genData();
}
}
// 处理器,不停的去处理数据
void handle(Data data) {
doSomeThing();
}
对于这样的场景,如果我们需要生成一个数据,处理一个数据,如果在单线程的场景中,一般是怎么做呢,通常是将处理器函数作为参数传入,例如这样:
// 生成器,不停的生成数据
void gen(Handle handle) {
while(1) {
Data data = genData();
handle(data);
}
}
这样的控制流程我们通常叫做控制反转,它存在一些问题:
- 灵活性不够好。与他无关的需求改动,会导致它也进行修改。例如需要每生成n次数据,记录改数据,该功能实际是一个额外的功能,他和生成器、处理器无关,但如果你要增加这个功能,要不该处理器,要不该生成器。会导致程序的耦合性提高。
- 控制权不够好。如果使用协程,可以由调用方控制它何时获取第一个值。但函数传递时,由生成器决定生成的节奏。
- 延迟计算不支持。使用协程,它可以在需要的时候生成一个数据,但是使用函数入参,就会一直执行生成,处理的流程。
而在协程中,我们可以将生成数据作为一个单独的子例程,然后通过暂停,恢复等功能,控制数据的生成的频率,并且解耦掉生成数据和处理数据,接下来,看一下协程版本的生成器处理是如何做的:
// 为了简化,下面的代码实际是伪代码。
// 生成器,不停的生成数据
Generator<Data> gen() {
while(1) {
Data data = genData();
co_yield data;
}
}
// 处理器,不停的去处理数据
void handle(Data data) {
doSomeThing();
}
int main()
{
auto co_gen = gen();
while(1) {
auto data =co_gen.resume();
handle(data);
}
}
这里首先创建一个协程,协程创建后处于暂停的状态,随后通过resume来恢复gen函数的执行,一直到co_yield时将data返回,随后协程被暂停直到下一次resume。下面是顺序流程(左),协程(右)的流程图:
既然协程的优点这么多,为什么一直到最近几年才开始流行起来呢。大概是如下几个原因:
- 编写的复杂高,传统控制流程是从上到下,人很好理解,也好编写。手动控制状态(暂停,恢复)编写难度高。
- 在cpu密集型的程序中,因为没有那么多需要等待的事件,很难有效利用多核cpu。
- 编程语言的支持不成熟,以前的主流语言,比如c、c++、java没有协程的原生支持。
可以说多线程很好的利用了CPU,再加上语言/操作系统本身支持,所以很好的替代了协程,下面我们编写一个多线程的生成器。
queue<Data> data_queue;
mutex mtx;
condition_variable cv;
// 生成器,不停的生成数据
void gen() {
while(1) {
unique_lock<mutex> lock(mtx);
data_queue.push(genData()); // 将数据放入队列
cv.notify_one(); // 通知消费者
}
}
// 处理器,不停的去处理数据
void handle() {
while(1) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !data_queue.empty() });
Data data = data_queue.front();
data_queue.pop();
lock.unlock();
doSomeThing(data);
}
}
int main() {
jthread genThread(gen);
jthread handleThread(handle);
}
可以看到,多线程代码在编写时也不是那么简单,需要考虑临界区,线程安全的问题。但由于底层支撑比较好,加上理解起来比协程简单一些,有效利用多个CPU,所以使用更广泛。
一直到近些年,随着IO密集型程序越来越多,在web服务和网络编程中,异步IO变得越来越重要,同时多线程的问题也开始出现:
- 线程调度的成本太高。线程的创建、切换、销毁,都会触发异常,从用户态切换到系统态,中间涉及到大量的状态保存、系统调用、缓存刷新等,这会浪费大量的cpu资源。对于IO密集型程序,线程上的子例程会频繁触发IO阻塞,这会导致线程频繁调度,上下文切换。
- 数据竞争问题。多线程的共享内存模型会导致频繁的进行锁竞争和竞态条件,增加了程序的复杂度,维护成本上升。
于是,为了解决IO密集型程序,协程这个工具又重新发光发热,再加上现代的协程底层设施逐渐齐全,又开始逐渐被大家使用起来了。
2.c++中的协程
为什么我开头会说,c++的协程有些不一样呢。那是因为c++的协程更像是构建协程库的应用,它为你提供了基础的组件和定义,方便库开发者去做协程开发,而不是给用户去使用。
下面是c++中协程的基本定义和类型说明:
协程是可以暂停执行以便稍后恢复的函数。协程是无堆栈的:它们通过返回给调用方来暂停执行,并且恢复执行所需的数据与堆栈分开存储。
这句话总结成一句话,c++的协程是无堆栈的、非对称的。 这里有必要解释这两个词。
- 无堆栈:它在协程的执行过程中,不能跨越函数调用堆栈的层级进行暂停。当协程暂停时,它的状态仅限于它所在的调用栈的顶层,无法在调用的子函数或嵌套函数中暂停。也就是说,它只能在协程的顶层函数中执行
yield
(暂停),无法在嵌套的调用中暂停。 - 非对称:它意味着协程暂停时将控制移交给调用者,它和调用者之间是上下级的关系。
c++中的协程包含了三种控制,分别是:
- co_await 表达式 — 暂停执行直到恢复
task<> tcp_echo_server()
{
char data[1024];
while (true)
{
std::size_t n = co_await socket.async_read_some(buffer(data));
co_await async_write(socket, buffer(data, n));
}
}
- co_yield表达式 — 暂停执行,返回一个值
generator<unsigned int> iota(unsigned int n = 0)
{
while (true)
co_yield n++;
}
- co_return 语句 — 完成执行,返回一个值
lazy<int> f()
{
co_return 7;
}
也就是说,只要至少包含了一个协程的控制,并且类型是满足协程约束的,就是一个合法的协程。
至少要有三个协程控制中的一个,函数才会被视为协程
需要注意的是,c++的协程不能使用可变参数、普通 return 语句或占位符返回类型(auto 或 Concept)。consteval 函数、constexpr 函数、构造函数、析构函数和 main 函数不能是协程。
下面是c++协程的状态机:
接下来,我们来讲解c++的协程该怎么写。不过首先必须了解c++协程中的核心的数据结构以及流程,不然的话不太容易理解。
2.1 c++协程核心数据结构
2.2.1 std::coroutine_handle
std::coroutine_handle,它是控制协程流程的handle,这里的流程包含了:
- static coroutine_handle std::coroutine_handle::from_promise(_Promise& _Prom),该函数是用来创建协程对象的函数, 它的参数是Promise的引用,这里的Promise会在2.2.2中说明。
- void resume() const,该函数用来恢复协程恢复协程的运行
- void destroy() const noexcept,该函数用来销毁协程。
- bool done() const noexcept,该函数用来检查协程是否运行结束。
上面的接口是开发协程时调用的
2.2.2 Promise
Promise,这里的Promise,准确来说是一个Concepts,它定义了如何从协程的内部操作,协程会通过它提交结果和异常。它的约束包括:
- coroutine get_return_object(),该接口用于返回协程对象,它会在你的协程(注意,不是协程对象,是协程)被创建时调用。
- std::suspend_always initial_suspend(),该接口用于初始化协程的suspend,它会在上面的接口之后调用。
- std::suspend_always final_suspend(),该接口用于结束协程的suspend,它会在co_return运行完后调用。
- void return_void(),该接口用于在co_return时被调用,注意,co_return运行完后,意味着Promise中的变量被销毁了。
- void unhandled_exception(),该接口用于处理未处理的异常。
上面的接口是需要你去定义出来的,不过不用担心,大部分的写法都是固定的,复制粘贴着用就可。
上面的接口一定要过一遍,然后再继续去看下面的示例。
2.2 co_yeild示例-生成器
该生成器会生成[start,end)的值,每次resume时获取一个新的值,当返回std::nullopt时,代表协程结束。下面是该生成器的源码和流程图:
#include <iostream>
#include <coroutine>
#include<optional>
using namespace std;
class Generator
{
public:
struct promise_type;
using Handle = std::coroutine_handle<promise_type>;
struct promise_type
{
std::optional<uint64_t> value;
Generator get_return_object()
{
return Generator(Handle::from_promise(*this));
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void unhandled_exception() { throw; }
std::suspend_always yield_value(int64_t from)
{
value = from;
return {};
}
void return_void() {}
};
Generator(Handle h) : handle(h) {}
~Generator() { handle.destroy(); }
std::optional<uint64_t> resume() {
handle.promise().value = std::nullopt;
handle.resume();
return handle.promise().value;
}
private:
Handle handle;
};
Generator gen(int start,int end)
{
for (int i = start; i < end; i++) {
co_yield i;
}
co_return;
}
int main() {
auto g = gen(1, 10);
while (1) {
auto val = g.resume();
if (!val) {
break;
}
cout << val.value() << " ";
}
cout << "\n";
return 0;
}
在这个生成器中:
- 首先我们定义了一个协程例程gen(),它里面包含了co_yield和co_return两个流程控制,并返回一个协程对象
- 定义了协程对象Generator,这个类名可以自己起,只需要保证该类中的promise和它的那几个约束被定义即可。
- 定义了协程对象的接口resume(),用来给外部用户使用。这个resume只是我自己定义的,你甚至可以修改为a(),b(),c()。
2.3 co_await
待续...