C++20支持协程了,快来学学协程

Source

golang作为一种后台开发语言,可以直接支持协程且语法更为简单,C++20的特性也使得C++变得更为简单和强大。下面就来谈谈协程
多线程模型中内核实现线程与线程之间的调度,通常一个线程是无法从头到尾占用着 cpu 的,尤其是进行 i/o 操作时,许多的系统调用都是阻塞的,此时内核保存该线程的上下文,然后挂起该线程。当然更多时候是由于该线程的本次运行时间耗尽,只得被挂起等待 cpu 的下一次临幸。

但是多线程存在两个问题,在线程数量过多时,问题被放大的尤为明显

  • 线程的上下文切换造成的开销。
  • 线程之间对资源的竞争问题。

上下文切换

上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:

  • 挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处,
  • 在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复
  • 跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程。
    这里的切换有一个时间片的概念

时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,则 CPU 将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则 CPU 当即进行切换。而不会造成 CPU 资源浪费。在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但在微观上:由于只有一个 CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。

在函数调用的时候就已经确定该函数是否会造成阻塞

在 I/O 密集型运算中,尤其是高并发时。为了保证公平,时间片的分配会越来越小,切换越发频繁。资源也就被浪费在了上下文切换中

而 cpu 密集型运算中,不需要频繁的切换线程,所以多线程是一个不错的选择

协程

为了解决 I/O 密集型运算内核在资源调度上的缺陷,所以引入了协程(coroutine)的概念。协程也被称为用户态的线程,内核态的线程调度由内核来完成。而用户态的线程的调度则交给用户来完成,也就是应用程序,也就是我们自己。我们可以实现自己的调度算法。更重要的是,即使我们有成千上万的线程,也不用担心线程切换浪费的资源问题了。

上面我们看到要实现线程的调度的关键就是上下文状态的保存。php 中,我们通过 Generator 对象来实现程序的中断与恢复。Generator 对象在程序中断时会为我们保存中断前的现场。只要有这一点,我们的应用程序就可以自己实现协程了。这里 Generator 如何保存上下文环境,是否像线程切换一样浪费资源还需要近一步了解。

我们可以用协程实现一个支持高并发的 web 服务器,如图
在这里插入图片描述

我们将在单个进程中同时处理这些并发的请求,从 http 请求开始接手,一点一点推进,直到 response。图中的每一个线程其实就是我们所说的协程,我们要做的就是实现一个调度器,来分配上面每一个线程的运行时间。

C++中的协程

随着coroutine ts正式进入c++20,c++已经进入协程时代了。c++20提供的无栈协程,拥有许多无与伦比的优越性,比如说没有传染性,可以与以前非协程风格的代码并存,再比如说不需要额外的调度器,总之是个好东西。
但是不幸的是c++20的协程标准只包含编译器需要实现的底层功能,并没有包含简单方便地使用协程的高级库,相关的类和函数进入std标准库估计要等到c++23。所以,在c++20中,如果要使用协程,要么等别人封装好了给你用,要么就要自己学着用底层的功能自己封装。
c++的协程功能是给库的开发者使用的,所以看起来比较复杂,但是经过库的作者封装以后用起来是非常简单的,比如说asio里面就已经封装好了,相关用法看我前面的这篇文章。另外,c++的协程性能非常之高,其作者的视频介绍里面说了,(一个进程)可以开启几十亿个协程,可以说是无出其右了。
知乎上面的简单使用教程如下,大家可以简单了解一下协程在C++中的使用。
协程的出现主要是为了解决异步编程的麻烦,异步编程一般是这样的:
async_call(input1, intput2, …, call_back)
就是用一堆输入参数再加上一个回调函数作为参数,async_call函数调用后立即返回,当异步操作完成时,call_back函数会被调用。
在C++中,使用异步的场景主要有两种:

  • 需要等待的结果在其它进程中提供,比如调用操作系统的异步io读写文件、通过网络发送请求到其它进程等待处理以后结果依然通过网络返回,文件读写完成或是网络请求返回时调用指定的回调函数;
  • 需要等待的结果在本进程中提供,一般就是在别的线程中,将请求发送到别的线程,别的线程操作完成以后,调用指定的回调函数。
    不管是哪种情况,对于异步调用,都可以用协程改造成一个如下格式的“同步”调用:
    co_await coro_call(input1, input2, …)
    其实改造起来极为简单,只要在异步函数的回调函数中调用resume()恢复协程即可。对于1和2两种情况,第一种情况更简单,因为第二种可能会需要考虑同步的问题
    协程通过Promise和Awaitable接口的15个以上的函数来提供给程序员定制协程的流程和功能,实现最简单的协程需要用到其中的8个(5个Promise的函数和3个Awaitable的函数),其中Promise的函数可以先不管它,先来看Awaitable的3个函数。
    如果要实现形如co_await blabla;的协程调用格式, blabla就必须实现Awaitable。co_await是一个新的运算符。Awaitable主要有3个函数:
  • await_ready:返回Awaitable实例是否已经ready。协程开始会调用此函数,如果返回true,表示你想得到的结果已经得到了,协程不需要执行了。所以大部分情况这个函数的实现是要return false。
  • await_suspend:挂起awaitable。该函数会传入一个coroutine_handle类型的参数。这是一个由编译器生成的变量。在此函数中调用handle.resume(),就可以恢复协程。
  • await_resume:当协程重新运行时,会调用该函数。这个函数的返回值就是co_await运算符的返回值。
    既然这3个函数都搞清楚了,那我们只要自己实现这3个函数,就可以用co_await来等待结果了。co_await在协程中使用,但是协程的入口必须是在某个函数中,函数的返回值需要满足Promise的规范。最简单的Promise如下:
