C++20 coroutine 探索I:co_await 原理 | 使用 C++ 协程写 python generator

17 篇文章 11 订阅
12 篇文章 2 订阅

时隔三个月,才回到当时说的学协程的坑,中间学了各种各样的东西,起码对现代 C++ 有些许了解了。尾递归优化 快速排序优化 CPS 变换 call/cc setjmp/longjmp coroutine 协程 栈编程和控制流 讲解_我说我谁呢 --CSDN博客

看这个文章之前可以先复习一下函数式编程里面的 call/cc 的概念,因为 C++20 的 stackless coroutine 就是用类似 call/cc 的手法实现的。

主要参考资料参照 Coroutines (C++20) - cppreference.com 这个

为了学懂 coroutine 以及明白他的开销, 就不从 cppcoro 学协程了,而是从 c++ 20 的裸接口讲。


上面那篇讲了 call/cc 的笔记里面提到,协程是为了解决原来的并发编程里对于需要同步操作加锁来保证他 sequential 运行的而不是 parallel 产生 race condition。

协程和多线程编程的概念不是一个东西,协程和异步编程的概念则是不是一个概念。

多线程编程

 下面是多线程编程例子,如果需要控制事情发生的某些先后顺序,那就要用阻塞和同步才能实现的效果,当然这个 block 的灰色部分可以去执行一些别的事情:


多线程异步编程 I

已经熟悉 C++11 的 std::future 和 std::promise 的人应该很熟悉多线程的异步编程了。用这两个东西并且用上多线程的时候,就涉及到阻塞和同步的问题,如果一个 future 还没有运行完,那么要获取他的唯一办法就是阻塞,等待他运行完为止。如果刚好的话就更好了,比如这里 notify 实际也可以是 thread 1 的非阻塞 polling,如果没有结果我继续干别的事情。


多线程异步编程 II

还有一个异步编程的样子,是有回调函数的多线程异步,他的一个逻辑不需要在同一个地方做完,所以不需要回到 thread 1.

这个对回调函数则有一些限制,回调函数是没有返回值的,他只是负责完成一些逻辑,比如 boost 的 asio 用来进行网络编程。但是异步编程的问题是写起来很麻烦,这个在 boost 的 asio 的文档里面讲 proactor 模式和 reactor 模式的时候都讲到了:

It is more difficult to develop applications using asynchronous mechanisms due to the separation in time and space between operation initiation and completion. Applications may also be harder to debug due to the inverted flow of control.

The Proactor Design Pattern: Concurrency Without Threads - 1.77.0 (boost.org)

比如下面这个 web server 的 accept 的例子(boost asio demo httpd),我们都知道异步 io 写一个服务器如果接收到一个连接就要把他加入连接池塘里,然后接收下一个请求的写法:这里一个回调里面还要做接下来的循环 accept 的逻辑,但是不能写循环,而是通过 tail callback 的方法实现,颇有回调地狱的味道!

void server::do_accept(){
  acceptor_.async_accept(
      [this](boost::system::error_code ec, boost::asio::ip::tcp::socket socket) {
        // Check whether the server was stopped by a signal before this
        // completion handler had a chance to run.
        if (!acceptor_.is_open())
          return;
        if (!ec) 
          connection_manager_.start(std::make_shared<connection>(
              std::move(socket), connection_manager_, request_handler_));
        do_accept();
      });
}

协程

协程实际既可以单线程实现,也可以多线程实现,但是最重要的控制流的连续,重要的是并发,没有并行。前面讲的异步他都是 async 的意思,异步编程另外一个重要的关键字是 await,await 的好处就是可以同步地写异步程序。

要理解协程是什么,先从从 subroutine 来理解吧, 下面这个是一个 subroutine 的 callback 尾调用的控制流:

异步讲究的是一个回调,即回调必须发生在异步运行的事情的后面。回调就保证了这个单个线程上实现这个连续运行的效果。但是这种回调涉及了 3 个 subroutine,即你本来两个逻辑(蓝色和灰色)就应该用两个 routine 做的,但是必须要分层了 3 个 subroutine (即函数)来实现。

上面这个演示即 call/cc 这种回调风格,实际就是回调地狱,具体分析一下吧,这个写程序的时候要把一个逻辑分为两部分做,比如蓝色的下半部分,具体例子比如封到一个闭包(即一个带捕获的 lambda 表达式对象)里面,然后传给灰色部分,让他运行完他的东西之后调用我这个下半部分。

