欢迎光临
我们一直在努力

cpp20协程学习

cpp20协程学习

    • 协程介绍
    • 为什么要有协程
    • 协程讲解
    • 协程帧存储在哪里
    • `co_await`介绍
    • 协程的实例程序

协程介绍

  • 先来看cppreference对协程的介绍. 从概念可以看出协程本质上还是函数, 只不过它可以进行暂停执行和恢复, 我们编写的普通函数通常是线性执行的, 一旦开始执行就无法停止, 直到执行完成
  • 协程是分为有栈协程和无栈协程的, cpp20是无栈协程, 无栈协程不会保存完整的调用栈, 只保存必要的信息, 而有栈协程会保存完整的调用栈, 简单来说区别就是无栈协程内部不能通过暂停非协程函数来暂停本协程, 而有栈协程可以通过暂停任意函数来暂停本协程(详细的部分后面介绍) 在这里插入图片描述

为什么要有协程

  • 看到协程, 你是否想到了进程和线程? 让我们回顾一下进程和线程的概念
    • 进程: 是操作系统用来分配资源的基本单位, 在linux下创建进程时, OS会做很多事情, 包括为进程分配独立的内存, 分配文件句柄, 创建pcb等
    • 线程: 是操作系统进行CPU调度的最小单位, linux下没有真正的线程, 只有轻量级进程, 它是进程的一个执行流, 一个进程可以创建多个线程, 这些线程共享进程的大部分资源, 但是每个线程会有自己的栈, 寄存器等
  • 回顾了进程和线程的概念之后, 可以看出进程和线程的创建还是要花费比较大的代价的, 由于OS的调度存在, 在多进程/线程程序中, 是存在进程/线程切换的, 为了保证进程/线程上下文的完整, 进程/线程的信息必然要保存起来, 等待合适的时机在恢复上下文
  • 切换上下文的过程(以线程为例):
    • 可能线程的时间片到了或者有高优先级的任务到来
    • 进入内核态
    • 保存当前线程的上下文
    • 调度器在就绪队列里选择合适的线程执行对应的任务
    • 恢复次线程的上下文
    • 返回用户态开始执行
  • 进程切换上下文还涉及到切换地址空间和页表等操作, 花费的代价更大
  • 再次回顾协程的概念, 它可以暂停且无栈, 我们知道函数调用是通过栈帧的, 调用函数开始保存调用栈, 函数执行完成删除调用栈, 一旦开始执行就会无法暂停, 而协程不同, 无栈就不需要保存整个调用栈, 而是把函数编译成状态机, 只保存必要状态, 假如一个函数内的逻辑是需要等待读取数据, 协程就可以在用户态保存执行状态, 等待数据时主动让出控制权, 等待数据就绪后再恢复执行, 这样可以避免线程阻塞和线程切换带来的开销, 这也是协程出现的原因.

协程讲解

  • 如何定义一个协程
    • 根据cppreference 的解释, 如果一个函数的定义包含co_await, co_yoeld, co_return任意一项, 就为协程
    • co_await: 跟一个表达式, 用于暂停执行直到恢复
    • co_yoeld: 跟一个表达式, 用于暂停执行并返回一个值
    • co_return:跟一个语句, 用于结束执行并返回一个值
  • 协程的限制
    • 协程不能使用可变参数、普通的return语句或占位符返回类型(auto或Concept), Consteval 函数、constexpr 函数、构造函数、析构函数和main 函数不能是协程。
  • 协程的执行流程在这里插入图片描述
  • 从上图我们可以得出一个协程必须要有三样东西, promise对象, 协程句柄, 协程句柄(我习惯叫他协程帧), 下面我们通过一段代码认识一下