struct Task
{
    
      
    struct promise_type {
    
      
        auto get_return_object() {
    
       return Task{
    
      }; }
        auto initial_suspend() {
    
       return std::experimental::suspend_never{
    
      }; }
        auto final_suspend() {
    
       return std::experimental::suspend_never{
    
      }; }
        void unhandled_exception() {
    
       std::terminate(); }
        void return_void() {
    
      }
    };
};

下面来举个例子,在例子中其实这个Task除了作为函数返回值以外没其它作用。

以一个add函数为例:int add100(int a),就是将参数a加上100,假设这个add非常耗时,把它设计成一个异步的调用,异步调用的结果当然是通过回调函数进行通知,定义如下:

using call_back = std::function<void(int)>;
void Add100ByCallback(int init, call_back f) //init是传入的初始值,add之后的结果由回调函数f通知
{
    
      
    std::thread t([init, f]() {
    
      
        std::this_thread::sleep_for(std::chrono::seconds(5)); // sleep一下,假装很耗时
        f(init + 100); // 耗时的计算完成了,调用回调函数
    });
    t.detach();
}

这个函数的调用是大家都熟悉的异步函数调用,提供一个回调函数作为参数就可以了,比如直接将得到的结果输出:

Add100ByCallback(5, [](int value){
    
       std::cout<<"get result: "<<value<<"\n"; });

现在我们把通过回调函数的异步调用改成协程,协程和普通的回调函数不同,可以直接得到add之后的结果。普通的回调函数是不可以有返回值的。协程如下:

Task Add100ByCoroutine(int init, call_back f)
{
    
      
    int ret = co_await Add100AWaitable(init); //co_await可以有返回值
    f(ret);
}

一次co_await不能体现协程的优越性,可以连续调用几次,将多个异步调用转化成串行化的“同步”调用,像下面这样,立马有从原始社会进入到现代社会的感觉,作为一个长年写异步程序的码农,庆幸自己终于迎来了解放:

Task Add100ByCoroutine(int init, call_back f)
{
    
      
    int ret = co_await Add100AWaitable(init);
    ret = co_await Add100AWaitable(ret);
    ret = co_await Add100AWaitable(ret);
    f(ret);
}

根据前面提到的3个函数,来实现Add100AWaitable类型:

struct Add100AWaitable
{
    
      
    Add100AWaitable(int init):init_(init) {
    
      }
    bool await_ready() const {
    
       return false; }
    int await_resume() {
    
       return result_; }
    void await_suspend(std::experimental::coroutine_handle<> handle)
    {
    
      
        // 定义一个回调函数,在此函数中恢复协程
        auto f = [handle, this](int value) mutable {
    
      
            result_ = value;
            handle.resume(); // 这句是关键
        };
        Add100ByCallback(init_, f); 
    }
    int init_; // 将参数存在这里
    int result_; // 将返回值存在这里
};

启动一个协程也很简单,直接调用Add100ByCoroutine即可:

Add100ByCoroutine(10, [](int value){ std::cout<<“get result from coroutine: “<<value<<”\n”; });
按照这个思路,可以将任意异步函数采用Awaitable包装的方法改造成协程,比如说上面例子中的异步函数Add100ByCallback。

按照本文中的例子依葫芦画瓢,然后再结合网上那些似懂非懂的文档,各位c++er都可以入门了。但是c++协程的知识非常之多,如何在需要的时候提供15个函数中的其它函数来定制不同的流程、如何减少不必要的内存分配、如何避免不必要的加锁等等,可以说是高手发挥的舞台。
参考链接:
https://lewissbaker.github.io/
https://zhuanlan.zhihu.com/p/59178345
https://learnku.com/articles/6477/thread-and-co-process