百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

聊聊C++20最大的变革之一 —— Coroutine,看不懂你打我(一)

toqiye 2024-10-21 12:31 49 浏览 0 评论

昨天发了个微头条说扫了眼C++ Coroutine看的十脸懵逼,结果很多朋友点赞表示赞同。昨天晚上搜到了一篇斯坦福的老大哥写的文章,看完茅塞顿开。今天又瞄了几眼asio的awaitable实现,基本上搞明白这个东西了。完全写清楚篇幅比较长,我打算分成4部分来写难度逐渐递增:

  1. 一个简单的例子开始了解Coroutine
  2. 从co_await到co_yield的改进
  3. 一个用法全面(但代码臃肿)的Generator样例
  4. 用几行简单的代码基于Coroutine实现一个异步执行框架

这是全文的第一篇,我们首先来讲讲Coroutine诞生的意义并从一个简单的例子开始讲解。

为什么需要Coroutine

早年C++缺少很多现代编程语言必备的一些异步编程能力,而异步机制对于编写高性能I/O密集型的程序来说是至关重要的,我之前写过一篇文章介绍完成队列( CompletionQueue)就是非常常见的异步编程的模式之一(同时也是C++中非常广泛使用的方法),感兴趣的朋友可以阅读这一篇文章

CompletionQueue从原理上讲没有什么特别大的硬伤,我们将在第4部分中介绍的一个非常简单的基于Coroutine的异步框架就非常适合使用CompletionQueue作为调度器的实现(合适的天衣无缝)。但是直接把CompletionQueue开放给上层应用就很难受了,下面我们通过看代码来理解为什么这样不是最优的。

首先我们写一段伪代码,模拟从socket连接到接收、发送数据的整个过程:

// socket开始监听
socket.listen()
// 等待连接请求并接受请求
connection = socket.accept()
// 连接建立后读取数据
data = connection.read()
// 向连接写入数据
connection.write(buffer)
// 关闭连接
connection.close()

以上是一个极端精简的流程,并且是完全同步的方式实现,这种实现的弊端这里就不赘述了(感兴趣的朋友可以看上面的文章链接),那么我们看看基于CompletionQueue这类需要大量回调的方式如何实现通过的功能。

socket.listen()

function onAccept(conn, err) {
  conn.read(onRead, cq)
}

function onRead(conn, data, err) {
  conn.write(onWrite, cq)
}

function onWrite(conn, err) {
  ...
}

socket.accept(onAccept, cq)

“回调地狱”了对吧,这还只是比较简单的业务逻辑,如果有大量的数据接收发送可以想象回调之间非常难以管理,而且管理成本还不是这个模式带来的最主要的麻烦 —— 最大的麻烦在于在回调之间共享数据以及管理这些数据的生命周期。

那么有没有可读性好、性能好的实现方法呢?当然是有的,本文讲的Coroutine就是面向这样的场景的,在真正写Coroutine代码之前,我们畅想一下理想的情况下使用方式是怎样的?

socket.listen()
conn = await socket.accept()
data = await conn.read()
await conn.write(buffer)
await conn.close()

诶,这就写完了?和最开始的同步方法很像?事实上确实很像,但是执行过程却是天差地别。简单的理解就是:每次执行await操作函数都会暂停执行,保存当前执行状态,直到其等待的事件发生。当前线程因为已经空闲了,所以可以做点儿别的事情了,显而易见对线程、CPU的利用率也更高的。

说明:这里不展开stackful / stackless等概念,感兴趣的朋友可以自行阅读。

那么C++中究竟该如何做到这样的简洁和高效的呢?别着急慢慢来,我们先从一个简单的例子开始讲起。

从一个简单的例子开始

首先介绍一个C++20引入的关键字co_await,其作用和上面伪代码中的await类似,不理解怎么用没关系,先知道这个东西,我们来看代码(这次是C++了,不是伪代码)。

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

class ReturnType {
 public:
  // Define the promise type
  class Promise {
   public:
    ReturnType get_return_object() { return {}; }
    std::suspend_never initial_suspend() noexcept { return {}; }
    std::suspend_never final_suspend() noexcept { return {}; }
    void unhandled_exception() {}
  };