#include <coroutine>
struct Task
{
struct promise_type
{
int value{};
Task get_return_object() { return Task{ std::coroutine_handle<promise_type>::from_promise(*this) }; }
auto initial_suspend() { return std::suspend_always{}; }
auto final_suspend() noexcept { return std::suspend_always{}; }
auto yield_value(int x) { value = x; return std::suspend_always{}; }
//void return_void() {}
void return_value(int x) { value = x; }
void unhandled_exception(){}
};
std::coroutine_handle<promise_type> _h;
explicit Task(auto h) : _h(h) {}
Task(Task&& other) noexcept : _h(other._h) { other._h = nullptr; }
Task(const Task&) = delete;
~Task()
{
if (_h)
_h.destroy();
}
int val(){ _h.resume();return _h.promise().value;}
};

  • 使用cpp20提供的协程需要包含头文件<coroutine>, 在上面的代码中, Task是协程的返回对象, 他可以代表一个正在运行/挂起的协程, 并允许外界与之交互, promise_type就是每个协程要关联的promise对象, 它的成员函数与协程的状态和外界交互有关, std::coroutine_handle<promise_type>是协程句柄, 协程帧是编译器创建的, 我们只分析它的创建和销毁流程
  • promise对象的成员函数, 这些函数名都是固定的, 缺少某个编译器会报错

在这里插入图片描述

  • 接下来解释一下图里的挂起是什么意思, 当调用普通函数时, 这个函数就会即刻执行, 但是协程不一样, auto t = get(5)是否会立即执行取决于promise_type的成员函数initial_suspend的返回值, 如果返回std::suspend_always, 协程就会挂起, 等待下一个恢复点才执行函数, 如果返回std::suspend_never就会和普通函数一样立即执行, 是否执行到结束取决于final_suspend函数的返回值, 是否执行同上.

Task get(int x)
{
co_yield i;
co_return 10;
}
int main()
{
auto t = get(5);
std::cout << t.val() << "\\n";
}

  • 协程帧的创建
    • 首先使用operator new分配协程帧对象(这里如果自定义了operator new就不会使用全局的operator new)
    • 把所有参数按值复制到协程帧, 会保留引用参数的引用限定符(需要注意此参数的生命周期必须大于等于协程的生命周期, 否则会造成悬垂引用的问题)
    • 调用promise对象的构造函数
    • 调用promise.get_return_object()并将结果保存在局部变量中. 当协程首次暂停时, 该调用的结果将返回给调用者, 也就是返回协程的返回对象给调用者
    • 根据initial_suspend函数的返回决定是否立即执行调用
  • 接下来我们来梳理这个函数的执行流程
    • 调用协程, 这里会执行上面我们描述的步骤 在这里插入图片描述
    • 执行t.val()的过程在这里插入图片描述
    • 接下来进入协程的销毁阶段 在这里插入图片描述
  • 无论是返回std::suspend_always还是std::suspend_never, 都需要和逻辑对应, 否则会出现未定义行为, 读者可以自行更改promise对象的成员函数的返回值, 看看哪些情况会出现未定义行为

协程帧存储在哪里

  • 进过前面的介绍, 我们知道协程是无栈的, 那自然不会存储在栈上, 我问了ai的回答是存储在堆上, 下面我们通过编译器来验证一下
  • 协程句柄提供了一个函数address, 用来获取协程帧的地址, 我们把获取到的地址和堆上变量的额地址比较一下, 还是上面的程序, 在Task的析构函数开始加一句

std::cout << "coroutine frame address is :" << _h.address() << std::endl;

在cpp中, new得到的变量是存储在堆上的, 在main函数内加

int* ptr = new int(10);std::cout << "ptr address is: " << ptr << "\\n";

  • 输出 在这里插入图片描述
  • 可以看到地址非常相似, 读者可以自行测试其他存储区的变量的地址进行比较

co_await介绍

  • 前面并没有介绍co_await, 接下来我们来认识一下这个cpp20的关键字, 我们前面的co_yield和co_return实际上是被co_await转换成函数调用的 在这里插入图片描述
  • expr要求是一个Awaitable对象, 可以通过两种方式实现Awaitable类型
    • 包含bool await_ready, void await_suspend, auto await_resume三个函数
    • 重载co_await, 即包含auto operator co_await()函数
  • co_yield x的实际调用
    • 会被转换成co_await 协程句柄.promise().yield_value(x), co_return也是同理
  • std::suspend_always和std::suspend_never也属于Awaitable类型, 这是msvc的实现在这里插入图片描述

