当前位置: 首页 > news >正文

C++20新特新——02特性的补充

        虽然上节我们介绍了不少关于协程的特点,但是大家可能对协程还是不是很了解,没关系,这里我们再对其进行补充,详细讲解一下;

一、协程函数与普通函数的区别

这里我们再回归到问题:普通函数和协程在这方面的区别是什么?

  • 普通函数是线程相关的,函数的状态和线程紧密相关!
  • 但是协程的状态和和线程无关!

接下来我们对这方面进行解释说明:

        假设当前我们有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:// 通过共享指针管理valuestd::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_一个默认值为0int 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_exceptionclass 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 ,也就是说它不允许抛出任何异常!

至此,我们对协程的学习就更进一步了;

相关文章:

  • 性能比拼: Redis Streams vs Pub/Sub
  • 解决使用lettuce连接Redis超时的问题(tcpUserTimeout 参数失效问题)
  • YOLOv1:开创实时目标检测新纪元
  • Wireshark抓账号密码
  • 普通笔记本与军用加固笔记本电脑的区别,探索防水、防爆、防摔的真·移动工作站!
  • 在线PDF阅读方案:jQuery + PDF.js
  • 内网和外网怎么互通?外网访问内网的几种简单方式
  • 解决HomeAssistant 无法安装 samba share问题
  • 【数据库原理及安全实验】实验六 角色访问控制
  • 《C++ Templates》:有关const、引用、指针的一些函数模板实参推导的例子
  • C#——NET Core 中实现汉字转拼音
  • Redis 8.0 正式版发布,新特性很强!
  • 品质领航家装时代,亚新丽以匠心雕琢每一寸美好
  • 5.6-DAE实现
  • AI大模型分类以及Prompt优化技巧
  • 迁移学习:如何加速模型训练和提高性能
  • Vue3 自定义指令的原理,以及应用
  • Make:独立创造者手册——从0到1的商业自由之路
  • GPAW安装流程——Ubuntu 系统(Python 3.8.10)
  • 宝塔面板,删除项目后还能通过域名进行访问
  • 人民财评:网售“婴儿高跟鞋”?不能让畸形审美侵蚀孩子身心
  • 第一集|《刑警的日子》很生活,《执法者们》有班味
  • 乌克兰议会批准美乌矿产协议
  • 马上评|比余华与史铁生的友情更动人的是什么
  • 吴勇强、高颜已任南京市委常委
  • 视频|漫画家寂地:古老丝路上的文化与交流留下的独特印记