Google “战败”后,C++20 用微软的提案进入协程时代!

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


作者 | 祁宇,许传奇,韩垚      责编 | 屠敏
出品 | CSDN(ID:CSDNnews)

经过多年的酝酿、争论、准备后,协程终于进入 C++20 标准。

图片

 

图片

微软提出并主导的无栈协程成为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 的影子了!可能也有读者觉得这个程度的异步回调还可以接受,但是如果工程变大,业务逻辑变得更加复杂,回调层次越来越深,维护起来就很困难了。

再来看看用协程是怎么写这个代码的:

auto endpoint = co_await async_query({host, port});
auto error_code = co_await async_connect(endpoint);
error_code = co_await async_handle_shake();
send_data = build_request();
error_code = co_await async_write(send_data);
while(true) {
    co_await async_read(response);
    if(finished()) {
        std::cout<<"finished ok\n";
        break;
    }

    append_response(recieve_data_);
}

基于C++20协程的异步client

同样是异步 client,相比回调模式的异步 client,整个代码非常清爽,简单易懂,同时保持了异步的高性能,这就是 C++20 协程的威力!

相信你看了这个例子之后应该不会再想用异步回调去写代码了吧,是时候拥抱协程了!

 

图片

C++20 为什么选择无栈协程?

 

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

有栈协程最大的优势就是侵入性小,使用起来非常简便,已有的业务代码几乎不需要做什么修改,但是 C++20 最终还是选择了使用无栈协程,主要出于下面这几个方面的考虑。

  • 栈空间的限制

有栈协程的“栈”空间普遍是比较小的,在使用中有栈溢出的风险;而如果让“栈”空间变得很大,对内存空间又是很大的浪费。无栈协程则没有这些限制,既没有溢出的风险,也无需担心内存利用率的问题。

  • 性能

有栈协程在切换时确实比系统线程要轻量,但是和无栈协程相比仍然是偏重的,这一点虽然在我们目前的实际使用中影响没有那么大(异步系统的使用通常伴随了 IO,相比于切换开销多了几个数量级),但也决定了无栈协程可以用在一些更有意思的场景上。举个例子,C++20 coroutines 提案的作者 Gor Nishanov 在 CppCon 2018 上演示了无栈协程能做到纳秒级的切换,并基于这个特点实现了减少 Cache Miss 的特性。

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

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

为什么?

我们知道一个函数的函数体(function body)是顺序执行的,执行完之后将结果返回给调用者,我们没办法挂起它并稍后恢复它,只能等待它结束。而无栈协程则允许我们把函数挂起,然后在任意需要的时刻去恢复并执行函数体,相比普通函数,协程的函数体可以挂起并在任意时刻恢复执行。

图片

所以,从这个角度来说,无栈协程是普通函数的泛化。

 

图片

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 = <expr>;
  auto&& awaitable = get_awaitable(promise, static_cast<decltype(value)>(value));
  auto&& awaiter = get_awaiter(static_cast<decltype(awaitable)>(awaitable));
  if (!awaiter.await_ready()) //是否需要挂起协程
  {
    using handle_t = std::experimental::coroutine_handle<P>;

    using await_suspend_result_t =
      decltype(awaiter.await_suspend(handle_t::from_promise(p)));

    <suspend-coroutine> //挂起协程

    if constexpr (std::is_void_v<await_suspend_result_t>)
    {
      awaiter.await_suspend(handle_t::from_promise(p)); //异步(也可能同步)执行task
      <return-to-caller-or-resumer> //返回给caller
    }
    else
    {
      static_assert(
         std::is_same_v<await_suspend_result_t, bool>,
         "await_suspend() must return 'void' or 'bool'.");

      if (awaiter.await_suspend(handle_t::from_promise(p)))
      {
        <return-to-caller-or-resumer>
      }
    }

    <resume-point> //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 协程示例来展示协程是如何运行的。

 

图片

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

 

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

#include <coroutine>
#include <iostream>
#include <thread>

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<task::promise_type>::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<task::promise_type> 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<task::promise_type> 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 协程还是挺难的,这时就需要协程库了,协程库可以大幅降低使用协程的难度。

 

图片

为什么需要一个协程库

 

通过前面的介绍可以看到,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 来简化异步编程。

 

图片

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<void> PrintThreadId(){
    std::cout<<"thread id="<<std::this_thread::get_id()<<"\n";
    co_return;
}

Lazy<void> TestPrintThreadId(async_simple::executors::SimpleExecutor &executor){
    std::cout<<"thread id="<<std::this_thread::get_id()<<"\n";
    PrintThreadId().via(&executor).detach();
    co_return;
}

int main() {
    async_simple::executors::SimpleExecutor executor(/*thread_num=*/1);
    async_simple::coro::syncAwait(TestPrintThreadId(executor));
    return 0;
}

借助 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)。

 

图片

性能

 

使用 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 的性能还是比较出色的,未来还会持续去优化改进。

 

图片

总结

 

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

posted @ 2022-04-16 21:43  CharyGao  阅读(235)  评论(0编辑  收藏  举报