  using promise_type = Promise;  // Required by c++ standard
};

class Awaiter {
 public:
  std::coroutine_handle<> *p_handle_;
  constexpr bool await_ready() const noexcept { return false; }
  void await_suspend(std::coroutine_handle<> h) { *p_handle_ = h; }
  constexpr void await_resume() const noexcept {}
};

ReturnType counter(std::coroutine_handle<> *out_p_handle) {
  std::cout << "counter: start" << std::endl;
  Awaiter awaiter{.p_handle_ = out_p_handle};
  for (size_t i = 0;; ++i) {
    std::cout << "counter: before co_await" << i << std::endl;
    co_await awaiter;
    std::cout << "counter: after co_await" << i << std::endl;
  }
  std::cout << "counter: end" << std::endl;
}

int main() {
  std::coroutine_handle<> handle;
  counter(&handle);
  for (size_t i = 0; i < 3; ++i) {
    std::cout << "main" << std::endl;
    handle();
  }
  handle.destroy();
  return 0;
}
/*
Outputs:
counter: start
counter: before co_await0
main
counter: after co_await0
counter: before co_await1
main
counter: after co_await1
counter: before co_await2
main
counter: after co_await2
counter: before co_await3
*/

(不用怀疑,我刚写的,可以运行,编译命令【后同】:g++ -std=c++20 -o main.out main.cpp,用新的clang / gcc都行)上述代码主要实现了两个功能函数:

  1. counter,一个自增的coroutine函数,每次调用打印一个比上次大1的数字,也就是说你调用counter三次,其会分别打印:0,1,2(实现这个功能最简单是用lambda,不过我们这是在讲解Coroutine嘛,先忽略只是作为例子)。这里coroutine函数我们可以简单的理解为:它是一个可以暂停执行并继续执行的函数,可以使用co_await等关键字
  2. main,主函数,调用counter后,会恢复counter三次执行。

C++11往后语言的变化很大,上面的代码如果看不太懂也别着急,可以留言,我看情况可以分别介绍一些特性。下面假设你已经基本看懂了这些代码在干嘛。

我们用异步编程的思路来看,整个的调用执行过程应该是

  1. main执行
  2. counter执行,返回必要的数据,然后暂停
  3. main恢复执行,循环第一次
  4. counter恢复执行,打印0,然后暂停
  5. main恢复执行,循环第二次
  6. counter恢复执行,打印1,然后暂停
  7. main恢复执行,循环第三次
  8. counter恢复执行,打印2,然后暂停
  9. main恢复执行,退出循环,终止程序

我们可以看到main和counter两个函数是交叉执行的,实现这种交叉的关键就在于co_awaitstd::coroutine_handle这两个概念 —— 当执行co_await时,counter会在这个位置暂停,然后切换到main函数继续执行;当main调用std::coroutine_handle()时,就会切换到counter里从上次暂停的地方继续。交叉使用二者就实现上了上述执行效果。接下来我们讲讲这段代码中的原理。

我们知道co_await有暂停执行的能力,std::coroutine_handle有从暂停点继续的能力,实际上原理很简单,我们可以先简单粗暴的这样理解 —— 对于co_await a;这样的代码:

  1. co_await所在的函数(也就是所谓的coroutine,协程)中的所有局部变量,都会保存到堆上,因为只有在堆上保存才能让这个函数在任意线程、栈帧上恢复执行。
  2. 创建一个无参数的匿名函数,调用这个函数会从co_await这个位置继续执行,你仍然可以粗暴的理解为这是一个非常暴力的goto语句,直接goto到当前代码为止(当然编译器如何实现并不一定是goto)。
  3. 调用对象a的一个方法,把上一步中创建的这个函数作为参数传进去。

讲到这里再回去对照一下代码,你会看到co_await的目标对象是Awaiter类的一个实例,而步骤3中提到的“对象a的一个方法”就是:Awaiter.await_suspend,传入的参数h就是那个函数对象!我们在Awaiter.await_suspend继续追踪参数h的去向,会发现它最终会赋值给第40行,main函数一开始定义的std::coroutine_handle<> handle对象,而当main每次需要恢复counter的执行时,都是调用handle()来实现的!

