Se prĂ©parer pour C ++ 20. Étude de cas rĂ©elle Coroutines TS

En C ++ 20, l'opportunitĂ© de travailler avec des coroutines prĂȘtes Ă  l'emploi est sur le point d'apparaĂźtre. Ce sujet est proche et intĂ©ressant pour nous chez Yandex.Taxi (pour nos propres besoins, nous dĂ©veloppons un framework asynchrone). Par consĂ©quent, aujourd'hui, nous allons montrer aux lecteurs de Habr en utilisant un exemple rĂ©el comment travailler avec des coroutines sans pile C ++.

À titre d'exemple, prenons quelque chose de simple: sans travailler avec des interfaces rĂ©seau asynchrones, des minuteries asynchrones, constituĂ©es d'une seule fonction. Par exemple, essayons de rĂ©aliser et de réécrire cette "nouille" Ă  partir des rappels:


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(); } }); } 


Présentation


Les coroutines ou coroutines sont la capacitĂ© d'empĂȘcher une fonction de s'exĂ©cuter dans un emplacement prĂ©dĂ©terminĂ©; passer quelque part l'Ă©tat entier de la fonction arrĂȘtĂ©e avec les variables locales; exĂ©cutez la fonction du mĂȘme endroit oĂč nous l'avons arrĂȘtĂ©e.
Il existe plusieurs saveurs de coroutines: empilables et empilables. Nous en reparlerons plus tard.

ÉnoncĂ© du problĂšme


Nous avons plusieurs files d'attente de tùches. Chaque tùche contient certaines tùches: il y a une file d'attente pour dessiner des graphiques, il y a une file d'attente pour les interactions réseau et il y a une file d'attente pour travailler avec un disque. Toutes les files d'attente sont des instances de la classe WorkQueue qui ont la méthode void PushTask (tùche std :: function <void ()>);. Les files d'attente vivent plus longtemps que toutes les tùches qui y sont placées (la situation selon laquelle nous avons détruit une file d'attente lorsqu'elle contient des tùches en suspens ne devrait pas se produire).

La fonction FuncToDealWith () de l'exemple exécute une logique dans différentes files d'attente et, selon les résultats de l'exécution, place une nouvelle tùche dans la file d'attente.

Nous réécrivons les «nouilles» des rappels sous la forme d'un pseudo-code linĂ©aire, marquant dans quelle file d'attente le code sous-jacent doit ĂȘtre exĂ©cutĂ©:

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

Environ ce résultat que je veux atteindre.

Il y a des limitations:

  • Les interfaces de file d'attente ne peuvent pas ĂȘtre modifiĂ©es - elles sont utilisĂ©es dans d'autres parties de l'application par des dĂ©veloppeurs tiers. Vous ne pouvez pas casser le code dĂ©veloppeur ou ajouter de nouvelles instances de file d'attente.
  • Vous ne pouvez pas modifier la façon dont vous utilisez la fonction FuncToDealWith. Vous pouvez seulement changer son nom, mais vous ne pouvez pas lui faire retourner des objets que l'utilisateur doit garder Ă  la maison.
  • Le code rĂ©sultant doit ĂȘtre aussi productif que l'original (ou mĂȘme plus productif).

Solution


Réécrire la fonction FuncToDealWith


Dans Coroutines TS, le réglage de la coroutine se fait en définissant le type de la valeur de retour de la fonction. Si le type satisfait certaines exigences, vous pouvez utiliser les nouveaux mots clés co_await / co_return / co_yield dans le corps de la fonction. Dans cet exemple, pour basculer entre les files d'attente, nous utiliserons 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(); } 

Il s'est avéré trÚs similaire au pseudocode de la derniÚre section. Toute la «magie» pour travailler avec des coroutines est cachée dans la classe CoroTask.

CoroTask


Dans le cas le plus simple (dans notre) cas, le contenu de la classe "tuner" de la coroutine est constitué d'un seul alias:

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


promise_type est un type de donnĂ©es que nous devons Ă©crire nous-mĂȘmes. Il contient une logique qui dĂ©crit:

  • que faire Ă  la sortie de coroutine
  • que faire lorsque vous entrez pour la premiĂšre fois dans la corutine
  • qui libĂšre des ressources
  • que faire avec les exceptions sortant de la coroutine
  • Comment crĂ©er un objet CoroTask
  • que faire si Ă  l'intĂ©rieur des corutines appelĂ©es co_yield

L'alias promise_type doit ĂȘtre appelĂ© de cette façon. Si vous changez le nom d'alias en quelque chose d'autre, le compilateur jurera et dira que vous avez mal orthographiĂ© CoroTask. Le nom CoroTask peut ĂȘtre modifiĂ© comme vous le souhaitez.