协程的实例程序

  • cpp20只提供了基本实现, 缺少可以直接使用的库, 下面这个程序可以用来生成指定的序列, 在cpp23中提供了这个类型std::generator

#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>

// 一个简单的 Generator 类型
template<typename T>
struct Generator {
// 协程的 promise 类型
struct promise_type {
T current_value; // 当前生成的值
std::exception_ptr exception_; // 用于存储异常

// 初始挂起点:我们希望生成器在创建时立即执行,或者挂起等待第一次迭代?
// 这里选择挂起,以便通过 begin() 启动协程。
auto initial_suspend() { return std::suspend_always{}; }

// 最终挂起点:协程结束后挂起,避免析构时重复销毁
auto final_suspend() noexcept { return std::suspend_always{}; }

// 生成一个值:yield_value 返回一个 awaiter,通常挂起协程,让外部消费值
auto yield_value(T value) {
current_value = value;
return std::suspend_always{}; // 挂起,等待下一次迭代
}

// 返回值:生成器不会通过 co_return 返回值,所以 return_void 即可
void return_void() {}

// 异常处理
void unhandled_exception() { exception_ = std::current_exception(); }

// 由协程框架调用,获得 Generator 对象
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
};

// 使用 coroutine_handle 管理协程
std::coroutine_handle<promise_type> coro_;

explicit Generator(std::coroutine_handle<promise_type> h) : coro_(h) {}

// 析构时销毁协程(如果未完成)
~Generator() {
if (coro_) coro_.destroy();
}

// 禁止拷贝,允许移动
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;

Generator(Generator&& other) noexcept : coro_(other.coro_) {
other.coro_ = nullptr;
}

Generator& operator=(Generator&& other) noexcept {
if (this != &other) {
if (coro_) coro_.destroy();
coro_ = other.coro_;
other.coro_ = nullptr;
}
return *this;
}

// 迭代器基本实现
struct iterator {
std::coroutine_handle<promise_type> coro_;

iterator(std::coroutine_handle<promise_type> h = nullptr) : coro_(h) {}

// 前置 ++:恢复协程,执行到下一个 yield 或结束
iterator& operator++() {
coro_.resume();
if (coro_.done()) {
// 如果协程结束,检查是否有异常
if (coro_.promise().exception_) {
std::rethrow_exception(coro_.promise().exception_);
}
coro_ = nullptr; // 标记为结束
}
return *this;
}

// 解引用:返回当前生成的值
T operator*() const {
return coro_.promise().current_value;
}

bool operator==(const iterator& other) const {
return coro_ == other.coro_;
}

bool operator!=(const iterator& other) const {
return !(*this == other);
}
};

// begin() 返回迭代器,首次恢复协程到第一个 yield 处
iterator begin() {
if (coro_) {
coro_.resume();
if (coro_.done()) {
// 如果一开始就结束(例如空生成),返回 end()
if (coro_.promise().exception_) {
std::rethrow_exception(coro_.promise().exception_);
}
return end();
}
}
return iterator{coro_};
}

// end() 返回空迭代器
iterator end() { return iterator{}; }
};

// 生成器函数:接受上限 n,生成 0 到 n-1
Generator<int> generate(int n) {
for (int i = 0; i < n; ++i) {
co_yield i; // 每次产生一个值
}
// 函数结束隐式 co_return;
}

int main() {
auto gen = generate(5); // 生成 0..4
for (auto v : gen) {
std::cout << v << " ";
}
std::cout << "\\n";
return 0;
}

  • 不过cpp23提供的可以直接使用的协程也不是很多, 具体可以看一些第三方库的实现, 比如Boost.Asio, cppcoro, libco等

本篇文章对协程的介绍就到这里了, 想要对协程有一个更全面的认识, 还是多写程序, 多看官方文档. 觉得写的还不错的话, 欢迎点赞关注, 如果有写的不对的地方还请指出

赞(0)
未经允许不得转载:171主机测评 » cpp20协程学习
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址