怎么样串起来了吧,看起来很简单吧:counter函数的co_await制造一个暂停的现场和一个从现场恢复执行的函数h,并把函数h传递给其调用者main函数,main函数每次需要counter继续执行时就会调用h —— counter继续执行后循环到下一次co_await,此时重复上面的过程。

如果你已经完全理解了这个过程,那么来迎接一次暴击:上述过程基本正确,但不完全正确,只是用来帮助你迈出走进Coroutine的第一步。

co_await, coroutine_handle和promise_type

上面的代码逻辑你应该基本理解了,但是显然还有茫茫多的其他代码没有解释在干什么 —— 要解释清楚每一行需要引入很多概念,这并不利于学习,所以我们继续按部就班的来,首先修正上文中不够准确的地方:

  1. 每次co_await都会创建一个函数对象吗?
    不会。对于一个coroutine函数(比如counter),恢复其执行的函数对象永远只有一个,无论调用多少次co_await、无论co_await的对象是不是同一个对象或同一个类型。
    那么你可能好奇编译器怎么实现的 —— 很简单,在堆上保存pc寄存器就可以了,就是每次co_await在保存数据时,把当前代码执行到的位置(就是pc寄存器的值)也保存在堆上,继续执行时再把堆上保存的地址写到pc寄存器即可。
  2. std::coroutine_handle只是一个函数对象吗?
    不是。我没有看C++库的实现中到底保存了什么,但是逻辑上他保存的是一个地址:这个地址指向了堆上一个空间,这个空间里保存了所有coroutine执行所必须的本地变量。这也是为什么第46行:main函数中调用了handle.destroy() —— 实际上是在释放堆上的空间。std::coroutine_handle保存的地址在整个coroutine生命周期中都是不会改变的,因此也只会被创建一次,并且可以安全的拷贝。

好了,以上两点我们理解的很清楚了,那么我们来看看这个非常令人奇怪的臃肿的ReturnTypeReturnType::Promise,我最开始学习Coroutine时在这个地方琢磨了好久。我觉得这么讲会更方便大家理解(cppreference的写法真的、太难了……):

  1. 我们先不关心ReturnType在干什么,C++中任何函数都要有返回值(或者void),即使是coroutine也不能例外,ReturnType就是counter函数的返回值的类型(当然ReturnType这个名字你随便改)。那么C++标准要求:所有coroutine函数的返回类型必须是包含一个子类型promise_type(好吧,因为我习惯驼峰命名法,所以在第17行起了个别名)。
  2. 你并不需要手动的创建ReturnType的实例,编译器会在合适的时候创建ReturnType::promise_type类型的实例,并调用该对象上的get_return_object函数来获得coroutine函数的返回值。在上面的例子中,第11行创建了counter函数的返回值。
  3. promise_type和std::promise没有任何关系(是的,没有任何、半毛钱、半分钱的关系)。
  4. promise_type实际上是沟通coroutine内外的桥梁。一定要记住这一句,非常重要 —— promise_type的实例是同时可以在coroutine外(上例中的main)和coroutine内(上例中的counter)读取和写入的。我们先记住promise_type的这个作用,看下面的例子,来把这个coroutine串起来。

我们来改进上面的代码,既然std::coroutine_handle对于一个coroutine是唯一的,那么我们可以通过ReturnType来把std::coroutine_handle传递给main函数下面我们来实现这个功能。

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

class ReturnType {
 public:
  // Define the promise type
  class Promise {
   public:
    ReturnType get_return_object() {
      return {
          .handle_ = std::coroutine_handle<Promise>::from_promise(*this),
      };
    }
    std::suspend_never initial_suspend() noexcept { return {}; }
    std::suspend_never final_suspend() noexcept { return {}; }
    void unhandled_exception() {}
  };

  using promise_type = Promise;  // Required by c++ standard

  std::coroutine_handle<> handle_;
};

ReturnType counter() {
  std::cout << "counter: start" << std::endl;
  for (size_t i = 0;; ++i) {
    std::cout << "counter: before co_await" << i << std::endl;
    co_await std::suspend_always{};
    std::cout << "counter: after co_await" << i << std::endl;
  }
  std::cout << "counter: end" << std::endl;
}

