关于c++:C20协程学习

51次阅读

共计 8011 个字符,预计需要花费 21 分钟才能阅读完成。

导语 | 本文推选自腾讯云开发者社区 -【技思广益 · 腾讯技术人原创集】专栏。该专栏是腾讯云开发者社区为腾讯技术人与宽泛开发者打造的分享交换窗口。栏目邀约腾讯技术人分享原创的技术积淀,与宽泛开发者互启迪共成长。本文作者是腾讯后盾开发工程师杨良聪。

协程 (coroutine) 是在执行过程中能够被挂起,在后续能够被复原执行的函数。在 C ++20 中,当一个函数外部呈现了 co_await、co_yield、co_return 中的任何一个时,这个函数就是一个协程。

C++20 协程的一个简略的示例代码:

coro_ret<int> number_generator(int begin, int count) {
    std::cout << "number_generator invoked." << std::endl;
    for (int i=begin; i<count; ++i) {co_yield i;}
    co_return;
}
int main(int argc, char* argv[])
{auto g = number_generator(1, 10);
    std::cout << "begin to run!" << std::endl;
    while(!g.resume()) {std::cout << "got number:" << g.get() << std::endl;
    }
    std::cout << "coroutine done, return value:" << g.get() << std::endl;
    return 0;
}

number_generator 内呈现了 co_yield 和 co_return 所以这不是一个一般的函数,而是一个协程,每当程序执行到第 4 行 co_yield i;时,协程就会挂起,程序的控制权会回到调用者那里,直到调用者调用 resume 办法,此时会复原到上次协程 yield 的中央,持续开始执行。

Promise

number_generator 的返回类型是 coro_ret<int>,而协程自身的代码中并没有通过 return 返回这个类型的数据,这就是 C ++20 里实现协程的一个关键点: 协程的返回类型 T 中,必须有 T::promise_type 这个类型定义,这个类型要实现几个接口。还是先看代码:

//!coro_ret 协程函数的返回值,外部定义 promise_type,承诺对象
template <typename T>
struct coro_ret
{
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;
    //! 协程句柄
    handle_type coro_handle_;
    
    //!promise_type 就是承诺对象,承诺对象用于协程内外交换
    struct promise_type
    {promise_type() {std::cout << "promise constructor invoded." << std::endl;}
        ~promise_type() = default;

        //! 生成协程返回值
        auto get_return_object()
        {
            std::cout << "get_return_object invoked." << std::endl;
            return coro_ret<T>{handle_type::from_promise(*this)};
        }

        //! 留神这个函数, 返回的就是 awaiter
        //! 如果返回 std::suspend_never{},就不挂起,//! 返回 std::suspend_always{} 挂起
        //! 当然你也能够返回其余 awaiter
        auto initial_suspend()
        {//return std::suspend_never{};
            std::cout << "initial_suspend invoked." << std::endl;
            return std::suspend_always{};}
        //!co_return 后这个函数会被调用
        /*
        void return_value(const T&amp; v)
        {
            return_data_ = v;
            return;
        }
        */

        void return_void()
        {std::cout << "return void invoked." << std::endl;}

        //!
        auto yield_value(const T&amp; v)
        {
            std::cout << "yield_value invoked." << std::endl;
            return_data_ = v;
            return std::suspend_always{};
            //return std::suspend_never{};}
        //! 在协程最初退出后调用的接口。auto final_suspend() noexcept
        {
            std::cout << "final_suspend invoked." << std::endl;
            return std::suspend_always{};}
        //
        void unhandled_exception()
        {
            std::cout << "unhandled_exception invoked." << std::endl;
            std::exit(1);
        }
        // 返回值
        T return_data_;
    };

    coro_ret(handle_type h)
            : coro_handle_(h)
    { }
    ~coro_ret()
    {
        //! 自行销毁
        if (coro_handle_)
        {coro_handle_.destroy();
        }
    }

    //! 复原协程,返回是否完结
    bool resume()
    {if (!coro_handle_.done()) {  //! 如果曾经 done 了,再调用 resume,会导致 coredump
            coro_handle_.resume();}
        return coro_handle_.done();}

    bool done() const
    {return coro_handle_.done();
    }

    //! 通过 promise 获取数据,返回值
    T get()
    {return coro_handle_.promise().return_data_;
    }

};

coro_ret 是个自定义的构造,为了能作为协程的返回值,须要定义一个 promise_type。这个类型须要实现如下的接口:

