为C ++做准备20。 协程TS真实案例研究

在C ++ 20中,即将出现使用协程的机会。 Yandex.Taxi对我们而言,这个主题是非常有趣的(出于我们自己的需要,我们正在开发一个异步框架)。 因此,今天,我们将通过一个真实的例子向Habr的读者展示如何使用C ++无堆栈协程。

作为示例,让我们简单地做一些事情:不使用异步网络接口,而是由一个函数组成的异步计时器。 例如,让我们尝试通过回调实现并重写此“面条”:


void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto finally = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetworkThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(finally); }); } else { writerQueue.PushTask(finally); } }); } else { finally(); } }); } 


引言


协程或协程是在预定位置停止执行功能的能力。 将停止函数的整个状态以及局部变量传递到某个地方; 从我们停止该功能的同一位置运行该功能。
协程有几种口味:无堆叠和堆叠。 我们稍后再讨论。

问题陈述


我们有几个任务队列。 每个任务都包含某些任务:有一个用于绘制图形的队列,有一个用于网络交互的队列,并且有一个用于处理磁盘的队列。 所有队列都是具有无效PushTask方法(std :: function <void()>任务);的WorkQueue类的实例。 队列的寿命比放置在其中的所有任务的寿命长(当队列中有未完成的任务时,我们应该销毁队列的情况)。

该示例中的FuncToDealWith()函数在不同的队列中执行一些逻辑,并根据执行结果将新任务放入队列中。

我们以线性伪代码的形式重写回调的“面条”,标记底层代码应在哪个队列中执行:

 void CoroToDealWith() { InCurrentThread(); // =>   writerQueue InWriterThread1(); if (NeedNetwork()) { // =>   networkQueue auto v = InNetworkThread(); if (v) { // =>   UIQueue InUIThread(); } } // =>   writerQueue InWriterThread2(); ShutdownAll(); } 

我想要达到的结果大约是。

有一些限制:

  • 队列接口不能更改-第三方开发人员在应用程序的其他部分使用它们。 您不能破坏开发人员代码或添加新的队列实例。
  • 您不能更改使用FuncToDealWith函数的方式。 您只能更改其名称,但不能使其返回用户必须在家中保留的任何对象。
  • 生成的代码应与原始代码一样高效(甚至更高效率)。

解决方案


重写FuncToDealWith函数


在协程TS中,协程调整是通过设置函数的返回值的类型来完成的。 如果类型满足某些要求,则可以在函数体内使用新的关键字co_await / co_return / co_yield。 在此示例中,要在队列之间切换,我们将使用co_yield:

 CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetworkThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } 

结果与上一节中的伪代码非常相似。 在CoroTask类中隐藏了使用协程的所有“魔术”。

冠捷


在最简单的情况下(在我们的情况下),协程的“调谐器”类的内容仅包含一个别名:

 #include <experimental/coroutine> struct CoroTask { using promise_type = PromiseType; }; 


promise_type是我们必须自己编写的数据类型。 它包含描述以下内容的逻辑:

  • 退出协程时该怎么办
  • 首次输入corutin时该怎么办
  • 谁释放资源
  • 从协程飞出的异常怎么办
  • 如何创建CoroTask对象
  • 如果corutins内部称为co_yield怎么办

别名promise_type必须以这种方式调用。 如果您将别名更改为其他名称,则编译器会发誓说您拼写的CoroTask错误。 您可以根据需要更改名称CoroTask。

但是,如果在promise_type中描述了所有内容,为什么需要CoroTask?
在更复杂的情况下,您可以创建CoroTask,使您可以与停止的协程进行通信,从中接收和发送数据,唤醒和销毁它。

PromiseType


进入有趣的部分。 我们描述了corutin的行为:

 class WorkQueue; // forward declaration class PromiseType { public: //      `co_return;`     , ... void return_void() const { /* ...    :) */ } //        ,  CoroTask, ... auto initial_suspend() const { // ...       . return std::experimental::suspend_never{}; } //      - , ... auto final_suspend() const { // ...        //      . return std::experimental::suspend_never{}; } //     , ... void unhandled_exception() const { // ...   (  ). std::terminate(); } //    CoroTask,    , ... auto get_return_object() const { // ...  CoroTask. return CoroTask{}; } //     co_yield, ... auto yield_value(WorkQueue& wq) const; // ... <  > }; 