int main() {
  auto return_object = counter();
  for (size_t i = 0; i < 3; ++i) {
    std::cout << "main" << std::endl;
    return_object.handle_();
  }
  return_object.handle_.destroy();
  return 0;
}

/*
Outputs:
counter: start
counter: before co_await0
main:0
counter: after co_await0
counter: before co_await1
main:1
counter: after co_await1
counter: before co_await2
main:2
counter: after co_await2
counter: before co_await3
*/

我们来看看这段代码,首先一个非常明显的区别:Awaiter类型没有了!删除了大量的代码,非常好!其次,main函数不需要创建std::coroutine_handle对象了。我们可以看到main函数是通过return_object.handle_()来实现第一个例子中handle()的能力的,我们再来到23行发现这个东西其实就是std::coroutine_handle对象,换汤不换药。

因此对于main函数来说,逻辑几乎没有变化,只是handle对象的获取方法改变了 —— 从counter的返回值ReturnType中获得std::coroutine_handle对象。还记得ReturnType是怎么创建的吗?对,来到第11-15行,C++标准中定义的promise_type::get_return_object方法来创建的,这个方法通过std::coroutine_handle<Promise>::from_promise(*this)这个方法来找到handle对象,并将获得的std::coroutine_handle传递给ReturnType。

这里应当对应于一个编译器的实现约束,逻辑上promise_type对象也保存在coroutine_handle指向的堆空间的,那么对于编译器来说知道了promise_type对象的地址,也就是知道了coroutine_handle的地址。编译器具体如何实现这个过程就不在这个文档的讨论范围内了。

这里需要再说明的是这个方法是相当的常用的,我们可以在promise_type的任何函数里通过这样简单的方法来说std::coroutine_handle对象。

好,那么这一步非常容易理解了。ReturnType::promise_type::get_return_object函数在创建ReturnType的实例时,根据promise_type对象的地址计算出coroutine_handle,并将这个值保存在ReturnType对象中。ReturnType对象返回给main函数之后,main函数就可以轻松的实现继续counter执行的功能了。

这里仔细看30行,会发现counter使用了一个:co_await std::suspend_always{};的方式暂停执行。这里先不做详细解释,可以简单的理解为std::suspend_always定义了一种“需要暂停执行”的逻辑,第2篇文章中会详细解释这里。

读到这里大家可能会疑惑 —— 你说的promise_type作为桥梁的事儿呢?我怎么没看出这个桥梁的作用?确实,这个例子里还没有体现出promise_type的这个作用,接下来我们继续修改这个代码 —— 我们通过promise_type把counter函数的变量i的值传递给main!

#include <concepts>
#include <coroutine>
#include <exception>
#include <iostream>
  
class ReturnType {
 public:
  // Define the promise type
  class Promise {
   public:
    ReturnType get_return_object() {
      return {
          .handle_ = std::coroutine_handle<Promise>::from_promise(*this),
      };
    }
    std::suspend_never initial_suspend() noexcept { return {}; }
    std::suspend_never final_suspend() noexcept { return {}; }
    void unhandled_exception() {}

    size_t value_;
  };

  using promise_type = Promise;  // Required by c++ standard

  std::coroutine_handle<Promise> handle_;  // Use 'Promise' type argument
};

class GetPromiseAwaiter {
 public:
  constexpr bool await_ready() const noexcept { return false; }
  bool await_suspend(std::coroutine_handle<ReturnType::Promise> h) {
    promise_ = &h.promise();
    return false;  // Do not suspend
  }
  ReturnType::Promise* await_resume() const noexcept { return promise_; }

 private:
  ReturnType::Promise* promise_;
};

ReturnType counter() {
  std::cout << "counter: start" << std::endl;
  auto p_promise = co_await GetPromiseAwaiter{};
  std::cout << "counter: before for" << std::endl;
  for (size_t i = 0;; ++i) {
    p_promise->value_ = i;
    co_await std::suspend_always{};
  }
  std::cout << "counter: end" << std::endl;
}