而协程的好处就是,在单线程的各个函数里增加一个 context switch 的语法糖,就能保住你原来只需要两个逻辑就能做的事情只用两个 coroutine 来实现,co 的意思就是通过协调来搞定同步的问题。对于上面这个的协程控制流是这样走的:

不过具体实现,可能还是要像回调地狱那样实现的(这个时候本来连函数的概念都没有,因为整个单核 CPU的并发本来就是单线程的进行一大堆 context switch 实现的,Program Counter 一直在前进和被修改而已,他并没有什么进程线程的概念),只能说这个实际是一个语法糖。 

其实看到这里已经对怎么实现一个协程有一点思想实验了吧,特别是结合 call/cc 的那个魔幻调用以及 current continuation (即一个闭包)的概念。


熟悉 python 的肯定熟悉 generator,这里就通过一个 打印版的 generator 的示例来讲解 C++ 20 的 coroutine 的 await 是怎么实现的。

C++20 Coroutine 简介与协程的 accept 写法

实际 C++ 这个东西是很生的,只能说是提供了一个语法糖的实现框架,如果要像 C# 或者 js kotlin 那种需要配备一系列的库函数,比如 cppcoro 正在做的,不过这个进标准得等下个大版本了吧。为什么说生,下面看就知道了。

首先是基本的关键字,这一篇为了简化,所以只简介 co_await 。await 实际做的就是挂起当前的协程(一个函数),然后去执行另一个协程(一个可以等待的函数),co_yield 则是返回一个东西并且挂起。

比如说 a 是一个协程,可能要运行很久,写程序的时候就直接:await a; 或者 auto b = await a; 比如上面的 asio 的 httpd 写法就能改成这样写,瞬间清爽多了吧!

void server::do_accept(){
  for(;;){
      auto [ec, socket] = co_await acceptor_.async_accept();
      if (!acceptor_.is_open())
         return;
      if (!ec) 
         connection_manager_.start(std::make_shared<connection>(
           std::move(socket), connection_manager_, request_handler_));
  }
}

下面就讲解这个 co_await 语法糖在 C++20 的编译器下要做些什么工作吧,顺便也能掌握编写这个协程的东西要做多么多的复杂工作。


用 C++20 写 generator

下面为了简化,我只写了个 print 的 generator,对于有返回值的 co_await 涉及的“复杂工作”更多,要解释的东西也更多了,下一篇再讲吧。

先看一下这个 generator 的使用样子,已经模仿了 python 了:

int main() {
  generator_counter count_from_24(24);
  for (int i = 0; i < 3; ++i) {
    count_from_24.next();
  }
  return 0;
}
// ----  结果是这些 :--
// 24
// 25
// 26

下面代码可以直接在 g++11 指定 -std=c++20 编译:

#include <concepts>
#include <coroutine>
#include <exception>
#include <iostream>

// 由于本文不讲解返回值,所以这个时候就当这些是一些 convention 写法吧。
struct task_t {
  struct promise_type {
    task_t get_return_object() { return {}; }
    std::suspend_never initial_suspend() { return {}; }
    std::suspend_never final_suspend() noexcept { return {}; }
    void unhandled_exception() {}
  };
};

struct awaitable {
  std::coroutine_handle<>* anchor;
  constexpr bool await_ready() const noexcept { return false; }
  void await_suspend(std::coroutine_handle<> h) {
    if (anchor) {
      *anchor = h;
      anchor = nullptr;
    }
  }
  constexpr void await_resume() const noexcept {}
};

task_t counter(std::coroutine_handle<>* continuation, int start) {
  awaitable a{continuation};
  for (unsigned i = start;; ++i) {
    co_await a;
    std::cout << i << std::endl;
  }
}

struct generator_counter {
  int start;
  std::coroutine_handle<> closure_func;
  generator_counter(int begin) : start{begin}, closure_func{} {
    counter(&closure_func, start);
  }
  void next(){
    closure_func();
  }
  ~generator_counter(){
    closure_func.destroy();
  }
};

int main() {
  generator_counter count_from_24(24);
  for (int i = 0; i < 3; ++i) {
    count_from_24.next();
  }
  return 0;
}

下面就来解释这个东西和 call/cc 的关系。


有点累了,先休息一下,留个坑。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值