  • coro_ret<T> get_return_object() 这个接口要能用 promise 本人的实例结构出一个协程的返回值,会在协程正在运行前进行调用,这个接口的返回值会作为协程的返回值。
  • awaiter initial_suspend() 这个接口会在协程被创立(也就是第一次调用),真正运行前,被调用,如果这个接口返回的是 std::suspend_never{},那么协程一创立进去,就会立即执行;如果返回的是 std::suspend_always{},那么协程被创立进去时,会处于挂起状态,不会立即执行,须要调用者被动 resume 才会触发第一次执行。这两个值其实都是 awaiter 类型,前面再解释这个类型。
  • awaiter yield_value(T v) 这个接口会在 co_yield v 时被调用,把 co_yield 前面跟着的值 v 做为参数传入,这里个别就是把这个值保留下来,提供给协程的调用者,返回值也是 awaiter,这里个别返回的是 std::suspend_always{}。
  • void return_value(T v) 这个接口会在 co_return v 时被调用,把 co_return 前面跟着的值 v 作为参数传入,这里个别就是把这个值保留下来,提供给协程调用者。
  • void return_void() 如果 co_return 前面没有接任何值,那么就会调用这个接口。return_void 和 return_value 只能抉择一个实现,否则会报编译谬误。
  • awaiter final_suspend() 在协程最初退出后调用的接口,如果返回 std::suspend_always 则须要用户自行调用 coroutine_handle 的 destroy 接口来开释协程相干的资源;如果返回 std::suspend_never 则在协程完结后,协程对应的 handle 就曾经为空,不能再调用 destroy 了(会 coredump)
  • void unhandled_exception()如果协程内的代码抛出了异样,那么这个接口会被调用。

协程相干对象

能够看出 promise 类的工作次要是两个: 一是定义协程的执行流程,次要接口是 initial_suspend,final_suspend,二是负责协程和调用者之间的数据传递,次要接口是 yield_value 和 return_value。

std::coroutine_handle<promise_type> 是协程的管制句柄类,最重要的接口是 promise、resume,前者能够取得协程的 promise 对象,后者能够复原协程的运行。此外还有 destroy 接口,用来销毁协程实例,done 接口用于返回协程是否曾经完结运行。通过 std::coroutine_handle<promise_type>::from_promise()办法,能够从 promise 实例取得对应的 handle。

coro_ret 中其余几个接口 resume,done 和 get_data 不是必须的,只是为了方便使用而存在。

总结一下,一个协程与这几个对象关联在一起:

  • promise
  • coroutine handle
  • coroutine state

这是个在堆上调配的外部对象,没有裸露给开发者,是用来保留协程内相干数据和状态的,具体来说就是:

  • promise 对象
  • 传给协程的参数
  • 以后挂终点的相干数据
  • 生命周期逾越挂终点的长期变量和本地变量,也就是在 resume 后须要复原进去的变量。

协程的创立

长期总结

要在 c ++20 里实现一个协程,须要定义一个协程的返回类型 T,这个 T 内须要定义一个 promise_type 的类型,这个类型要实现几个指定的接口,这样就足够了。这样,要开发一个蕴含异步操作的协程,代码的构造大抵会是这样的:

coro_return<T> logic() {
    // 发动异步操作
    some_async_oper();
    co_yield xxx
     
     // 复原执行了,要先检查和取得异步操作的后果
     auto result = get_async_oper_result()
     do_some_thing(result)

     co_return
}

int main() {auto co_ret = logic();
  // 循环查看异步操作是否完结
  while(true) {auto result = get_async_result();
      if (result) {
          // 异步操作完结了,复原协程的运行,要把后果传过来
          co_ret.resume()
          break;
      }
  }
}

能够看到,在协程外部,发动异步操作和获取后果,被 yield 宰割为了两步,和同步代码还是有着显著的区别。这时,co_await 就能够施展它的作用了,应用了 co_await 后的协程代码会是这样的

coro_return<T> logic() {auto result = co_await some_async_oper();
    do_some_thing(result);
}

这样就和同步代码就根本没有区别了,除了这个 co_await

  • co_await

co_await 最常见的应用形式为 auto ret=co_await expr,co_await 后跟一个表达式,整个语句的执行过程有多种状况,是比较复杂的。这里形容的是简化版本,次要是简化了 promise.await_transform 的作用,以及 awaitable 对象,能够点击上面链接看残缺的形容。这里假设协程的 promise_type 没有实现 await_transform 办法。

https://en.cppreference.com/w…

用代码表白,是这样:

 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));
      <return-to-caller-or-resumer>
    }
    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>
  }

  return awaiter.await_resume();
  • 首先是 expr 求值
  • expr 表达式的返回值类型 (awaiter) 必须实现这几个接口: await_ready、await_suspend 和 await_resume。
  • await_ready 被调用,如果返回 true,那么协程齐全不会被挂起,间接会去调用 await_resume()接口,把这个接口作为 await 的返回值,继续执行协程。
  • 如果 await_ready 返回 false,那么协程会被挂起,而后调用 await_suspend 接口,并将协程的句柄传给这个接口。留神,此时协程曾经被挂起,但控制权还没有交给调用者。
  • 如果 await_suspend 接口的返回类型是 void,或者返回类型是 bool,返回值是 true,那么就将控制权交还给调用者。
  • 如果 await_suspend 接口返回的是 false,那么协程会被 resume,并接着调用 await_resume,把这个接口作为 await 的返回值,继续执行协程。
  • 如果后面的步骤中,协程被挂起了,那么当协程被调用者 resume 的时候,会先调用 await_resume 接口,把这个接口作为 await 的返回值,继续执行协程。
  • co_await 的例子

