虽然上节我们介绍了不少关于协程的特点,但是大家可能对协程还是不是很了解,没关系,这里我们再对其进行补充,详细讲解一下;
一、协程函数与普通函数的区别
这里我们再回归到问题:普通函数和协程在这方面的区别是什么?
- 普通函数是线程相关的,函数的状态和线程紧密相关!
- 但是协程的状态和和线程无关!
接下来我们对这方面进行解释说明:
假设当前我们有Foo这个函数,我们需要调用Foo这个函数,此时在对应的的线程的栈上会记录这个函数的状态(参数、局部变量等),也就是函数栈帧!
这里是通过移动函数的栈顶指针和栈底指针来实现的;
详细的大家可以参考我之前写的一篇博客:
如上图所示,此时我们调用FOO普通函数:
这里地址2和地址3分别对应我们的栈顶指针(低地址)和栈底指针!(高地址)
此时如果我们再调用Bar()函数:
- 这里地址3到地址2是给FOO函数调用使用的;
- 地址2到地址1是给Bar()函数调用使用的;
- 如果同时调用两个函数,此时栈顶指针指向地址1;
- 当bar()销毁的时候,此时栈顶指针从地址1回到地址2;
因此这里可以发现,函数栈帧中存放的函数的状态完全依赖于线程栈!
如果线程栈被销毁了,此时函数的状态也就被销毁掉了;
但是协程不一样,此时如果我们假设Bar()是一个协程:
此时,协程的状态信息是存放在堆上的!与线程的函数栈帧分开!
传递给协程的参数都会复制到状态当中,局部变量会直接再协程的状态中进行直接创建!
但是实际上,调用Bar()的时候,本质上还是一个函数调用,所以栈顶指针也会往下移动,在栈上给执行 Bar() 所需的状态分配空间,其中会有一个引用指向在堆上的状态,这样一来, Bar() 就可以像一个普通函数那样执行了,线程也可以访问到堆上的协程的状态。
如果协程需要暂停,那么当前执行的代码的位置就会记录到堆的状态当中!
此时栈上的执行状态会被直接销毁!栈顶指针移动到回收空间;
而在下一次恢复执行时,堆状态中记录的暂停位置会读取出来,从这个位置接着执行,从而实现一个可暂停和恢复的函数!
二、协程相比于线程函数的优点
协程的主要优点体现于:其可以优化异步逻辑的代码,与进程相比,尤其是在多进程方面,使得代码的逻辑更简单!
接下来我们举一个具体的例子:假设我们有一个组件叫 IntReader ,它的功能是从一个访问速度很慢的设备上读取一个整数值,因此它提供的接口是异步的,如下所示:
class IntReader {
public:
void BeginRead() {
std::thread thread([]() {
std::srand(static_cast<unsigned int>(std::time(nullptr)));
int value = std::rand();
});
thread.detach();
}
};
这里BeginRead相当于启动1个显得线程用来读取一个随机数;
关于异步的线程接口的使用可以参考我的上一篇博客:
C++20新特新——01协程的入门及一些语法知识-CSDN博客
这里相当于BeginRead为主线程,然后新启动一个线程生成一个随机数,然后主线程和子线程实现线程分离;
- 调用.join的时候此时主线程会进行同步阻塞;
- 调用.detch的时候此时主线程和子线程会进行异步分离;
问题:如果我想要获取IntReader的结果,我应该怎么实现?
即此时问题就是一个线程要获取另一个线程的返回值,此时有两种解决方法:回调函数和async;
使用async解决问题
在上一篇博客当中,我提到过async与thread相比,可以获取到线程的返回值!
所以这里我们将上面的代码进行修改:
#include <future>
#include <cstdlib>
#include <ctime>
class IntReader {
public:
std::future<int> BeginRead() {
// 使用 std::async 启动异步任务,返回 future<int>
return std::async(std::launch::async, []() {
// 生成随机数(需确保线程安全)
std::srand(static_cast<unsigned int>(std::time(nullptr)));
return std::rand();
});
}
};
int main() {
IntReader reader;
std::future<int> future = reader.BeginRead(); // 启动异步任务
// 执行其他操作...
int value = future.get(); // 阻塞等待结果
std::cout << "生成的随机数: " << value << std::endl;
return 0;
}
上面的这里我们返回的std::rand()实际上是一个int类型,然后这个int类型会隐式的转化为std::future类型进行返回;
而在主函数当中,这里我们定义了future变量,此时就会执行对应的异步代码;然后通过调用get函数会返回生成的结果;
使用回调函数解决问题
class IntReader {
public:
void BeginRead(const std::function<void(int)>& callback) {
std::thread thread([callback]() {
std::srand(static_cast<unsigned int>(std::time(nullptr)));
int value = std::rand();
callback(value);
});
thread.detach();
}
};
void PrintInt() {
IntReader reader;
reader.BeginRead([](int result) {
std::cout << result < std::endl;
});
}
这种方式本质就是:
- 当我们调用BeginRead函数的时候,向其中传入一个回调函数用来接收回收值;
- 在BeginRead中,这里我们将随机值传递到了回调函数当中;
- 在main函数,这里我们向其中传入回调函数,形参result用来接收value,然后函数体对其打印即可;
假如我们需要调用多个 IntReader ,把它们的结果加起来再输出,那么基于回调的代码就会很难看了:
void PrintInt() {
IntReader reader1;
reader1.BeginRead([](int result1) {
int total = result1;
IntReader reader2;
reader2.BeginRead([total](int result2) {
total += result2;
IntReader reader3;
reader3.BeginRead([total](int result3) {
total += result3;
std::cout << total << std::endl;
});
});
});
}
需要注意的是:这里的代码逻辑实际上是一个线程执行完再执行下一个线程,不会出现同时并行执行的效果,是按照串行执行进行的;
但是这里的代码逻辑很乱,很难整理清楚;
但是如果我们使用协程就不一样了:
Task PrintInt() {
IntReader reader1;
int total = co_await reader1;
IntReader reader2;
total += co_await reader2;
IntReader reader3;
total += co_await reader3;
std::cout << total << std::endl;
}
这里每个等待体可以获取到对应的随机值,然后进行返回计算;
整体的逻辑清晰了不少;
三、如何实现一个完整的协程
在第一节,我们已经介绍了对应的协程体的等待体和返回值等,大家可以参考上节博客,这里我们只对协程进行一些补充;
1. 协程的返回类型和promise_type
- C++对协程的返回类型只有一个要求:包含名为 promise_type 的内嵌类型。
- 跟上文介绍的 等待体一样, promise_type 需要符合C++规定的协议规范,也就是要定义几个特定的函数。
- promise_type 是协程的一部分,当协程被调用,在堆上为其状态分配空间的时候,同时也会在其中创建一个对应的 promise_type 对象。
- 通过在它上面定义的函数,我们可以与协程进行数据交互,以及控制协程的行为。
2. 协程的返回值和co_return
协程的返回值取决于我们的需求!例如上面我们所示的例子中,这里PrintInfo函数只是与上面的函数体内进行交互,而不需要返回实际的值给调用者当中;
普通的线程函数可以通过回调函数或者通过异步接口std:async这样获取到返回值,那么协程如何获取到返回值呢?
这里协程中提供了一个co_return的关键字,例如下面假如我们不是打印对应的消息,而是要获取对应的信息GetInfo,那么此时我们可以对该协程函数进行修改:
Task GetInt() {
IntReader reader1;
int total = co_await reader1;
IntReader reader2;
total += co_await reader2;
IntReader reader3;
total += co_await reader3;
co_return total;
}
这里的co_return total 这个表达式等价于 promise_type.return_value(total) ,也就是说,返回的数据会通过 return_value() 函数传递给 promise_type 对象, promise_type 要实现这个函数才能接收到数据;
这里我们要区分co_return的本质:实际上是上total的值设置到了promise_type的对象当中!而不是类似普通的线程函数中的return那种;
这里的total的值是返回到了promise_type当中,所以对应的协程的返回值如果想要从Task当中获取到promise_type当中的value,这里我们可以让Task和promise_type两者共享一份数据!
例如下面所示的协程代码例子:
#define _CRT_SECURE_NO_WARNINGS 1
#include <coroutine>
#include <iostream>
#include <thread>
// 定义一个等待体
class IntReader {
public:
// 协程挂起
bool await_ready() {
return false;
}
void await_suspend(std::coroutine_handle<> handle) {
// 挂起后创建一个子线程,将随机数赋值给value_
std::thread thread([this, handle]() {
std::srand(static_cast<unsigned int>(std::time(nullptr)));
value_ = std::rand();
handle.resume();
});
// 父线程和子线程异步
thread.detach();
}
int await_resume() {
return value_;
}
private:
int value_{};
};
class Task {
public:
class promise_type {
public:
// 由于promise_type的构造函数调用了
promise_type() : value_(std::make_shared<int>()) {
}
Task get_return_object() {
return Task{ value_ };
}
// co_return 实际上调用了该函数
// 将传入的参数value保存到value_当中
void return_value(int value) {
*value_ = value;
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
private:
std::shared_ptr<int> value_;
};
public:
// 初始化的时候需要传入共享指针
Task(const std::shared_ptr<int>& value) : value_(value) {
}
int GetValue() const {
return *value_;
}
private:
// 通过共享指针管理value
std::shared_ptr<int> value_;
};
Task GetInt() {
IntReader reader1;
int total = co_await reader1;
IntReader reader2;
total += co_await reader2;
IntReader reader3;
total += co_await reader3;
co_return total;
}
int main() {
auto task = GetInt();
std::string line;
while (std::cin >> line) {
std::cout << task.GetValue() << std::endl;
}
return 0;
}
问题:这里是如何实现Task和promise_type共享同一个变量value的?
首先我们看这里的promise_type的构造函数:
// 由于promise_type的构造函数调用了
promise_type() : value_(std::make_shared<int>()) {
}
这里我们是让primise_type的value_指向0所示的共享指针,从而进行初始化;
而当进行返回一个返回值的时候,此时会用value_构造一个Task,这里需要注意的是:因为该成员函数发生在promise_type的内部,所以此时value_采用的是promise_type的value!
Task get_return_object() {
return Task{ value_ }; // 将 promise_type 的 value_ 传递给 Task
}
所以此时Task会用这个value_对Task里面value_进行初始化!
接下来这里我们再看返回值,当我们调用co_return的时候:
此时会调用下面的函数:
void return_value(int value) {
*value_ = value; // 将值写入共享指针指向的内存
}
这里是将形参value写入到value_当中,但是我们需要注意的是:
- 这个函数发生在promise_type内部,但是由于此时promise_type和Task共享同一个value_;
- 所以此时对promise_type里面的value_发生改变,那么Task里面的value_也会发生改变;
所以如果我们想要获取到对应的value,只需要在Task里面定义一个接口即可:
int GetValue() const {
return *value_;
}
此时即可获取到对应的value的值!
注意点:
- 跟普通的 return 一样, co_return 也可以不带任何参数,这时候协程以不带数据的方式返回,相当于调用了 promise_type.return_void() , promise_type 需要定义这个函数以支持不带数据的返回;
- 如果我们在协程结束的时候没有调用任何 co_return ,那么编译器会隐式地加上一个不带参数的 co_return 调用;
这里我们再重点提醒一下,co_return和传统的return不一样,相当于将值存放到promise_type里面当中!
问题:除了上面所示的共享指针,还有没有其他的方法可以使Task获取到promise_type的成员变量?
其实有一个特别简单的方法,也就是通过协程句柄:coroutine_handle获取到对应的promise对象,例如我们可以对上面的代码进行修改:
#include <coroutine>
#include <iostream>
class Task {
public:
class promise_type {
public:
// 直接存储数据,而非共享指针
int value_ = 0;
// 返回 Task 对象时,传入协程句柄
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
// 协程完成后挂起,保持协程帧存活
std::suspend_always final_suspend() noexcept { return {}; }
// 其他必要接口
std::suspend_never initial_suspend() { return {}; }
void unhandled_exception() {}
void return_value(int value) { value_ = value; } // co_return 赋值
};
public:
// 保存协程句柄
explicit Task(std::coroutine_handle<promise_type> h) : coro_handle(h) {}
// 析构时销毁协程帧
~Task() {
if (coro_handle) coro_handle.destroy();
}
// 禁止拷贝,允许移动(避免重复销毁)
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;
Task(Task&& other) noexcept : coro_handle(other.coro_handle) {
other.coro_handle = nullptr;
}
// 通过协程句柄直接访问 promise_type 的数据
int GetValue() const {
return coro_handle.promise().value_;
}
private:
std::coroutine_handle<promise_type> coro_handle;
};
// 示例协程
Task MyCoroutine() {
co_return 42; // 调用 return_value(42)
}
int main() {
Task task = MyCoroutine();
std::cout << task.GetValue(); // 输出 42
}
在Task内部:
// 通过协程句柄直接访问 promise_type 的数据
int GetValue() const {
return coro_handle.promise().value_;
}
这里我们可以直接通过协程句柄获取到promise对象,然后再获取到对应的value的值;
这种方法理解更为简单;
3. 协程的关键字co_yield
问题:什么时候我们需要使用co_yield?
当协程调用了 co_return ,意味着协程结束了,就跟我们在普通函数中用 return 结束函数一样。这时候,与这个协程实例有关的内存都会被释放掉,它不能再执行了
但是如果需要在协程中多次返回数据而不结束协程的话,可以使用 co_yield 操作符!
co_yield 的作用是,返回一个数据,并且让协程暂停,然后等下一次机会恢复执行;
co_yield value 这个表达式等价于 co_await promise_type.yield_value(value) , co_yield 的参数会传递给 promise_type 的 yield_value() 函数,再把这个函数的返回值传给 co_await ;(这里该函数的返回值是一个等待体类型的!);
在这里就可以使用预定义的 std::supsend_never 或 std::suspend_always ,通常会使用后者来让协程每次调用 co_yield 的时候都暂停;
例如下面这个例子:
#include <coroutine>
#include <iostream>
#include <thread>
// 定义等待体
class IntReader {
public:
// 将协程挂起
bool await_ready() {
return false;
}
// 切换到另一个线程
void await_suspend(std::coroutine_handle<> handle) {
std::thread thread([this, handle]() {
// 定义不被销毁的静态变量
static int seed = 0;
value_ = ++seed;
handle.resume();
});
// 主线程和子线程异步
thread.detach();
}
int await_resume() {
return value_;
}
private:
// 这里实际上是调用统一的列表初始化
// 给value_一个默认值为0
int value_{};
};
class Task {
public:
class promise_type {
public:
Task get_return_object() {
return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
// 调用co_yield的时候,调用该函数;
// 返回值是一个等待体类型 --- 让传递返回值后总是挂起
std::suspend_always yield_value(int value) {
value_ = value;
return {};
}
void return_void() { }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
int GetValue() const {
return value_;
}
private:
int value_{};
};
public:
Task(std::coroutine_handle<promise_type> handle) : coroutine_handle_(handle) {
}
int GetValue() const {
return coroutine_handle_.promise().GetValue();
}
void Next() {
coroutine_handle_.resume();
}
private:
std::coroutine_handle<promise_type> coroutine_handle_;
};
Task GetInt() {
while (true) {
IntReader reader;
int value = co_await reader;
co_yield value;
}
}
int main() {
auto task = GetInt();
std::string line;
while (std::cin >> line) {
std::cout << task.GetValue() << std::endl;
task.Next();
}
return 0;
}
上面的代码相比于之前我们写的确实改动了不少,但是这里我们可以逐个进行分析:
- 整体的代码框架依然是定义一个等待体、一个协程的返回值、一个协程函数和我们对应的主函数;
这里我们先分析等待体:
// 切换到另一个线程
void await_suspend(std::coroutine_handle<> handle) {
std::thread thread([this, handle]() {
// 定义不被销毁的静态变量
static int seed = 0;
value_ = ++seed;
handle.resume();
});
// 主线程和子线程异步
thread.detach();
}
这里等待体从之前的生成随机数变为递增的整数;
除此之外,当我们调用co_yield的时候,协程保存到对应的数据后可能会挂起,所以我们在返回值Task提供协程的恢复函数:
void Next() {
coroutine_handle_.resume();
}
需要注意的是,由于恢复协程需要用到协程句柄,所以我们需要在Tsak里面声明一个协程句柄:
private:
std::coroutine_handle<promise_type> coroutine_handle_;
那么此时我们我们通过promise_type返回Task对象的时候,就需要向其传入一个协程句柄用来初始化;
问题:那么在promise_type的内部,我们怎么获取到Task的协程句柄呢?
实际上,promise_type作为连接协程内外的桥梁,这里其提供了一个静态的接口函数:
template <class _Promise>
struct coroutine_handle {
static coroutine_handle from_promise(_Promise& _Prom) noexcept {
...
}
}
这里我们向其传入一个promise_type等待体的对象,然后值是一个协程句柄;
所以在promise_type返回一个协程对象的时候,这时候我们就可以通过下面这种方式传入协程句柄:
Task get_return_object() {
return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
promise_type内可以通过该静态函数获取到协程体对象;
与此相对应的,Task内部也可以通过协程句柄.promise()获取到对应的promise_type对象!
int GetValue() const {
return coroutine_handle_.promise().GetValue();
}
问题:coroutine_handle有coroutine_handle<>和coroutine_handle<romise_type>,这两个有什么区别?
这里我们可以发现在我们定义等待体的时候:
void await_suspend(std::coroutine_handle<> handle) {
// 挂起后创建一个子线程,将随机数赋值给value_
std::thread thread([this, handle]() {
std::srand(static_cast<unsigned int>(std::time(nullptr)));
value_ = std::rand();
handle.resume();
});
// 父线程和子线程异步
thread.detach();
}
例如这里的await_suspend,这里我们传入的协程句柄是coroutine_handle<>类型!
而当我们在Task定义协程句柄的时候:
private:
std::coroutine_handle<promise_type> coroutine_handle_;
类型为:coroutine_handle<romise_type>!
它们的区别类似于指针 void* 和 promise_type* 的区别,前者是无类型的,后者是强类型的!
两种类型的协程句柄本质上是相同的东西,它们可以有相同的值,指向同一个协程实例,而且也都可以恢复协程执行。
但是这里需要注意的是只有强类型的 std::coroutine_handle<promise_type> 才能调用 from_promise() 获取到 promise_type 对象!
除此之外。这里我们还把协程函数改为无限循环的类型:
Task GetInt() {
while (true) {
IntReader reader;
int value = co_await reader;
co_yield value;
}
}
我们可以再看之前的使用co_return的协程函数:
Task GetInt() {
IntReader reader1;
int total = co_await reader1;
IntReader reader2;
total += co_await reader2;
IntReader reader3;
total += co_await reader3;
co_return total;
}
其实在协程中使用无限循环是很常见的,因为当我们调用co_yield的时候,此时返回值保存到value当中,并且协程会挂起!不会一直死循环执行!当我们恢复协程时,其执行完工作又继续挂起,和传统的死循环是不一样的!
四、协程的生命周期
在一开始调用协程的时候,C++会在堆上为协程的状态分配内存,这块内存必须在适当的时机来释放,否则就会造成内存泄漏。释放协程的内存有两种方式:自动释放和手动释放。
当协程结束的时候,如果我们不做任何干预,那么协程的内存就会被自动释放。调用了 co_return 语句之后,协程就会结束,下面两个协程是自动释放的例子:
Task GetInt() {
IntReader reader;
int value = co_await reader;
co_return value;
}
Task PrintInt() {
IntReader reader1;
int value = co_await reader;
std::cout << value << std::endl;
}
PrintInt() 没有出现 co_return 语句,编译器会在末尾隐式地加上 co_return !
自动释放的方式有时候并不是我们想要的,参考下面这个例子:
#include <coroutine>
#include <iostream>
#include <thread>
class Task {
public:
class promise_type {
public:
Task get_return_object() {
return Task{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
void return_value(int value) {
value_ = value;
}
int GetValue() const {
return value_;
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() {}
private:
int value_{};
};
public:
Task(std::coroutine_handle<promise_type> handle) : coroutine_handle_(handle) {
}
int GetValue() const {
return coroutine_handle_.promise().GetValue();
}
private:
std::coroutine_handle<promise_type> coroutine_handle_;
};
Task GetInt() {
co_return 1024;
}
int main() {
auto task = GetInt();
std::string line;
while (std::cin >> line) {
std::cout << task.GetValue() << std::endl;
}
return 0;
}
打印的结果如下所示:
会发现打印的是一些随机值!
造成这个现象的原因是,协程在返回1024之后就被自动释放了, promise_type 也跟着被一起释放了,此时在 Task 内部持有的协程句柄已经变成了野指针,指向一块已经被释放的内存。所以访问这个协程句柄的任何行为都会造成不确定的后果!
解决方法:
修改 promise_type 中 final_supsend() 函数的返回类型,从 std::suspend_never 改成 std::suspend_always ;协程在结束的时候,会调用 final_suspend() 来决定是否暂停,如果这个函数返回了要暂停,那么协程不会自动释放,此时协程句柄还是有效的,可以安全访问它内部的数据;
不过,这时候释放协程就变成我们的责任了,我们必须在适当的时机调用协程句柄上的 destroy() 函数来手动释放这个协程!
~Task() {
coroutine_handle_.destroy();
}
修改后此时我们再次运行我们的程序:
此时发现可以正常打印值!
五、协程的异常处理
协程的异常处理机制与普通函数有所不同,主要依赖于 promise_type 中的 unhandled_exception 方法!接下来我们对其进行解释说明:
#include <exception> // for std::current_exception
class Task {
public:
class promise_type {
public:
// 存储异常
std::exception_ptr exception_;
// 当协程抛出未捕获的异常时调用
void unhandled_exception() {
exception_ = std::current_exception(); // 捕获异常指针
}
// 其他必要方法(initial_suspend, final_suspend, get_return_object 等)
};
private:
std::coroutine_handle<promise_type> coro_;
};
promise_type 的 unhandled_exception() 函数会被调用,我们可以在这个函数里面做对应的异常处理!
而在我们的实际协程的代码框架中,我们可以采用下面的框架伪代码:
try {
co_await promise_type.initial_suspend();
//协程函数体的代码...
}
catch (...) {
promise_type.unhandled_exception();
}
co_await promise_type.final_suspend();
首先这里我们先执行:
co_await promise_type.initial_suspend();
- 看协程是立刻挂起,还是执行到对于的co_await再挂起;
- 接下来填写的是协程的主逻辑框架;
- 如果出现异常,此时会交给对应的promise_type.unhandle_exception进行处理!
- 最后在调用final_suspend()看协程结束后是否需要挂起;
调用了 unhandled_exception() 之后,协程就结束了,接下来会继续调用 final_suspend() ,与正常结束协程的流程一样;
C++规定 final_suspend() 必须定义成 noexcept ,也就是说它不允许抛出任何异常!
至此,我们对协程的学习就更进一步了;