c++协程

写这篇文章是最近在学习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;
}

在这个生成器中:

  1. 首先我们定义了一个协程例程gen(),它里面包含了co_yield和co_return两个流程控制,并返回一个协程对象
  2. 定义了协程对象Generator,这个类名可以自己起,只需要保证该类中的promise和它的那几个约束被定义即可。
  3. 定义了协程对象的接口resume(),用来给外部用户使用。这个resume只是我自己定义的,你甚至可以修改为a(),b(),c()。

2.3 co_await

待续...

C++协程是一种轻量级的线程,可以在单个线程中实现多个协程C++20引入了协程库,其中包括协程原语和一些实用程序,使协程的使用变得更加容易。下面是一个简单的C++协程实现demo: ```c++ #include <iostream> #include <coroutine> struct Generator { struct promise_type { int current_value; Generator get_return_object() { return Generator(std::coroutine_handle<promise_type>::from_promise(*this)); } auto initial_suspend() { return std::suspend_always{}; } auto final_suspend() noexcept { return std::suspend_always{}; } void unhandled_exception() {} void return_void() {} auto yield_value(int value) { current_value = value; return std::suspend_always{}; } }; std::coroutine_handle<promise_type> coroutine; Generator(std::coroutine_handle<promise_type> h) : coroutine(h) {} ~Generator() { if (coroutine) coroutine.destroy(); } int get() { coroutine.resume(); return coroutine.promise().current_value; } }; Generator counter(int begin, int end) { for (int i = begin; i <= end; ++i) co_yield i; } int main() { auto gen = counter(1, 5); while (auto value = gen.get()) std::cout << value << ' '; } ``` 这个demo中,我们定义了一个生成器(Generator)结构体和一个promise_type结构体。Generator结构体包含一个协程句柄,可以通过该句柄来操作协程。promise_type结构体定义了生成器的类型,并提供了返回对象,挂起,终止等方法。在counter函数中,我们使用co_yield关键字来挂起当前协程并返回值。在主函数中,我们使用while循环来不断调用协程的get方法来获取生成的值。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值