int main() {
  auto return_object = counter();
  auto& promise = return_object.handle_.promise();
  for (size_t i = 0; i < 3; ++i) {
    std::cout << "main:" << promise.value_ << std::endl;
    return_object.handle_();
  }
  return_object.handle_.destroy();
  return 0;
}

/*
Outputs:
counter: start
counter: before for
main:0
main:1
main:2
*/

同样,我们继续看这一段代码有什么变化:

  1. ReturnType::promise_type多了一个value_成员变量,显然我们就是通过这个变量作为桥梁的
  2. 增加了一整个GetPromiseAwaiter类[流泪],代码又变多了……(放心,后面还会删掉,有更好的方法,这里写这个类只是为了让大家理解其中的原理)
  3. counter方法不再用std::cout来输出,来到第46行,而是通过设置promise_type::value_变量来把i的值传递出去
  4. main通过promise::value_的值来读取i,来到第56行。

考虑到大家上面已经掌握了一些知识储备了,我要加快速度了~ (后面的文章解释会逐渐变少,毕竟还挺累的……)。

首先看第53,54行,main方法通过ReturnType实例来获得promise_type对象,这并不稀奇,因为std::coroutine_handle提供了一个promise方法来获得promise_type对象。(这很容易理解,我们可以通过promise_type对象来计算出coroutine_handle,反过来是一样的)。因此main的改动是很好理解的,接下来我们主要看GetPromiseAwaiter类和第43行:co_await GetPromiseAwaiter{};

这里我们就必须首先解释co_wait的对象的约束了 —— 为了让co_await a;这样的代码是可执行的,a对象的类型需要包含至少如下几个函数:

  1. bool await_ready()
    co_await执行时,首先会调用这个函数,用于确定是否需要暂停当前函数执行:返回true表示不暂停,返回false表示暂停。
  2. void await_suspend(std::coroutine_handle<>) 或者 bool await_suspend(std::coroutine_handle<>)
    如果await_ready()返回false,那么会执行该函数,该函数有两个形式:返回 void 时表示需要暂停执行返回 bool 时,如果返回值是true则暂停执行,否则不暂停执行。
  3. void await_resume() 或者 T await_resume() 其中T表示一个类型
    该函数返回在coroutine在继续执行时,co_await函数的返回值。

用伪代码的形式是

res = co_await awaiter;
// 等价于
if (!awaiter.await_ready()) {
  // 编译器计算当前coroutine的std::coroutine_handle<> h
  if (awaiter.await_suspend(h) == void || awaiter.await_suspend(h) == true) {
    // 保存coroutine状态
    // 暂停执行,切换到调用者
    ...
    // 继续执行
  }
  res = awaiter.await_resume();
}

我们看GetPromiseAwaiter的实现,可以看到其实它是通过配合使用await_suspend和await_resume来获得promise_type对象的:

  1. await_suspend函数中保存coroutine_handle::promise返回的promise_type对象到成员变量中
  2. await_resume函数返回这个成员变量

因此可以看到第43行可以得到promise_type对象的地址并在后续的代码中操作该对象的value_成员的值,从而实现“以promise_type为桥梁,把数据从coroutine内部传递到coroutine外部的功能”。

这里额外需要注意的一点是GetPromiseAwaiter::await_suspend函数返回了false,不暂停执行,这是因为我们并不希望counter函数暂停在这个位置(一旦暂停,代码就会跳转到main函数中继续执行),返回false保证co_await方法不会暂停当前coroutine,使得代码可以继续执行,直到第46行设置了promise_type对象的值并在第47行暂停执行。

好了这就是C++ coroutine系列文章的第一篇了,码了好长时间,希望大家能对coroutine有一个初步、宏观的理解。因为这是这个系列的第一篇,因此代码解释的比较详细,在后续的代码中,我会主要介绍重点概念,减少对代码的详细解读了~

喜欢就点个赞,点个关注吧!

相关推荐

国产web端开源ui组件-通用前端ui界面组件库

写个序吧:随着工作的不断深入,越来越发现很多好的前端开源项目都来自于国外,国产的开源项目很多时候面临叫好不叫座甚至有很多就消失不见了。开源和创新,不仅仅是需要我们的参与同样也需要我们不断地传播,因此才...