在上面的代码中,您会注意到数据类型std :: experimental :: suspend_never。 这是一种特殊的数据类型,表示不需要停止corutin。 它也有相反的含义-类型std :: experimental :: suspend_always,它告诉您停止corutin。 这些类型是所谓的等待。 如果您对他们的内部结构感兴趣,那么不用担心,我们将尽快写我们的Awaitables。

上面的代码中最重要的地方是final_suspend()。 该功能具有意想不到的效果。 因此,如果我们不停止执行此函数,则编译器分配给协程的资源将为我们清除编译器。 但是,如果在此函数中我们停止执行协程(例如,通过返回std :: experimental :: suspend_always {}),那么您将必须手动释放外部某个地方的资源:您将必须保存指向协程的智能指针并显式调用它销毁()。 幸运的是,对于我们的示例,这不是必需的。

正确的PromiseType :: yield_value


似乎编写PromiseType :: yield_value非常简单。 我们有一条线; 协程,必须将其暂停并依次放置:

 auto PromiseType::yield_value(WorkQueue& wq) { //        std::experimental::coroutine_handle<> this_coro = std::experimental::coroutine_handle<>::from_promise(*this); //    .  this_coro  operator(),    // wq      .   , //     ,  operator(),  //   . wq.PushTask(this_coro); //     . return std::experimental::suspend_always{}; } 

在这里,我们面临着非常大且难以发现的问题。 事实是,我们首先将协程放入队列中,然后才将其挂起。 协程可能会从队列中删除并甚至在我们将其挂起在当前线程中之前就开始执行。 这将导致争用条件,不确定的行为以及完全疯狂的运行时错误。

正确的 PromiseType :: yield_value


因此,我们需要首先停止corutin,然后才将其添加到队列中。 为此,我们将编写我们的Awaitable并将其命名为schedule_for_execution:

 auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { WorkQueue& wq; constexpr bool await_ready() const noexcept { return false; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; return schedule_for_execution{wq}; } 

类std :: experimental :: suspend_always,std :: experimental :: suspend_never,schedule_for_execution和其他Awaitables应该包含3个函数。 调用await_ready来检查协程是否应该停止。 程序停止运行后,将调用await_suspend,将停止的协程的句柄传递给它。 当协程执行恢复时,将调用await_resume。
三角形skrabs std ::实验:: coroutine_handle <>中可以写什么?
您可以在那里指定PromiseType类型,该示例将完全相同:)

std ::实验:: coroutine_handle <>(又名std ::实验:: coroutine_handle <void>)是所有std ::实验:: coroutine_handle <DataType>的基本类型,其中DataType必须是当前协程的promise_type。 如果不需要访问DataType的内部内容,则可以编写std ::实验:: coroutine_handle <>。 在您要从特定类型的promise_type抽象并使用类型擦除的地方,这可能很有用。

完成


您可以在线编译,运行示例并以各种方式进行实验

如果我不喜欢co_yield,可以用其他东西代替它吗?
可以替换为co_await。 为此,将以下函数添加到PromiseType中:

 auto await_transform(WorkQueue& wq) { return yield_value(wq); } 

但是,如果我不喜欢co_await怎么办?
事情不好。 没什么改变。


备忘单


CoroTask是调整协程行为的类。 在更复杂的情况下,它允许您与停止的协程通信并从中获取任何数据。

CoroTask :: promise_type描述了协程停止的方式和时间,释放资源的方式以及构造CoroTask的方式。

Awaitables(std ::实验:: suspend_always,std ::实验:: suspense_never,schedule_for_execution等)告诉编译器在特定点如何处理协程(是否需要停止corutin,如何处理停止的corutin以及在corutin唤醒时如何处理) 。

最佳化


我们的PromiseType有一个缺陷。 即使我们当前正在正确的任务队列中运行,调用co_yield仍将挂起协程并将其重新放置在同一任务队列中。 最好不要停止协程的执行,而是立即继续执行。

让我们修复此缺陷。 为此,将一个私有字段添加到PromiseType中:

 WorkQueue* current_queue_ = nullptr; 

在其中,我们将保留一个指向当前正在执行的队列的指针。

接下来,调整PromiseType :: yield_value:

 auto PromiseType::yield_value(WorkQueue& wq) { struct schedule_for_execution { const bool do_resume; WorkQueue& wq; constexpr bool await_ready() const noexcept { return do_resume; } void await_suspend(std::experimental::coroutine_handle<> this_coro) const { wq.PushTask(this_coro); } constexpr void await_resume() const noexcept {} }; const bool do_not_suspend = (current_queue_ == &wq); current_queue_ = &wq; return schedule_for_execution{do_not_suspend, wq}; } 

在这里,我们调整了schedule_for_execution :: await_ready()。 现在,此函数告诉编译器,如果当前任务队列与我们尝试启动的协程队列不匹配,则协程不需要挂起。

做完了 您可以进行各种尝试

关于性能


在原始示例中,每次调用WorkQueue :: PushTask(std :: function <void()> f)时,我们都从lambda创建了一个std :: function <void()>类的实例。 在实际代码中,这些lambda通常非常大,这就是为什么std :: function <void()>被迫动态分配内存来存储lambda的原因。

在协程示例中,我们从std ::实验:: coroutine_handle <>创建std :: function <void()>的实例。 std :: experimental :: coroutine_handle <>的大小取决于实现,但是大多数实现都将其大小保持为最小。 因此,在clang上,其大小等于sizeof(void *)。 构造std :: function <void()>时,不会从小对象进行动态分配。
总计-使用协程,我们摆脱了一些不必要的动态分配。

但是! 编译器通常不能仅仅将所有协程保存在堆栈中。 因此,在输入CoroToDealWith时,可以进行另一种动态分配。

无堆叠与堆叠


我们只是使用Stackless协程,它们需要编译器的支持才能使用。 也有可以在库级别完全实现的Stackable Coroutines。

第一个允许更经济地分配内存,编译器可能会更好地对其进行优化。 第二个代码在现有项目中更易于实现,因为它们需要的代码修改更少。 但是,在此示例中,您不会感到与众不同,需要更复杂的示例。

总结


我们检查了基本示例,并获得了通用类CoroTask,该类可用于创建其他协程。

与天真的方法相比,带有它的代码更具可读性,并且生产力更高:
与协程
 void FuncToDealWith() { InCurrentThread(); writerQueue.PushTask([=]() { InWriterThread1(); const auto fin = [=]() { InWriterThread2(); ShutdownAll(); }; if (NeedNetwork()) { networkQueue.PushTask([=](){ auto v = InNetThread(); if (v) { UIQueue.PushTask([=](){ InUIThread(); writerQueue.PushTask(fin); }); } else { writerQueue.PushTask(fin); } }); } else { fin(); } }); } 
 CoroTask CoroToDealWith() { InCurrentThread(); co_yield writerQueue; InWriterThread1(); if (NeedNetwork()) { co_yield networkQueue; auto v = InNetThread(); if (v) { co_yield UIQueue; InUIThread(); } } co_yield writerQueue; InWriterThread2(); ShutdownAll(); } 

船上有些时刻:

  • 如何从Corutin调用另一个协程并等待其完成
  • 您可以在CoroTask中塞入哪些有用的东西
  • 一个使Stackless和Stackful有所不同的示例

其他


如果您想了解C ++语言的其他新颖性或在优点上与同事进行个人交流,请查看C ++俄罗斯会议。 下届将于10月6日在下诺夫哥罗德举行。

如果您遇到与C ++相关的痛苦,并且想要改进该语言的某些内容或者只是想讨论可能的创新,那么欢迎访问https://stdcpp.ru/

好吧,如果让Yandex.Taxi拥有大量与图形无关的任务感到惊讶,那么我希望这对您来说是一个惊喜:) 10月11访问我们 ,我们将讨论C ++等。

Source: https://habr.com/ru/post/zh-CN420861/


All Articles