以封装一个 socket 的 connect 操作为例,咱们心愿能像这样在协程中去 connect 一个 tcp 地址:

coro_ret<int> connect_addr_example(io_service&amp; service, const char* ip, int16_t port)
{
    coroutine_tcp_client client;
    // 异步连贯, service 是对 epoll 的一个封装
    auto connect_ret = co_await client.connect(ip, port, 3, service);
    printf("client.connect return:%d\n", connect_ret);
    if (connect_ret)
    {printf("connect failed, coroutine return\n");
        co_return -1;
    }

    do_something_with_connect(client);

    co_return 0;
}

那么须要做的事件是

  • 第 5 行中的 client.connect 首先发动一个异步连贯的申请(设置 socket 为 noneblock,而后 connect, 并把 socket 和本人的指针退出 epoll),返回的类型须要是一个 awaiter,也就是要实现这三个接口:await_ready、await_suspend 和 await_resume
  • 在 await_ready 中,判断连贯是否曾经建设了(某些状况下 connect 会立即胜利返回),或者出错了(比方给 connect 传了非法的参数),此时须要返回 true,协程就齐全不会挂起。其余状况须要返回 false,让协程挂起
  • 在 await_suspend 中,能够保留下传入的协程句柄,而后间接返回 true。
  • 在 await_resume 中,判断下连贯的后果,胜利返回 0,其余状况返回错误码。
  • 协程外的主循环里,应用 epoll 进行轮询,当对应的句柄有事件时(胜利连贯、超时、出错),就取出对应的 client 指针,设置好连贯的后果,并 resume 协程。

大抵的代码如下:

   struct connect_awaiter
    {
        coroutine_tcp_client& tcp_client_;

        // co_await 开始会调用,依据返回值决定是否挂起协程
        bool await_ready()
{auto status = tcp_client_.status();
            switch(status)
            {
            case ERROR:
                printf("await_ready: status error invalid, should not suspend!\n");
                return true;
            case CONNECTED:
                printf("await_ready: already connected, should not suspend!\n");
                return true;
            default:
                printf("await_ready: status:%d, return false.\n", status);
                return false;
            }

        }

        // 在协程挂起后会调用这个,如果返回 true,会返回调用者,如果返回 false,会立即 resume 协程
        bool await_suspend(std::coroutine_handle<> awaiting)
{printf("await_suspend invoked.\n");
            tcp_client_.handle_ = awaiting;
            return true;
        }

        // 在协程 resume 的时候会调用这个,这个的返回值会作为 await 的返回值
        int await_resume()
{int ret = tcp_client_.status() == CONNECTED ? 0 : -1;
            printf("awati_resume invoked, ret:%d\n", ret);
            return ret;
        }
    };

理解了 co_await 之后,能够回头看一下之前的内容,后面屡次呈现的 std::suspend_never 和 std::suspend_always 就是两个预约义好的 awaiter,也有那三个接口的定义,有趣味的同学能够看看对应的源代码。promise 对象的 initial_suspend、final_suspend、yield_value 返回的都是 awaiter,实际上零碎执行的是 co_await promise.initial_suspend(),co_yield 实际上执行的是 co_await promise.yield_value()。如果有须要,也能够返回自定义的 awaiter。

总结

能够看出 C ++20 给出了一个非常灵活、有很弱小可定制性的协程机制,但短少根本的库反对,连写一个最简略的协程都须要开发者付出不少了解和学习的老本,目前的状态只能说是打了一个的地基,在 C ++23 中,为协程提供库的反对是重要的指标之一,能够刮目相待。

参考资料:

1. 协程(C++20)

2.C++ 协程:理解运算符 co_await

3.C++20 行将到来的 coroutine 是否与 Golang 的 goroutine 媲美?

如果你是腾讯技术内容创作者,腾讯云开发者社区诚邀您退出【腾讯云原创分享打算】,支付礼品,助力职级降职。

正文完
 0