锐单电子商城 , 一站式电子元器件采购平台!
  • 电话:400-990-0325

C++20协程原理和应用

时间:2023-02-17 21:30:00 dc连接器类型

【CSDN 编者按】两年前,C 20 正式发布。在这一版本,开发者终于迎来了协程特性,它可以让代码非常清爽,简单易懂,同时保持了异步的高性能。但许多开发者直言不讳,C 图书馆的开发者使用协程标准,非常复杂,对普通开发者一点也不友好。在本文中,C 资深技术专家齐宇立足于 C 20 无栈协程标准以具体示例分享协程的具体应用实践和经验。

作者 | 许传奇,韩遥,齐宇 责编 | 屠敏

出品 | CSDN(ID:CSDNnews)

经过多年的酝酿、争论和准备,协议终于进入了 C 20 标准。

1.微软提出并主导无栈协程成为C 20协程标准

协程不是一个新概念。它已经有几十年的历史了,已经存在于许多其他编程语言中(Python、C#、Go)。

协程分为无栈协程和有栈协程。无栈是指可以悬挂/恢复的函数,而有栈协程则相当于用户态线程。有栈协程切换的成本是用户态线程切换的成本,而无栈协程切换的成本相当于函数调用的成本。

无栈协程与线程的区别:无栈协程只能被线程调用,本身并不抢占内核调度,而线程则可以抢占内核调度。

C 20 微软提出并主导了协议(来自于 C#)无栈协程。许多人反对这一特点,主要槽点包括:难以理解、过于灵活、动态分配等。Google 该提案发起了一系列吐槽,并试图给出一个有栈协程的方案。有栈协程比系统级线程轻很多,但比无栈协程差很多。

由于 C 设计哲学是"Zero Overhead Abstractions",最后,无栈协程成了 C 20 协程标准。

当今 C 世界演变的两个主题是异步化与并行化。而 C 20 协程可以用同步语法编写异步代码,使其成为编写异步代码的好工具。异步库的协程将是大势所趋,需要掌握 C 20 协程。

以简单的例子展示协程的妙处。

async_resolve({host, port}, [](auto endpoint){   async_connect(endpoint, [](auto error_code){     async_handle_shake([](auto error_code){         send_data_ = build_request();          async_write(send_data_, [](auto error_code){             async_read();         });     });     }); });  void async_read() {     async_read(response_, [](auto error_code){         if(!finished()) {             append_response(recieve_data_);             async_read();         }else {             std::cout<<"finished ok\n";         }     }); }

基于回调的异步client的伪代码

基于异步回调 client 流程如下:

  • 异步域名分析

  • 异步连接

  • 异步 SSL 握手

  • 异步发送数据

  • 异步接收数据

这个代码有很多回调函数,使用回调的时候还有一些陷阱,比如如何保证安全的回调、如何让异步读实现异步递归调用,如果再结合异步业务逻辑,回调的嵌套层次会更深,我们已经看到 callback hell 的影子了!一些读者可能认为这种程度的异步回调是可以接受的,但如果项目变得更大,业务逻辑变得更加复杂,回调水平越来越深,就很难维护。

让我们来看看如何用协程写这个代码:

autoendpoint=co_awaitasync_query({host,port}); autoerror_code=co_awaitasync_connect(endpoint); error_code=co_awaitasync_handle_shake(); send_data=build_request(); error_code=co_awaitasync_write(send_data); while(true){ co_awaitasync_read(response); if(finished()){ std::cout<<"finishedok\n"; break; }  append_response(recieve_data_); }

基于C 20协程的异步client

同样是异步 client,与回调模式的异步相比 client,整个代码非常清爽,易于理解,同时保持高性能的异步,即 C 20 协程的力量!

看完这个例子,你应该不想用异步回调来写代码了。是时候拥抱协程了!

2.C 20 为何选择无栈协程?

有栈(stackful)协程通常的实现手段是在堆上提前分配一块较大的内存空间(比如 64K),即协程所谓的栈,参数,return address 这个栈空间可以存放等。如果需要协程切换,则通过 swapcontext 一种形式使系统认为堆叠空间是普通的堆栈,实现了上下文的切换。

栈协程最大的优点是侵入性小,使用方便。现有的业务代码几乎不需要修改,但是 C 20 最后选择使用无栈协程,主要是出于以下几个方面。

  • 限制栈空间

有栈协程的栈空间一般比较小,使用中有栈溢出的风险;如果栈空间变大,对内存空间的浪费会很大。没有栈协程没有这些限制,既没有溢出的风险,也没有内存利用率的担忧。

  • 性能

有栈协程在切换过程中确实比系统线程轻,但与无栈协程相比仍然偏重,虽然对我们目前的实际使用影响不大(异步系统的使用通常伴随着 IO,与切换成本相比,有几个数量级),但也决定了无栈协程可以用在一些更有趣的场景中。举个例子,C 20 coroutines 提案的作者 Gor Nishanov 在 CppCon 2018 上演示了无栈协程可以实现纳秒切换,并根据这一特点实现了减少 Cache Miss 的特性。

无栈协程是普通函数的泛化

无栈协程是一种可暂停和恢复的函数,是函数调用的泛化。

为什么?

我们知道函数体(function body)它是按顺序执行的。执行后,将结果返回给调用器。我们不能把它挂起来,稍后再恢复它。我们只能等它结束。无栈协程允许我们在任何需要的时间悬挂函数并恢复和执行函数体。与普通函数相比,协程函数体可以随时悬挂和恢复执行。

因此,从这个角度来看,无栈协程是普通函数的泛化。

3.C 20 协程的微言大义

C 20 提供三个新关键字(co_await、co_yield 和 co_return),若这三个关键字存在于一个函数中,则为协程。

编译器将为协程生成许多代码,以实现协程语义。会生成什么样的代码?如何实现协程语义?协程的创建是什么?co_await机制是什么?在探索这些问题之前,让我们看看和 C 20 一些与协程相关的基本概念。

协议相关对象

协程帧(coroutine frame)

当 caller 调用协程时,将首先创建协程帧,协程帧将构建 promise 对象,再通过 promise 对象产生 return object。

这些内容主要包括协程帧:

  • 协程参数

  • 局部变量

  • promise 对象

协程恢复运行时需要使用这些内容,caller 通过协程帧的句柄 std::coroutine_handle 访问协程帧。

promise_type

promise_type 是 promise 对象类型。promise_type 定义一种协程行为,包括协程创建模式、协程初始化完成和束时的行为、发生异常时的行为、如何生成 awaiter 的行为以及 co_return 的行为等等。promise 对象可以用于记录/存储一个协程实例的状态。每个协程桢与每个 promise 对象以及每个协程实例是一一对应的。

coroutine return object

它是promise.get_return_object()方法创建的,一种常见的实现手法会将 coroutine_handle 存储到 coroutine object 内,使得该 return object 获得访问协程的能力。

std::coroutine_handle

协程帧的句柄,主要用于访问底层的协程帧、恢复协程和释放协程帧。
程序员可通过调用 std::coroutine_handle::resume() 唤醒协程。

co_await、awaiter、awaitable

  • co_await:一元操作符;

  • awaitable:支持 co_await 操作符的类型;

  • awaiter:定义了 await_ready、await_suspend 和 await_resume 方法的类型。

co_await expr 通常用于表示等待一个任务(可能是 lazy 的,也可能不是)完成。co_await expr 时,expr 的类型需要是一个 awaitable,而该 co_await表达式的具体语义取决于根据该 awaitable 生成的 awaiter。

看起来和协程相关的对象还不少,这正是协程复杂又灵活的地方,可以借助这些对象来实现对协程的完全控制,实现任何想法。但是,需要先要了解这些对象是如何协作的,把这个搞清楚了,协程的原理就掌握了,写协程应用也会游刃有余了。

协程对象如何协作

以一个简单的代码展示这些协程对象如何协作:

Return_t foo () { 
    auto res = co_await awaiter; 
    co_return res ; 
}

Return_t:promise return object。

awaiter: 等待一个task完成。

协程运行流程图

图中浅蓝色部分的方法就是 Return_t 关联的 promise 对象的函数,浅红色部分就是 co_await 等待的 awaiter。

这个流程的驱动是由编译器根据协程函数生成的代码驱动的,分成三部分:

  • 协程创建;

  • co_await awaiter 等待 task 完成;

  • 获取协程返回值和释放协程帧。

协程的创建

Return_t foo () { 
    auto res = co_await awaiter; 
    co_return res ; 
}

foo()协程会生成下面这样的模板代码(伪代码),协程的创建都会产生类似的代码:

{
  co_await promise.initial_suspend();
  try
  {
    coroutine body;
  }
  catch (...)
  {
    promise.unhandled_exception();
  }
FinalSuspend:
  co_await promise.final_suspend();
}

首先需要创建协程,创建协程之后是否挂起则由调用者设置 initial_suspend 的返回类型来确定。

创建协程的流程大概如下:

  • 创建一个协程帧(coroutine frame)

  • 在协程帧里构建 promise 对象

  • 把协程的参数拷贝到协程帧里

  • 调用 promise.get_return_object() 返回给 caller 一个对象,即代码中的 Return_t 对象

在这个模板框架里有一些可定制点:如 initial_suspend、final_suspend、unhandled_exception 和 return_value。

我们可以通过 promise 的 initial_suspend 和 final_suspend 返回类型来控制协程是否挂起,在 unhandled_exception 里处理异常,在 return_value 里保存协程返回值。

可以根据需要定制 initial_suspend 和 final_suspend 的返回对象来决定是否需要挂起协程。如果挂起协程,代码的控制权就会返回到caller,否则继续执行协程函数体(function body)。

另外值得注意的是,如果禁用异常,那么生成的代码里就不会有 try-catch。此时协程的运行效率几乎等同非协程版的普通函数。这在嵌入式场景很重要,也是协程的设计目的之一。

co_await 机制

co_await 操作符是 C++20 新增的一个关键字,co_await expr 一般表示等待一个惰性求值的任务,这个任务可能在某个线程执行,也可能在 OS 内核执行,什么时候执行结束不知道,为了性能,我们又不希望阻塞等待这个任务完成,所以就借助 co_await 把协程挂起并返回到 caller,caller 可以继续做事情,当任务完成之后协程恢复并拿到 co_await 返回的结果。

所以 co_await 一般有这几个作用:

  • 挂起协程;

  • 返回到 caller;

  • 等待某个任务(可能是 lazy 的,也可能是非 lazy 的)完成之后返回任务的结果。

编译器会根据 co_await expr 生成这样的代码:

{
  auto&& value = ;
  auto&& awaitable = get_awaitable(promise, static_cast(value));
  auto&& awaiter = get_awaiter(static_cast(awaitable));
  if (!awaiter.await_ready()) //是否需要挂起协程
  {
    using handle_t = std::experimental::coroutine_handle

;     using await_suspend_result_t =       decltype(awaiter.await_suspend(handle_t::from_promise(p)));      //挂起协程     if constexpr (std::is_void_v)     {       awaiter.await_suspend(handle_t::from_promise(p)); //异步(也可能同步)执行task        //返回给caller     }     else     {       static_assert(          std::is_same_v,          "await_suspend() must return 'void' or 'bool'.");       if (awaiter.await_suspend(handle_t::from_promise(p)))       {                }     }      //task执行完成,恢复协程,这里是协程恢复执行的地方   }   return awaiter.await_resume(); //返回task结果 }

这个代码执行流程就是“协程运行流程图”中粉红色部分,从这个生成的代码可以看到,通过定制 awaiter.await_ready() 的返回值就可以控制是否挂起协程还是继续执行,返回 false 就会挂起协程,并执行 awaiter.await_suspend,通过 awaiter.await_suspend 的返回值来决定是返回 caller 还是继续执行。

正是 co_await 的这种机制是变“异步回调”为“同步”的关键。

C++20 协程中最重要的两个对象就是 promise 对象(恢复协程和获取某个任务的执行结果)和 awaiter(挂起协程,等待task执行完成),其它的都是“工具人”,要实现想要的的协程,关键是要设计如何让这两个对象协作好。

关于co_await的更多细节,读者可以看这个文档(https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await)。

微言大义

再回过头来看这个简单的协程:

Return_t foo () { 
    auto res = co_await awaiter; 
    co_return res ; 
}

foo 协程只有三行代码,但它最终生成的是一百多行的代码, 如论是协程的创建还是 co_await 机制都是由这些代码实现的,这就是 C++20 协程的“微言大义”。

关于 C++20 协程的概念和实现原理已经讲了很多了,接下来通过一个简单的 C++20 协程示例来展示协程是如何运行的。

4.一个简单的 C++20 协程例子

这个例子很简单,通过 co_await 把协程调度到一个线程中打印一下线程 id。

#include 
#include 
#include 

namespace Coroutine {
  struct task {
    struct promise_type {
      promise_type() {
        std::cout << "1.create promie object\n";
      }
      task get_return_object() {
        std::cout << "2.create coroutine return object, and the coroutine is created now\n";
        return {std::coroutine_handle::from_promise(*this)};
      }
      std::suspend_never initial_suspend() {
        std::cout << "3.do you want to susupend the current coroutine?\n";
        std::cout << "4.don't suspend because return std::suspend_never, so continue to execute coroutine body\n";
        return {};
      }
      std::suspend_never final_suspend() noexcept {
        std::cout << "13.coroutine body finished, do you want to susupend the current coroutine?\n";
        std::cout << "14.don't suspend because return std::suspend_never, and the continue will be automatically destroyed, bye\n";
        return {};
      }
      void return_void() {
        std::cout << "12.coroutine don't return value, so return_void is called\n";
      }
      void unhandled_exception() {}
    };

    std::coroutine_handle handle_;
  };

  struct awaiter {
    bool await_ready() {
      std::cout << "6.do you want to suspend current coroutine?\n";
      std::cout << "7.yes, suspend becase awaiter.await_ready() return false\n";
      return false;
    }
    void await_suspend(
      std::coroutine_handle handle) {
      std::cout << "8.execute awaiter.await_suspend()\n";
      std::thread([handle]() mutable { handle(); }).detach();
      std::cout << "9.a new thread lauched, and will return back to caller\n";
    }
    void await_resume() {}
  };

  task test() {
    std::cout << "5.begin to execute coroutine body, the thread id=" << std::this_thread::get_id() << "\n";//#1
    co_await awaiter{};
    std::cout << "11.coroutine resumed, continue execcute coroutine body now, the thread id=" << std::this_thread::get_id() << "\n";//#3
  }
}// namespace Coroutine

int main() {
  Coroutine::test();
  std::cout << "10.come back to caller becuase of co_await awaiter\n";
  std::this_thread::sleep_for(std::chrono::seconds(1));

  return 0;
}

测试输出:

1.create promie object
2.create coroutine return object, and the coroutine is created now
3.do you want to susupend the current coroutine?
4.don't suspend because return std::suspend_never, so continue to execute coroutine body
5.begin to execute coroutine body, the thread id=0x10e1c1dc0
6.do you want to suspend current coroutine?
7.yes, suspend becase awaiter.await_ready() return false
8.execute awaiter.await_suspend()
9.a new thread lauched, and will return back to caller
10.come back to caller becuase of co_await awaiter
11.coroutine resumed, continue execcute coroutine body now, the thread id=0x700001dc7000
12.coroutine don't return value, so return_void is called
13.coroutine body finished, do you want to susupend the current coroutine?
14.don't suspend because return std::suspend_never, and the continue will be automatically destroyed, bye

从这个输出可以清晰的看到协程是如何创建的、co_await 等待线程结束、线程结束后协程返回值以及协程销毁的整个过程。

协程创建

输出内容中的 1、2、3 展示了协程创建过程,先创建 promise,再通过 promise.get_return_object() 返回 task,这时协程就创建完成了。

协程创建后的行为

协程创建完成之后是要立即执行协程函数呢?还是先挂起来?这个行为由 promise.initial_suspend() 来确定,由于它返回的是一个 std::suspend_never的awaiter,所以不会挂起协程,于是就立即执行协程函数了。

co_await awaiter

执行协程到函数的 co_await awaiter 时,是否需要等待某个任务?返回 false 表明希望等待,于是接着进入到 awaiter.wait_suspend(),并挂起协程,在 await_suspend 中创建了一个线程去执行任务(注意协程具柄传入到线程中了,以便后面在线程中恢复协程),之后就返回到 caller了,caller 这时候可以不用阻塞等待线程结束,可以做其它事情。注意:这里的 awaiter 同时也是一个 awaitable,因为它支持 co_await。

更多时候我们在线程完成之后才去恢复协程,这样可以告诉挂起等待任务完成的协程:任务已经完成了,现在可以恢复了,协程恢复后拿到任务的结果继续执行。

协程恢复

当线程开始运行的时候恢复挂起的协程,这时候代码执行会回到协程函数继续执行,这就是最终的目标:在一个新线程中去执行协程函数的打印语句。

协程销毁

awaiter.final_suspend 决定是否要自动销毁协程,返回 std::suspend_never 就自动销毁协程,否则需要用户手动去销毁。

协程的“魔法”

再回过头来看协程函数:

task test() {
    std::cout << std::this_thread::get_id() << "\n";
    co_await awaiter{};
    std::cout << std::this_thread::get_id() << "\n";
}

输出结果显示 co_await 上面和下面的线程是不同的,以 co_await 为分界线,co_await 之上的代码在一个线程中执行,co_await 之下的代码在另外一个线程中执行,一个协程函数跨了两个线程,这就是协程的“魔法”。本质是因为在另外一个线程中恢复了协程,恢复后代码的执行就在另外一个线程中了。

另外,这里没有展示如何等待一个协程完成,简单的使用了线程休眠来实现等待的,如果要实现等待协程结束的逻辑,代码还会增加一倍。

相信你通过这个简单的例子对 C++20 协程的运行机制有了更深入的理解,同时也会感叹,协程的使用真的只适合库作者,普通的开发者想用 C++20 协程还是挺难的,这时就需要协程库了,协程库可以大幅降低使用协程的难度。

5.为什么需要一个协程库

通过前面的介绍可以看到,C++20 协程还是比较复杂的,它的概念多、细节多,又是编译器生成的模板框架,又是一些可定制点,需要了解如何和编译器生成的模板框架协作,这些对于普通的使用者来说光理解就比较吃力,更逞论灵活运用了。

这时也可以理解为什么当初 Google 吐槽这样的协程提案难于理解、过于灵活了,然而它的确可以让我们仅需要通过定制化一些特定方法就可以随心所欲的控制协程,还是很灵活的。

总之,这就是 C++20 协程,它目前只适合给库作者使用,因为它只提供了一些底层的协程原语和一些协程暂停和恢复的机制,普通用户如果希望使用协程只能依赖协程库,由协程库来屏蔽这些底层细节,提供简单易用的 API。因此,我们迫切需要一个基于 C++20 协程封装好的简单易用的协程库。

正是在这种背景下,C++20 协程库 async_simple(https://github.com/alibaba/async_simple)就应运而生了!

阿里巴巴开发的 C++20 协程库,目前广泛应用于图计算引擎、时序数据库、搜索引擎等在线系统。连续两年经历天猫双十一磨砺,承担了亿级别流量洪峰,具备非常强劲的性能和可靠的稳定性。

async_simple 现在已经在 GitHub 上开源,有了它你在也不用为 C++20 协程的复杂而苦恼了,正如它的名字一样,让异步变得简单。

接下来我们将介绍如何使用 async_simple 来简化异步编程。

6.async_simple 让协程变得简单

async_simple 提供了丰富的协程组件和简单易用的 API,主要有:

  1. Lazy:lazy 求值的无栈协程

  2. Executor:协程执行器

  3. 批量操作协程的 API:collectAll 和 collectAny

  4. uthread:有栈协程

关于 async_simple 的更多介绍和示例,可以看 GitHub(https://github.com/alibaba/async_simple/tree/main/docs/docs.cn)上的文档。

有了这些常用的丰富的协程组件,我们写异步程序就变得很简单了,通过之前打印线程 id 例子来展示如何使用 async_simple 来实现它,也可以对比下用协程库的话,代码会简单多少。

#include "async_simple/coro/Lazy.h"
#include "async_simple/executors/SimpleExecutor.h"

Lazy PrintThreadId(){
    std::cout<<"thread id="< TestPrintThreadId(async_simple::executors::SimpleExecutor &executor){
    std::cout<<"thread id="<

借助 async_simple 可以轻松地把协程调度到 executor 线程中执行,整个代码变得非常清爽,简单易懂,代码量相比之前少得多,用户也不用去关心 C++20 协程的诸多细节了。

借助 async_simple 这个协程库,可以轻松的让 C++20 协程这只“王谢堂前燕,飞入寻常百姓家”!

async_simple 提供了很多 example,比如使用 async_simple 开发 http client、http server、smtp client 等示例,更多 Demo 可以看 async_simple 的 demo example(https://github.com/alibaba/async_simple/blob/main/demo_example)。

7.性能

使用 async_simple 中的 Lazy 与 folly 中的 Task 以及 cppcoro 中的 task 进行比较,对无栈协程的创建速度与切换速度进行性能测试。需要说明的是,这只是一个高度裁剪的测试用于简单展示 async_simple,并不做任何性能比较的目的。而且 Folly::Task 有着更多的功能,例如 Folly::Task 在切换时会在 AsyncStack 记录上下文以增强程序的 Debug 便利性。

测试硬件

CPU: Intel® Xeon® Platinum 8163 CPU @ 2.50GHz

测试结果

单位: 纳秒,数值越低越好。

测试结果表明 async_simple 的性能还是比较出色的,未来还会持续去优化改进。

8.总结

C++20 协程像一台精巧的“机器”,虽然复杂,但非常灵活,允许我们去定制化它的一些“零件”,通过这些定制化的“零件”我们可以随心所欲的控制这台“机器”,让它帮我们实现任何想法。

正是这种复杂性和灵活性让 C++20 协程的使用变得困难,幸运的是我们可以使用工业级的成熟易用的协程库 async_simple 来简化协程的使用,让异步变得简单!

参考资料:

  • https://github.com/alibaba/async_simple

  • https://timsong-cpp.github.io/cppwp/n4868/

  • https://blog.panicsoftware.com/coroutines-introduction/

  • https://lewissbaker.github.io/

  • https://juejin.cn/post/6844903715099377672

  • https://wiki.tum.de/download/attachments/93291100/Kolb%20report%20-%20Coroutines%20in%20C%2B%2B20.pdf

作者:祁宇,Modern C++ 开源社区 purecpp.org 创始人,《深入应用 C++11》作者
许传奇,阿里巴巴开发工程师, LLVM Committer, C++ 标准委员会成员
韩垚,阿里巴巴工程师,目前从事搜索推荐引擎开发

锐单商城拥有海量元器件数据手册IC替代型号,打造电子元器件IC百科大全!

相关文章