Mais pourquoi cette CoroTask est-elle nécessaire si tout est décrit dans promise_type?
Dans les cas plus complexes, vous pouvez crĂ©er une CoroTask qui vous permettra de communiquer avec une coroutine arrĂȘtĂ©e, d'envoyer et de recevoir des donnĂ©es de celle-ci, de la rĂ©veiller et de la dĂ©truire.

PromiseType


Arriver à la partie amusante. Nous décrivons le comportement de la corutine:

 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; // ... <  > }; 

Dans le code ci-dessus, vous pouvez remarquer le type de donnĂ©es std :: experimental :: suspend_never. Il s'agit d'un type de donnĂ©es spĂ©cial qui indique que la corutine n'a pas besoin d'ĂȘtre arrĂȘtĂ©e. Il y a aussi son contraire - le type std :: experimental :: suspend_always, qui vous dit d'arrĂȘter la corutine. Ces types sont les soi-disant attendables. Si vous ĂȘtes intĂ©ressĂ© par leur structure interne, ne vous inquiĂ©tez pas, nous Ă©crirons bientĂŽt nos Awaitables.

L'endroit le plus simple dans le code ci-dessus est final_suspend (). La fonction a des effets inattendus. Donc, si nous n'arrĂȘtons pas l'exĂ©cution dans cette fonction, les ressources allouĂ©es Ă  la coroutine par le compilateur nettoieront le compilateur pour nous. Mais si dans cette fonction nous arrĂȘtons l'exĂ©cution de coroutine (par exemple, en retournant std :: experimental :: suspend_always {}), alors vous devrez libĂ©rer manuellement des ressources de quelque part Ă  l'extĂ©rieur: vous devrez enregistrer un pointeur intelligent pour coroutiner quelque part et l'appeler explicitement dĂ©truire (). Heureusement, cela n'est pas nĂ©cessaire pour notre exemple.

INCORRECT PromiseType :: yield_value


Il semble que l'Ă©criture de PromiseType :: yield_value soit assez simple. Nous avons une ligne; coroutine, qui doit ĂȘtre suspendu et Ă  ce tour mettre:

 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{}; } 

Et ici, nous sommes confrontĂ©s Ă  un problĂšme trĂšs important et difficile Ă  dĂ©tecter. Le fait est que nous mettons d'abord la coroutine dans la file d'attente et que nous la suspendons ensuite. Il peut arriver que la coroutine soit supprimĂ©e de la file d'attente et commence Ă  ĂȘtre exĂ©cutĂ©e avant mĂȘme de la suspendre dans le thread actuel. Cela entraĂźnera une condition de concurrence, un comportement indĂ©fini et des erreurs d'exĂ©cution complĂštement folles.

Correct PromiseType :: yield_value


Donc, nous devons d'abord arrĂȘter la corutine et ensuite l'ajouter Ă  la file d'attente. Pour ce faire, nous allons Ă©crire notre Awaitable et l'appeler 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}; } 

Les classes std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution et autres Awaitables devraient contenir 3 fonctions. wait_ready est appelĂ© pour vĂ©rifier si la coroutine doit ĂȘtre arrĂȘtĂ©e. wait_suspend est appelĂ© aprĂšs l'arrĂȘt du programme, le handle de la coroutine arrĂȘtĂ©e lui est transmis. wait_resume est appelĂ© lorsque l'exĂ©cution de la coroutine reprend.
Et que peut-on écrire dans des skrabs triangulaires std :: experimental :: coroutine_handle <>?
Vous pouvez spĂ©cifier le type PromiseType ici, et l'exemple fonctionnera exactement de la mĂȘme maniĂšre :)

std :: experimental :: coroutine_handle <> (alias std :: experimental :: coroutine_handle <void>) est le type de base pour tous std :: experimental :: coroutine_handle <DataType>, oĂč le DataType doit ĂȘtre le type_de_promesse de la coroutine actuelle. Si vous n'avez pas besoin d'accĂ©der au contenu interne de DataType, vous pouvez Ă©crire std :: experimental :: coroutine_handle <>. Cela peut ĂȘtre utile dans les endroits oĂč vous souhaitez faire abstraction d'un type particulier de promise_type et utiliser l'effacement de type.

Terminé


Vous pouvez compiler, exécuter l'exemple en ligne et expérimenter de toutes les maniÚres .

Et si je n'aime pas co_yield, puis-je le remplacer par quelque chose?
Peut ĂȘtre remplacĂ© par co_await. Pour ce faire, ajoutez la fonction suivante Ă  PromiseType:

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

Mais que faire si je n'aime pas co_await?
La chose est mauvaise. Rien Ă  changer.


Feuille de triche


CoroTask est une classe qui ajuste le comportement d'une coroutine. Dans les cas plus complexes, il vous permet de communiquer avec une coroutine arrĂȘtĂ©e et de prendre toutes les donnĂ©es de celle-ci.

CoroTask :: promise_type dĂ©crit comment et quand les coroutines s'arrĂȘtent, comment libĂ©rer des ressources et comment construire CoroTask.

Les fichiers attendus (std :: experimental :: suspend_always, std :: experimental :: suspend_never, schedule_for_execution et autres) indiquent au compilateur quoi faire avec la coroutine Ă  un point spĂ©cifique (si arrĂȘter la corutine, quoi faire avec la corutine arrĂȘtĂ©e et quoi faire lorsque la corutine se rĂ©veille) .

Optimisations


Il y a une faille dans notre PromiseType. MĂȘme si nous sommes actuellement en cours d'exĂ©cution dans la file d'attente de tĂąches correcte, appeler co_yield suspendra toujours la coroutine et la replacera dans la mĂȘme file d'attente de tĂąches. Il serait beaucoup plus optimal de ne pas arrĂȘter l'exĂ©cution de la coroutine, mais de poursuivre immĂ©diatement l'exĂ©cution.

Corrigeons cette faille. Pour ce faire, ajoutez un champ privé à PromiseType:

 WorkQueue* current_queue_ = nullptr; 

Dans celui-ci, nous tiendrons un pointeur sur la file d'attente dans laquelle nous sommes actuellement en cours d'exécution.

Ensuite, ajustez 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}; } 

Ici, nous avons modifiĂ© Schedule_for_execution :: Wait_ready (). Maintenant, cette fonction indique au compilateur que la coroutine n'a pas besoin d'ĂȘtre suspendue si la file d'attente de tĂąches actuelle correspond Ă  celle sur laquelle nous essayons de dĂ©marrer.

C'est fait. Vous pouvez expérimenter de toutes les maniÚres .

À propos des performances


Dans l'exemple d'origine, à chaque appel à WorkQueue :: PushTask (std :: function <void ()> f), nous avons créé une instance de la classe std :: function <void ()> à partir du lambda. Dans le code réel, ces lambdas sont souvent de taille assez importante, c'est pourquoi std :: function <void ()> est obligé d'allouer dynamiquement de la mémoire pour stocker les lambdas.

Dans l'exemple coroutine, nous créons des instances de std :: function <void ()> à partir de std :: experimental :: coroutine_handle <>. La taille de std :: experimental :: coroutine_handle <> dépend de l'implémentation, mais la plupart des implémentations essaient de garder sa taille au minimum. Donc, sur clang, sa taille est égale à sizeof (void *). Lors de la construction de std :: function <void ()>, l'allocation dynamique ne se produit pas à partir de petits objets.
Total - avec Coroutines, nous nous sommes débarrassés de plusieurs allocations dynamiques inutiles.

Mais! Souvent, le compilateur ne peut pas simplement enregistrer toute la coroutine sur la pile. De ce fait, une allocation dynamique supplémentaire est possible lors de la saisie de CoroToDealWith.

Stackless vs stackful


Nous venons de travailler avec les coroutines Stackless, qui nĂ©cessitent le support du compilateur pour fonctionner. Il existe Ă©galement des Coroutines empilables qui peuvent ĂȘtre implĂ©mentĂ©es entiĂšrement au niveau de la bibliothĂšque.

Les premiers permettent une allocation de mémoire plus économique, potentiellement ils sont mieux optimisés par le compilateur. Les seconds sont plus faciles à implémenter dans les projets existants, car ils nécessitent moins de modifications de code. Cependant, dans cet exemple, vous ne pouvez pas sentir la différence, des exemples plus compliqués sont nécessaires.

Résumé


Nous avons examinĂ© l'exemple de base et obtenu une classe universelle CoroTask, qui peut ĂȘtre utilisĂ©e pour crĂ©er d'autres coroutines.

Le code avec lui devient plus lisible et légÚrement plus productif qu'avec l'approche naïve:
ÉtaitAvec des coroutines
 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(); } 

Par dessus bord, il y a eu des moments:

  • comment appeler une autre coroutine de la corutine et attendre sa fin
  • quels trucs utiles vous pouvez entasser dans CoroTask
  • un exemple qui fait la diffĂ©rence entre Stackless et Stackful

Autre


Si vous souhaitez en savoir plus sur les autres nouveautés du langage C ++ ou communiquer personnellement avec vos collÚgues sur les avantages, consultez la conférence C ++ Russia. La prochaine se tiendra le 6 octobre à Nijni Novgorod .

Si vous avez des problÚmes liés au C ++ et que vous souhaitez améliorer quelque chose dans le langage ou si vous souhaitez simplement discuter des innovations possibles, alors bienvenue sur https://stdcpp.ru/ .

Eh bien, si cela vous surprend que Yandex.Taxi a un grand nombre de tĂąches qui ne sont pas liĂ©es aux graphiques, alors j'espĂšre que cela s'est avĂ©rĂ© ĂȘtre une agrĂ©able surprise pour vous :) Venez nous rendre visite le 11 octobre , nous parlerons de C ++ et plus encore.

Source: https://habr.com/ru/post/fr420861/


All Articles