微信小程序商城项目,包括,分销,团购,秒杀,优惠券惠源码分享

源码获取,关注转发文章之后私信回复【源码】即可免费获取到!小程序商城,包括:分销(支持三级)、团购(拼多多模式)、秒杀、优惠券、等功能技术框架核心框架:SpringFramework4安全框架:A...

VUE3前端开发入门系列教程

一直以来使用ThinkJS开发,使用Semantic-UI手写代码,又缺少一些table等插件,好累。平时使用NodeJS开发后端较多,一直有接触VUE想法,总是不得入门(可能是思维固化了),再次深入...

支持分销、团购、秒杀、优惠券、微信商城项目,源码免费分享

小程序商城,包括:分销(支持三级)、团购(拼多多模式)、秒杀、优惠券、等功能如果您需要用到这个微信小程序的源码,欢迎关注转发之后私信【源码】来免费获取到!面向对象Open-Shop是企业在创立初期很好...

在Gitee获8.5k Star,做微信小程序商城看这一个开源项目就够了

商城系统是小程序中比较热门的类型,许多开发者在寻找商城类小程序项目时,都会遇到一些声称「开源」但是并不是完全开源,有时候还会收费的项目。今天Gitee介绍的这款微信小程序商城项目就是一款从前端到后...

七款国内免费开源PHP CMS推荐,无限制,可商用

自织梦cms收费后,很多使用dedecms的站长都转移到了别的cms系统上,上一期也给大家分享了几款国外开源cms系统,今天来给大家分享几款国内的免费且可商用的phpcms系统:PbootCmsPb...

VUE3前端开发入门系列教程二:使用iView框架辅助开发

1、安装iView新框架,支持VUE3npminstallview-ui-plus2、编辑src/main.js,添加以下内容,导入js和css到项目importViewUIPlusfrom...

TS 真正比 JS 强大的那些特性

在前端开发领域,JavaScript(JS)一直是当之无愧的武林盟主,凭借灵活多变的特性和超广泛的兼容性打下大片江山。然而,随着前端应用日益复杂,TypeScript(TS)这位后起之秀崛起,以独特优...

自写一个函数将js对象转为Ts的Interface接口

如今的前端开发typescript已经成为一项必不可以少的技能了,但是频繁的定义Interface接口会给我带来许多工作量,我想了想如何来减少这些非必要且费时的工作量呢,于是决定写一个函数,将对象放进...

如何优雅地校验后端接口数据,不做前端背锅侠

背景最近新接手了一批项目,还没来得及接新需求,一大堆bug就接踵而至,仔细一看,应该返回数组的字段返回了null,或者没有返回,甚至返回了字符串"null"???这我能忍?我立刻截...

正点原子I.MX6U嵌入式Linux C应用编 第十八章 输入设备应用编程

输入设备应用编程本章学习...

Python时间序列分析:使用TSFresh进行自动化特征提取

TSFresh(基于可扩展假设检验的时间序列特征提取)是一个专门用于时间序列数据特征自动提取的框架。该框架提取的特征可直接应用于分类、回归和异常检测等机器学习任务。TSFresh通过自动化特征工程流程...

人教版八下数学第十九章《一次函数》辅导(6)一次函数(1)

人教版八下数学第十九章《一次函数》辅导(6)一次函数(1)一、生活中的一次函数探究1(1)一个小球由静止开始沿一个斜坡向下滚动,其速度每秒增加2m/s,若小球的速度为vm/s,运动时间为ts,求v关于...

笔记|Simulink中S函数的设计

S函数的简介S函数是Simulink中提供给用户的一个自定义模块,由于在研究过程中经常需要复杂的算法设计,Simulink中提供的模块无法满足使用,就需要用编程的形式设计出S函数模块,然后嵌入到系统中...

初探HarmonyOS开发,ArkTS语言初看

最近在研究HarmonyOS(鸿蒙操作系统)开发,HarmonyOS(鸿蒙操作系统)想必大家都不陌生了,但是我也是在该系统发布许久后才首次尝试上手开发,因为有写java后台的经验,也算是能堪堪上手。据...

取消回复欢迎 发表评论: