En savoir plus sur Coroutines en C ++

Bonjour chers collègues.

Dans le cadre du développement du thème C ++ 20, nous sommes tombés à un moment sur un article assez ancien (septembre 2018) du journal de bord de Yandex, qui s'appelle « Se préparer pour C ++ 20. Coroutines TS avec un exemple réel ». Il se termine par le vote très expressif suivant:



«Pourquoi pas», nous avons décidé et traduit un article de David Pilarski sous le titre «Coroutines introduction». L'article a été publié il y a un peu plus d'un an, mais j'espère que vous le trouverez de toute façon très intéressant.

Alors c'est arrivé. Après beaucoup de doutes, de controverses et de préparation de cette fonctionnalité, le WG21 est parvenu à une opinion commune sur ce à quoi les coroutines devraient ressembler en C ++ - et il est très probable qu'elles seront incluses en C ++ 20. Étant donné qu'il s'agit d'une fonctionnalité majeure, je pense qu'il est temps de la préparer et de l'étudier déjà maintenant (comme vous vous en souvenez, il y a encore plus de modules, de concepts, de gammes à apprendre ...)

Beaucoup s'opposent encore à la coroutine. Souvent, ils se plaignent de la complexité de leur développement, de nombreux points de personnalisation et, éventuellement, de performances sous-optimales en raison, éventuellement, d'une allocation sous-optimisée de la mémoire dynamique (peut-être;)).

Parallèlement au développement de spécifications techniques (TS) approuvées (officiellement publiées), même des tentatives ont été faites pour développer en parallèle un autre mécanisme de la corutine. Nous parlerons ici des coroutines décrites dans TS ( spécifications techniques ). Une approche alternative, à son tour, appartient à Google. En conséquence, il s'est avéré que l'approche de Google souffre de nombreux problèmes, dont la solution nécessite souvent d'étranges fonctionnalités supplémentaires de C ++.

Au final, il a été décidé d'adopter une version de Corutin développée par Microsoft (sponsorisée par TS). C'est à propos de ces coroutines qui seront discutées dans cet article. Commençons donc par la question de ...

Que sont les coroutines?


Les coroutines existent déjà dans de nombreux langages de programmation, par exemple en Python ou C #. Les coroutines sont une autre façon de créer du code asynchrone. En quoi ils diffèrent des flux, pourquoi les coroutines doivent être implémentées en tant que fonctionnalité de langage dédié et, enfin, quelle est leur utilisation sera expliquée dans cette section.

Il y a un grave malentendu concernant ce que sont les coroutines. Selon l'environnement dans lequel ils sont utilisés, ils peuvent être appelés:

  • Coroutines sans pile
  • Empiler les coroutines
  • Ruisseaux verts
  • Les fibres
  • Gorutins

La bonne nouvelle: les corutines empilées, les ruisseaux verts, les fibres et les gorutines sont une seule et même chose (mais ils sont parfois utilisés de différentes manières). Nous en parlerons plus loin dans cet article et nous les appellerons fibres ou empiler coroutines. Mais la coroutine sans pile possède certaines fonctionnalités qui doivent être discutées séparément.

Pour comprendre les coroutines, y compris au niveau intuitif, apprenons brièvement les fonctions et (disons-le ainsi) «leur API». La façon standard de travailler avec eux est d'appeler et d'attendre la fin:

void foo(){
     return; //     
}	
foo(); //   / 

Après avoir appelé la fonction, il est déjà impossible de faire une pause ou de reprendre son travail. Vous ne pouvez effectuer que deux opérations sur les fonctions: startet finish. Lorsque la fonction est lancée, vous devez attendre qu'elle soit terminée. Si la fonction est appelée à nouveau, son exécution se poursuivra dès le début.

Avec les coroutines, la situation est différente. Vous pouvez non seulement les démarrer et les arrêter, mais également les suspendre et les reprendre. Ils sont toujours différents des flux principaux, car les coroutines elles-mêmes ne sont pas évincées (d'un autre côté, les coroutines se réfèrent généralement au flux et le flux est évincé). Pour comprendre cela, considérons un générateur défini en Python. Qu'une telle chose soit appelée générateur en Python, en C ++ ce serait appelé coroutine. Un exemple est tiré de ce site :

def generate_nums():
     num = 0
     while True:
          yield num
          num = num + 1	

nums = generate_nums()
	
for x in nums:
     print(x)
	
     if x > 9:
          break

Voici comment ce code fonctionne: un appel de fonction generate_numsconduit à la création d'un objet coroutine. À chaque étape de l'énumération d'un objet coroutine, la coroutine elle-même reprend le travail et ne l'interrompt qu'après un mot clé yielddans le code; puis l'entier suivant de la séquence est retourné (la boucle for est du sucre syntaxique pour appeler une fonction next()qui reprend coroutine). Le code termine la boucle en rencontrant une instruction break. Dans ce cas, la corutine ne se termine jamais, mais il est facile d'imaginer une situation dans laquelle la corutine atteint la fin et se termine. Comme on peut le voir, à une opération applicable korutine start, suspend, resumeet enfin,finish. [Remarque: C ++ fournit également des opérations de création et de destruction, mais elles ne sont pas importantes dans le contexte d'une compréhension intuitive de coroutine].

Coroutines comme bibliothèque


Donc, maintenant, il est à peu près clair ce que sont les coroutines. Vous savez peut-être qu'il existe des bibliothèques pour créer des objets en fibre. La question est, pourquoi avons-nous besoin de coroutines sous la forme d'une fonctionnalité de langage dédiée, et pas seulement d'une bibliothèque qui fonctionnerait avec des coroutines.

Ici, nous essayons de répondre à cette question et de démontrer la différence entre les coroutines empilées et sans pile. Cette différence est essentielle pour comprendre la corutine comme faisant partie du langage.

Empiler les coroutines


Voyons d'abord ce que sont les coroutines de pile, comment elles fonctionnent et pourquoi elles peuvent être implémentées en tant que bibliothèque. Les expliquer est relativement simple, car ils ressemblent à des flux en termes de conception.

La corutine de fibre ou de pile a une pile distincte qui peut être utilisée pour gérer les appels de fonction. Afin de comprendre exactement comment fonctionnent les coroutines de ce type, nous considérons brièvement les cadres de fonction et les appels de fonction d'un point de vue de bas niveau. Mais d'abord, parlons des propriétés des fibres.

  • Ils ont leur propre pile,
  • La durée de vie des fibres ne dépend pas du code qui les appelle (généralement elles ont un ordonnanceur défini par l'utilisateur),
  • Les fibres peuvent être détachées d'un fil et attachées à un autre,
  • Planification coopérative (la fibre doit décider de passer à une autre fibre / ordonnanceur),
  • Ne peut pas fonctionner simultanément dans le même thread.

Les effets suivants résultent des propriétés ci-dessus:

  • le changement de contexte des fibres doit être effectué par l'utilisateur des fibres, et non par l'OS (en outre, l'OS peut libérer la fibre, libérant le fil dans lequel il fonctionne),
  • Il n'y a pas de véritable course aux données entre les deux fibres, car à tout moment une seule d'entre elles peut être active,
  • Le concepteur de fibres doit être en mesure de choisir le bon endroit et l'heure, où et quand il convient de restituer la puissance de calcul à un ordonnanceur ou à un appelant éventuel.
  • Les opérations d'entrée / sortie dans la fibre doivent être asynchrones, afin que d'autres fibres puissent effectuer leurs tâches sans se bloquer.

Examinons maintenant de plus près le fonctionnement des fibres et expliquons d'abord comment la pile participe aux appels de fonction.

Ainsi, la pile est un bloc de mémoire continu nécessaire pour stocker des variables locales et des arguments de fonction. Mais, plus important encore, après chaque appel de fonction (à quelques exceptions près), des informations supplémentaires sont insérées dans la pile qui indiquent à la fonction appelée comment retourner à l'appelant et restaurer les registres du processeur.

Certains de ces registres ont des affectations spéciales et lors de l'appel de fonctions, ils sont stockés sur la pile. Ce sont les registres (dans le cas de l'architecture ARM):

SP - pointeur de pile
LR - registre de communication
PC - pointeur de pile de compteur de programme

(SP) est un registre qui contient l'adresse du début de la pile liée à l'appel de fonction en cours. Grâce à la valeur existante, vous pouvez facilement vous référer aux arguments et aux variables locales stockés sur la pile.

Le registre de communication (LR) est très important lors de l'appel de fonctions. Il stocke l'adresse de retour (l'adresse de l'appelant), où le code sera exécuté une fois l'exécution de la fonction en cours terminée. Lorsque la fonction est appelée, le PC est enregistré dans LR. Lorsque la fonction revient, le PC est restauré à l'aide de LR.

Le compteur de programme (PC) est l'adresse de l'instruction en cours d'exécution.
Chaque fois qu'une fonction est appelée, la liste des liens est enregistrée, afin que la fonction sache où le programme doit retourner une fois terminé.



Le comportement des registres PC et LR lors de l'appel et du retour d'une fonction

Lors de l'exécution d'une coroutine de pile, les fonctions appelées utilisent la pile précédemment allouée pour stocker ses arguments et ses variables locales. Étant donné que toutes les informations sur chaque fonction appelée dans la corutine de pile sont stockées sur la pile, la fibre peut suspendre toute fonction au sein de cette corutine.



Voyons ce qui se passe sur cette image. Tout d'abord, chaque fibre et chaque fil a sa propre pile distincte. La couleur verte indique les numéros de série indiquant la séquence d'actions.

  1. Un appel de fonction régulier à l'intérieur d'un thread. La mémoire est allouée sur la pile.
  2. . . , . . , .
  3. .
  4. . .
  5. .
  6. .
  7. . , , , .
  8. .
  9. .
  10. – , .
  11. , .
  12. . .
  13. . : , . , ( ) .
  14. , .
  15. .
  16. . . . , .
  17. .
  18. , , .

Lorsque vous travaillez avec des coroutines de pile, il n'est pas nécessaire de disposer d'une fonction de langue dédiée qui garantirait leur utilisation. L'intégralité du korutiny de la pile doit être implémentée à l'aide de bibliothèques, et il existe déjà des bibliothèques conçues spécifiquement à cet effet:

swtch.com/libtask
code.google.com/archive/p/libconcurrency
www.boost.org Boost.Fiber
www.boost.org the Boost .Coroutine

De toutes ces bibliothèques, seul Boost est C ++, et tous les autres sont C.
Pour une description détaillée du fonctionnement de ces bibliothèques, voir la documentation. Mais, en général, toutes ces bibliothèques vous permettent de créer une pile distincte pour la fibre et offrent la possibilité de reprendre la coroutine (à l'initiative de l'appelant) et de la mettre en pause (de l'intérieur).

Prenons un exemple Boost.Fiber:

#include <cstdlib>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
	
#include <boost/intrusive_ptr.hpp>
	
#include <boost/fiber/all.hpp>
	
inline
void fn( std::string const& str, int n) {
     for ( int i = 0; i < n; ++i) {
          std::cout << i << ": " << str << std::endl;
               boost::this_fiber::yield();
     }
}
	
int main() {
     try {
          boost::fibers::fiber f1( fn, "abc", 5);
          std::cerr << "f1 : " << f1.get_id() << std::endl;
          f1.join();
          std::cout << "done." << std::endl;
	
          return EXIT_SUCCESS;
     } catch ( std::exception const& e) {
          std::cerr << "exception: " << e.what() << std::endl;
     } catch (...) {
          std::cerr << "unhandled exception" << std::endl;
     }
     return EXIT_FAILURE;
}

Dans le cas de Boost.Fiber , la bibliothèque a un planificateur intégré pour coroutine. Toutes les fibres passent dans le même fil. Comme la planification de la corutine est coopérative, la fibre doit d'abord décider quand rendre le contrôle au programmateur. Dans cet exemple, cela se produit lorsque la fonction yield est appelée, ce qui interrompt la coroutine.

Puisqu'il n'y a pas d'autre fibre, le planificateur de fibres décide toujours de reprendre la coroutine.

Coroutines sans pile


Les coroutines sans pile diffèrent légèrement en propriétés de celles en pile. Cependant, ils ont les mêmes caractéristiques de base, car les coroutines non empilées peuvent également être démarrées, et après leur suspension peut être reprise. Des coroutines de ce type que nous trouverons probablement en C ++ 20.

Si nous parlons des propriétés similaires de la corutine - les coroutines peuvent:

  • Corutin est étroitement liée à son appelant: lorsqu'une coroutine est appelée, l'exécution lui est transférée et le résultat de la coroutine est retransféré à l'appelant.
  • La durée de vie d'une pile de corutine est égale à la durée de vie de sa pile. La durée de vie d'une coroutine sans pile est égale à la durée de vie de son objet.

Cependant, dans le cas de coroutines sans pile, il n'est pas nécessaire d'allouer une pile entière. Ils consomment beaucoup moins de mémoire que ceux de la pile, mais cela est précisément dû à certaines de leurs limitations.

Pour commencer, s'ils n'allouent pas de mémoire à la pile, comment fonctionnent-ils? Où, dans leur cas, toutes les données doivent être stockées sur la pile lorsque vous travaillez avec des coroutines de pile. Réponse: sur la pile de l'appelant.

Le secret des coroutines sans pile est qu'elles ne peuvent se suspendre qu'à la fonction la plus élevée. Pour toutes les autres fonctions, leurs données sont situées sur la pile du côté appelé, donc toutes les fonctions appelées à partir de la corutine doivent être terminées avant que le travail de la corutine ne soit suspendu. Toutes les données nécessaires à la coroutine pour maintenir son état sont allouées dynamiquement sur le tas. Cela nécessite généralement quelques variables et arguments locaux, qui sont beaucoup plus compacts qu'une pile entière qui devrait être allouée à l'avance.

Jetez un œil au fonctionnement des corutines



sans pile

Comme vous pouvez le voir, il n'y a maintenant qu'une seule pile - c'est la pile principale du thread. Examinons pas à pas ce qui est montré dans cette image (le cadre d'activation de la coroutine est ici bicolore - le noir montre ce qui est stocké sur la pile et le bleu - ce qui est stocké sur le tas).

  1. Un appel de fonction régulier dont la trame est stockée sur la pile
  2. La fonction crée une coroutine . Autrement dit, il lui alloue une trame d'activation quelque part sur le tas.
  3. Appel de fonction normale.
  4. Appelez Corutin . Le corps de Corutin se démarque dans une pile régulière. Le programme est exécuté de la même manière que dans le cas d'une fonction régulière.
  5. Un appel de fonction régulier de coroutine. Encore une fois, tout se passe toujours sur la pile [Remarque: vous ne pouvez pas suspendre la coroutine à partir de ce point, car ce n'est pas la fonction la plus élevée de la coroutine]
  6. [: .]
  7. – , , .
  8. – , + .
  9. 5.
  10. 6.
  11. . .

Ainsi, il est évident que dans le deuxième cas, il est nécessaire de se souvenir de beaucoup moins de données pour toutes les opérations de suspension et de reprise du travail de coroutine, cependant, la coroutine peut reprendre et suspendre uniquement elle-même, et uniquement à partir de la fonction la plus élevée. Tous les appels de fonction et la coroutine se déroulent de la même manière, cependant, entre les appels, il est nécessaire d'enregistrer des données supplémentaires, et la fonction doit être capable de sauter au point de suspension et de restaurer l'état des variables locales. Il n'y a pas d'autres différences entre le cadre coroutine et le cadre fonction.

La corutine peut également provoquer d'autres coroutines (non représentées dans cet exemple). Dans le cas de coroutines sans pile, chaque appel entraîne l'allocation d'un nouvel espace pour de nouvelles données de coroutine (avec un appel répété de coroutine, la mémoire dynamique peut également être allouée plusieurs fois).

La raison pour laquelle les coroutines doivent fournir une fonctionnalité de langage dédiée est que le compilateur doit décider quelles variables décrivent l'état de la coroutine et créer du code stéréotypé pour passer aux points de suspension.

Utilisation pratique de la corutine


Les coroutines en C ++ peuvent être utilisées de la même manière que dans d'autres langages. Coroutines simplifiera l'orthographe:

  • générateurs
  • code d'entrée / sortie asynchrone
  • informatique paresseuse
  • applications pilotées par les événements

Sommaire


J'espère qu'en lisant cet article vous découvrirez:

  • pourquoi en C ++ vous devez implémenter des coroutines en tant que fonctionnalité de langage dédiée
  • Quelle est la différence entre les coroutines empilées et sans pile?
  • pourquoi les coroutines sont nécessaires

All Articles