Version texte du rapport «Acteurs vs CSP vs Tâches ...» avec C ++ CoreHard Automne 2018

Début novembre, Minsk a accueilli la prochaine conférence C ++ Conférence C ++ CoreHard automne 2018. Elle a livré un rapport du capitaine «Acteurs vs CSP vs Tâches ...» , qui parlait de la façon dont les applications de niveau supérieur à «peuvent regarder en C ++» nus multithreading », des modèles de programmation compétitifs. Sous la version coupée de ce rapport, transformé en article. Peigné, paré par endroits, complété par endroits.

Je profite de cette occasion pour remercier la communauté CoreHard pour l'organisation de la prochaine grande conférence à Minsk et pour l'occasion de prendre la parole. Et aussi pour la publication rapide de rapports vidéo de rapports sur YouTube .

Passons donc au sujet principal de la conversation. À savoir, quelles approches nous pouvons utiliser pour simplifier la programmation multithread en C ++, à quoi ressembleront certaines de ces approches dans le code, quelles fonctionnalités sont inhérentes à des approches spécifiques, ce qui est commun entre elles, etc.

Remarque: des erreurs et des fautes de frappe ont été détectées dans la présentation d'origine du rapport, de sorte que l'article utilisera des diapositives de la version mise à jour et modifiée, qui peuvent être trouvées dans Google Slides ou sur SlideShare .

Le multithreading nu est mal!


Il faut commencer par la banalité répétée, qui reste cependant toujours d'actualité:
La programmation C ++ multithread via des threads nus, des mutex et des variables de condition est la sueur , la douleur et le sang .

Un bon exemple a été récemment décrit ici dans cet article ici sur Habré: "L' architecture du méta-serveur du jeu de tir mobile en ligne Tacticool. " Dans ce document, les gars ont expliqué comment ils avaient réussi à collecter, apparemment, une gamme complète de râteaux liés au développement de code multi-thread en C et C ++. Il y a eu des «passages de mémoire» à la suite de courses et de faibles performances en raison d'une échec de la parallélisation.

Du coup, tout s'est terminé tout naturellement:
Après quelques semaines passées à rechercher et à corriger les bogues les plus critiques, nous avons décidé qu'il était plus facile de tout réécrire à partir de zéro que d'essayer de corriger tous les défauts de la solution actuelle.

Les gens ont mangé du C / C ++ en travaillant sur la première version de leur serveur et ont réécrit le serveur dans une autre langue.

Une excellente démonstration de la façon dont, dans le monde réel, en dehors de notre communauté C ++ confortable, les développeurs refusent d'utiliser C ++ même lorsque l'utilisation de C ++ est toujours appropriée et justifiée.

Mais pourquoi?


Mais pourquoi, s'il est dit à plusieurs reprises que le «multithread nu» en C ++ est mauvais, les gens continuent à l'utiliser avec une persévérance digne d'une meilleure application? Ce qui est à blâmer:

  • l'ignorance?
  • la paresse?
  • Syndrome NIH?

Après tout, il y a loin d'une approche testée par le temps et de nombreux projets. En particulier:

  • acteurs
  • communication des processus séquentiels (CSP)
  • tâches (asynchrones, promesses, futurs, ...)
  • flux de données
  • programmation réactive
  • ...

On espère que la raison principale est encore l'ignorance. Il est peu probable que cela soit enseigné dans les universités. Ainsi, les jeunes professionnels, entrant dans la profession, utilisent le peu qu'ils connaissent déjà. Et si le magasin de connaissances n'est alors pas réapprovisionné, alors les gens continuent à utiliser des threads nus, des mutex et des variables de condition.

Aujourd'hui, nous allons parler des trois premières approches de cette liste. Et nous ne parlerons pas de manière abstraite, mais sur l'exemple d'une tâche simple. Essayons de montrer à quoi ressemblera le code qui résout ce problème en utilisant Actor, les processus et canaux CSP, ainsi qu'en utilisant Task.

Défi pour les expériences


Il est nécessaire d'implémenter un serveur HTTP qui:

  • accepte la demande (ID photo, ID utilisateur);
  • donne une image avec des "filigranes" uniques à cet utilisateur.

Par exemple, un tel serveur peut être requis par un service payant qui distribue du contenu par abonnement. Si l'image de ce service "apparaît" quelque part, alors par les "filigranes" dessus, il sera possible de comprendre qui a besoin de "bloquer l'oxygène".

La tâche est abstraite, elle a été formulée spécifiquement pour ce rapport sous l'influence de notre projet de démonstration Shrimp (nous en avons déjà parlé: n ° 1 , n ° 2 , n ° 3 ).

Ce notre serveur HTTP fonctionnera comme suit:

Après avoir reçu une demande d'un client, nous nous tournons vers deux services externes:

  • le premier nous renvoie des informations utilisateur. Y compris à partir de là, nous obtenons une image avec des "filigranes";
  • la seconde nous renvoie l'image d'origine

Ces deux services fonctionnent indépendamment et nous pouvons y accéder simultanément.

Comme le traitement des demandes peut se faire indépendamment les unes des autres, et même certaines actions lors du traitement d'une seule demande peuvent se faire en parallèle, l'utilisation de la compétitivité se suggère. La chose la plus simple qui vous vient à l'esprit est de créer un thread distinct pour chaque demande entrante:

Mais le modèle one-request = one-workflow est trop cher et ne s'adapte pas bien. Nous n'en avons pas besoin.

Même si nous approchons le nombre de workflows de manière inutile, nous en avons encore besoin d'un petit nombre:

Ici, nous avons besoin d'un flux séparé pour recevoir les requêtes HTTP entrantes, d'un flux séparé pour nos propres requêtes HTTP sortantes, d'un flux séparé pour coordonner le traitement des requêtes HTTP reçues. Ainsi qu'un pool de workflows pour effectuer des opérations sur les images (puisque les manipulations sur les images sont bien parallèles, le traitement d'une image par plusieurs flux à la fois réduira son temps de traitement).

Par conséquent, notre objectif est de gérer un grand nombre de demandes entrantes simultanées sur un petit nombre de threads de travail. Voyons comment nous y parvenons à travers différentes approches.

Quelques avertissements importants


Avant de passer à l'histoire principale et aux exemples de code d'analyse, quelques notes doivent être prises.

Premièrement, tous les exemples suivants ne sont liés à aucun framework ou bibliothèque particulier. Toutes les correspondances dans les noms des appels API sont aléatoires et involontaires.

Deuxièmement, il n'y a pas de gestion d'erreur dans les exemples ci-dessous. Cela se fait délibérément, afin que les diapositives soient compactes et visibles. Et aussi pour que le matériel rentre dans le temps alloué au rapport.

Troisièmement, les exemples utilisent une certaine entité execution_context, qui contient des informations sur ce qui existe d'autre à l'intérieur du programme. Remplir cette entité dépend de l'approche. Dans le cas des acteurs, execution_context aura des liens vers d'autres acteurs. Dans le cas de CSP, dans execution_context, il y aura des canaux CSP pour la communication avec d'autres processus CSP. Etc.

Approche n ° 1: acteurs


Modèle d'acteurs en bref


Lors de l'utilisation du modèle d'acteurs, la solution sera construite à partir d'objets-acteurs séparés, chacun ayant son propre état privé et cet état est inaccessible à quiconque sauf à l'acteur lui-même.

Les acteurs interagissent entre eux via des messages asynchrones. Chaque acteur a sa propre boîte aux lettres unique (file d'attente de messages), dans laquelle les messages envoyés à l'acteur sont enregistrés et d'où ils sont récupérés pour un traitement ultérieur.

Les acteurs travaillent sur des principes très simples:

  • un acteur est une entité avec un comportement;
  • les acteurs répondent aux messages entrants;
  • Après avoir reçu le message, l'acteur peut:
    • envoyer un certain nombre (final) de messages à d'autres acteurs;
    • créer un certain nombre (final) de nouveaux acteurs;
    • Définissez un nouveau comportement pour le traitement des messages suivants.

Au sein d'une application, les acteurs peuvent être implémentés de différentes manières:

  • chaque acteur peut être représenté comme un flux de système d'exploitation distinct (cela se produit, par exemple, dans la bibliothèque C :: Just :: Thread Pro Actor Edition);
  • chaque acteur peut être représenté comme une coroutine empilée;
  • chaque acteur peut être représenté comme un objet dans lequel quelqu'un appelle des méthodes de rappel.

Dans notre décision, nous allons utiliser des acteurs sous forme d'objets avec des rappels, et laisser des coroutines pour l'approche CSP.

Schéma de décision basé sur le modèle des acteurs


Sur la base des acteurs, le schéma général de résolution de notre problème ressemblera à ceci:

Nous aurons des acteurs qui sont créés au début du serveur HTTP et qui existent tout le temps pendant que le serveur HTTP fonctionne. Ce sont des acteurs tels que: HttpSrv, UserChecker, ImageDownloader, ImageMixer.

À la réception d'une nouvelle demande HTTP entrante, nous créons une nouvelle instance de l'acteur RequestHandler, qui sera détruite après l'émission d'une réponse à la demande HTTP entrante.

Code d'acteur RequestHandler


L'implémentation de l'acteur request_handler, qui coordonne le traitement d'une requête HTTP entrante, peut ressembler à ceci:
class request_handler final : public some_basic_type { const execution_context context_; const request request_; optional<user_info> user_info_; optional<image_loaded> image_; void on_start(); void on_user_info(user_info info); void on_image_loaded(image_loaded image); void on_mixed_image(mixed_image image); void send_mix_images_request(); ... //     . }; void request_handler::on_start() { send(context_.user_checker(), check_user{request_.user_id(), self()}); send(context_.image_downloader(), download_image{request_.image_id(), self()}); } void request_handler::on_user_info(user_info info) { user_info_ = std::move(info); if(image_) send_mix_images_request(); } void request_handler::on_image_loaded(image_loaded image) { image_ = std::move(image); if(user_info_) send_mix_images_request(); } void request_handler::send_mix_images_request() { send(context_.image_mixer(), mix_images{user_info->watermark_image(), *image_, self()}); } void request_handler::on_mixed_image(mixed_image image) { send(context_.http_srv(), reply{..., std::move(image), ...}); } 

Analysons ce code.

Nous avons une classe dans les attributs dont nous stockons ou allons stocker ce dont nous avons besoin pour traiter la demande. Dans cette classe, il existe également un ensemble de rappels qui seront appelés à un moment ou à un autre.

Tout d'abord, lorsqu'un acteur vient d'être créé, le rappel on_start () est appelé. Dans ce document, nous envoyons deux messages à d'autres acteurs. Tout d'abord, il s'agit d'un message check_user pour vérifier l'ID client. Deuxièmement, il s'agit d'un message download_image pour télécharger l'image d'origine.

Dans chacun des messages envoyés, nous nous transmettons un lien (un appel à la méthode self () renvoie un lien vers l'acteur pour lequel self () a été appelé). Cela est nécessaire pour que notre acteur puisse envoyer un message en réponse. Si nous n'envoyons pas de lien à notre acteur, par exemple, dans le message check_user, alors l'acteur UserChecker ne saura pas à qui envoyer les informations utilisateur.

Lorsqu'un message user_info avec des informations utilisateur nous est envoyé en réponse, le rappel on_user_info () est appelé. Et lorsque le message image_loaded nous est envoyé, le rappel on_image_loaded () est appelé sur notre acteur. Et maintenant, à l'intérieur de ces deux rappels, nous voyons une caractéristique inhérente au modèle des acteurs: nous ne savons pas exactement dans quel ordre nous recevrons les messages de réponse. Par conséquent, nous devons écrire notre code afin qu'il ne dépende pas de l'ordre dans lequel les messages arrivent. Par conséquent, dans chacun des processeurs, nous stockons d'abord les informations reçues dans l'attribut correspondant, puis vérifions si nous avons déjà collecté toutes les informations dont nous avons besoin? Si c'est le cas, nous pouvons continuer. Sinon, nous attendrons plus loin.

C'est pourquoi nous avons des ifs on_user_info () et on_image_loaded () qui sont exécutés lors de l'appel de send_mix_images_request ().

En principe, dans les implémentations du modèle d'acteurs, il peut y avoir des mécanismes comme la réception sélective d'Erlang ou le masquage d'Akka, à travers lesquels vous pouvez manipuler l'ordre de traitement des messages entrants, mais nous n'en parlerons pas aujourd'hui, afin de ne pas plonger dans la jungle des détails des différentes implémentations du modèle. Acteurs.

Donc, si toutes les informations dont nous avons besoin de UserChecker et ImageDownloader sont reçues, la méthode send_mix_images_request () est appelée, dans laquelle le message mix_images est envoyé à l'acteur ImageMixer. Le rappel on_mixed_image () est appelé lorsque nous recevons un message de réponse avec l'image résultante. Ici, nous envoyons cette image à l'acteur HttpSrv et attendons que HttpSrv forme une réponse HTTP et détruise le RequestHandler qui est devenu inutile (bien que, en principe, rien n'empêche l'acteur RequestHandler de s'autodétruire dans le rappel on_mixed_image ()).

C'est tout.

La mise en œuvre de l'acteur RequestHandler s'est avérée assez volumineuse. Mais cela est dû au fait que nous devions décrire une classe avec des attributs et des rappels, puis implémenter également des rappels. Mais la logique du travail de RequestHandler est très triviale, et sa compréhension, malgré la quantité de code dans la classe request_handler, n'est pas difficile.

Caractéristiques inhérentes aux acteurs


Nous pouvons maintenant dire quelques mots sur les caractéristiques du modèle d'acteurs.

Réacteurs


En règle générale, les acteurs ne répondent qu'aux messages entrants. Il y a des messages - l'acteur les traite. Aucun message - l'acteur ne fait rien.

Cela est particulièrement vrai pour les implémentations du modèle d'acteurs dans lesquelles les acteurs sont représentés comme des objets avec des rappels. Le cadre tire le rappel de l'acteur et si l'acteur ne rend pas le contrôle du rappel, le cadre ne peut pas servir d'autres acteurs dans le même contexte.

Les acteurs sont surchargés


Sur les acteurs, nous pouvons très facilement faire en sorte qu'un acteur-producteur génère des messages pour un acteur-consommateur à un rythme beaucoup plus rapide qu'un acteur-consommateur ne pourra traiter.

Cela conduira au fait que la file d'attente des messages entrants pour l'acteur-consommateur augmentera constamment. Croissance de la file d'attente, c.-à-d. une consommation de mémoire accrue dans l'application réduira la vitesse de l'application. Cela entraînera une croissance encore plus rapide de la file d'attente et, par conséquent, l'application peut se dégrader pour devenir inopérante.

Tout cela est une conséquence directe de l'interaction asynchrone des acteurs. Parce que l'opération d'envoi est généralement non bloquante. Et pour le faire bloquer n'est pas facile, car un acteur peut s'envoyer à lui-même. Et si la file d'attente pour l'acteur est pleine, alors lors de l'envoi, l'acteur sera bloqué et cela arrêtera son travail.

Ainsi, lorsque vous travaillez avec des acteurs, une attention particulière doit être portée au problème de la surcharge.

De nombreux acteurs ne sont pas toujours la solution.


En règle générale, les acteurs sont des entités légères et il y a une tentation de les créer dans leur application en grand nombre. Vous pouvez créer dix mille acteurs, cent mille et un million. Et même une centaine de millions d'acteurs, si le fer vous le permet.

Mais le problème est que le comportement d'un très grand nombre d'acteurs est difficile à suivre. C'est-à-dire vous pouvez avoir certains acteurs qui fonctionnent clairement correctement. Certains acteurs qui, de toute évidence, ne fonctionnent pas correctement ou ne fonctionnent pas du tout, et vous en êtes sûr. Mais il peut y avoir un grand nombre d'acteurs dont vous ne savez rien: fonctionnent-ils du tout, fonctionnent-ils correctement ou incorrectement? Et tout cela parce que lorsque vous avez une centaine de millions d'entités autonomes avec votre propre logique de comportement dans votre programme, le suivi est très difficile pour tout le monde.

Par conséquent, il peut s'avérer que lors de la création d'un grand nombre d'acteurs dans l'application, nous ne résolvons pas notre problème appliqué, mais obtenons un autre problème. Et, par conséquent, il peut être avantageux pour nous d'abandonner des acteurs simples qui résolvent une seule tâche, au profit d'acteurs plus complexes et plus lourds qui effectuent plusieurs tâches. Mais il y aura alors moins d'acteurs "lourds" dans l'application et il nous sera plus facile de les suivre.

Où chercher, que prendre?


Si quelqu'un veut essayer de travailler avec des acteurs en C ++, alors inutile de construire ses propres vélos, il existe plusieurs solutions toutes faites, notamment:


Ces trois options sont animées, évolutives, multiplateformes, documentées. Vous pouvez également les essayer gratuitement. De plus, plusieurs options de degrés de fraîcheur [pas] différents peuvent être trouvées dans la liste sur Wikipedia .

SObjectizer et CAF sont conçus pour être utilisés dans des tâches de haut niveau où des exceptions et de la mémoire dynamique peuvent être appliquées. Et le cadre QP / C ++ peut intéresser ceux qui sont impliqués dans le développement embarqué, comme c'est sous cette niche qu'il est «emprisonné».

Approche n ° 2: CSP (communication de processus séquentiels)


CSP sur les doigts et sans matan


Le modèle CSP est très similaire au modèle Actors. Nous construisons également notre solution à partir d'un ensemble d'entités autonomes, chacune ayant son propre état privé et n'interagissant avec d'autres entités que via des messages asynchrones.

Seules ces entités dans le modèle CSP sont appelées «processus».

Les processus dans CSP sont légers, sans aucune parallélisation de leur travail à l'intérieur. Si nous devons paralléliser quelque chose, nous démarrons simplement plusieurs processus CSP, à l'intérieur desquels il n'y a plus de parallélisation.

Les processus CSP interagissent les uns avec les autres via des messages asynchrones, mais les messages ne sont pas envoyés aux boîtes aux lettres, comme dans le modèle des acteurs, mais aux canaux. Les canaux peuvent être considérés comme des files d'attente de messages, généralement de taille fixe.

Contrairement au modèle Acteurs, où une boîte aux lettres est automatiquement créée pour chaque acteur, les canaux dans le CSP doivent être créés explicitement. Et si nous avons besoin que les deux processus interagissent, alors nous devons créer le canal nous-mêmes, puis dire au premier processus "vous écrirez ici", et le deuxième processus devrait dire: "vous lirez ici d'ici".

Dans le même temps, les canaux ont au moins deux opérations qui doivent être appelées explicitement. La première est l'opération d'écriture (envoi) pour écrire un message sur le canal.

Deuxièmement, il s'agit d'une opération de lecture (réception) pour lire un message à partir d'un canal. Et la nécessité d'appeler explicitement lecture / réception distingue CSP du modèle Acteurs, car dans le cas des acteurs, l'opération de lecture / réception peut généralement être cachée à l'acteur. C'est-à-dire La structure d'acteur peut récupérer des messages de la file d'attente des acteurs et appeler un gestionnaire (rappel) pour le message récupéré.

Alors que le processus CSP lui-même doit choisir le moment de l'appel de lecture / réception, alors le processus CSP doit déterminer le message qu'il a reçu et traiter le message extrait.

Au sein de notre «grande» application, les processus CSP peuvent être implémentés de différentes manières:

  • Le processus CSP-shny peut être implémenté comme un OS de thread séparé. Cela s'avère une solution coûteuse, mais avec un multitâche préemptif;
  • Le processus CSP peut être implémenté par coroutine (coroutine empilée, fibre, fil vert, ...). C'est beaucoup moins cher, mais le multitâche n'est que coopératif.

De plus, nous supposons que les processus CSP sont présentés sous la forme de coroutines empilables (bien que le code montré ci-dessous puisse très bien être implémenté sur les threads OS).

Diagramme de solution basée sur CSP


Le schéma de solution basé sur le modèle CSP ressemblera beaucoup à un schéma similaire pour le modèle des acteurs (et ce n'est pas un hasard):

Il y aura également des entités qui démarreront au démarrage du serveur HTTP et fonctionneront tout le temps - ce sont les processus CSP HttpSrv, UserChecker, ImageDownloader et ImageMixer. Pour chaque nouvelle demande entrante, un nouveau processus CSP RequestHandler sera créé. Ce processus envoie et reçoit les mêmes messages que lors de l'utilisation du modèle d'acteurs.

Code de processus RequestHandler CSP


Cela peut ressembler au code d'une fonction qui implémente le processus timide CSP de RequestHandler:
 void request_handler(const execution_context ctx, const request req) { auto user_info_ch = make_chain<user_info>(); auto image_loaded_ch = make_chain<image_loaded>(); ctx.user_checker_ch().write(check_user{req.user_id(), user_info_ch}); ctx.image_downloader_ch().write(download_image{req.image_id(), image_loaded_ch}); auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); auto image_mix_ch = make_chain<mixed_image>(); ctx.image_mixer_ch().write( mix_image{user.watermark_image(), std::move(original_image), image_mix_ch}); auto result_image = image_mix_ch.read(); ctx.http_srv_ch().write(reply{..., std::move(result_image), ...}); } 

Ici, tout est assez banal et répète régulièrement le même schéma:

  • Tout d'abord, nous créons un canal pour recevoir des messages de réponse. Ceci est nécessaire car le processus CSP n'a pas sa propre boîte aux lettres par défaut, comme les acteurs. Par conséquent, si le processus CSP-shny veut recevoir quelque chose, alors il devrait être intrigué par la création du canal où ce "quelque chose" sera écrit;
  • puis nous envoyons notre message au processus maître CSP. Et dans ce message, nous indiquons le canal pour le message de réponse;
  • puis nous effectuons l'opération de lecture à partir du canal où nous devrions recevoir un message de réponse.

Ceci est très clairement vu dans l'exemple de communication avec le processus ImageSPixer CSP:
 auto image_mix_ch = make_chain<mixed_image>(); //  . ctx.image_mixer_ch().write( //  . mix_image{..., image_mix_ch}); //     . auto result_image = image_mix_ch.read(); //  . 

Mais séparément, il convient de se concentrer sur ce fragment:
  auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); 

Ici, nous voyons une autre différence sérieuse par rapport au modèle des acteurs. Dans le cas du CSP, nous pouvons recevoir des messages de réponse dans l'ordre qui nous convient.

Vous voulez d'abord attendre user_info? Pas de problème, allez dormir en lecture jusqu'à ce que user_info apparaisse. Si image_loaded nous a déjà été envoyé à ce moment-là, il attendra simplement dans son canal jusqu'à ce que nous le lisions.

En fait, c'est tout ce qui peut accompagner le code ci-dessus. Le code basé sur CSP était plus compact que son homologue basé sur un acteur. Ce qui n'est pas surprenant puisque ici, nous n'avons pas eu à décrire une classe distincte avec des méthodes de rappel. Et une partie de l'état de notre processus CSP-timide RequestHandler est présent implicitement sous la forme d'arguments ctx et req.

Fonctionnalités CSP


Réactivité et proactivité des processus CSP


Contrairement aux acteurs, les processus CSP peuvent être réactifs, proactifs ou les deux. Disons que le processus CSP a vérifié ses messages entrants; s'il y en avait, il les a traités. Et puis, voyant qu'il n'y avait pas de messages entrants, il s'est engagé à multiplier les matrices.

Après un certain temps, le processus CSP de la matrice était fatigué de se multiplier et il a de nouveau vérifié les messages entrants. Pas de nouveaux? Eh bien, allons multiplier les matrices plus loin.

Et cette capacité des processus CSP à effectuer certains travaux même en l'absence de messages entrants rend le modèle CSP très différent du modèle Actors.

Mécanismes natifs de protection contre les surcharges


Puisque, en règle générale, les canaux sont des files d'attente de messages d'une taille limitée et qu'une tentative d'écrire un message sur un canal rempli arrête l'expéditeur, alors dans CSP, nous avons un mécanisme intégré de protection contre la surcharge.

En effet, si nous avons un processus producteur agile et un processus consommateur lent, alors le processus producteur remplira rapidement le canal et il sera suspendu pour la prochaine opération d'envoi. Et le processus producteur dormira jusqu'à ce que le processus consommateur libère de l'espace dans le canal pour de nouveaux messages. Dès que l'endroit apparaît, le processus producteur se réveille et lance de nouveaux messages dans la chaîne.

Ainsi, lors de l'utilisation de CSP, on peut moins se soucier du problème de surcharge que dans le cas du Modèle d'Acteurs. Certes, il y a un piège ici, dont nous parlerons un peu plus tard.

Comment les processus CSP sont-ils mis en œuvre


Nous devons décider comment nos processus CSP seront mis en œuvre.

Cela peut être fait pour que chaque processus CSP-shny soit représenté par un thread OS séparé. Cela s'avère une solution coûteuse et non évolutive. Mais d'un autre côté, nous obtenons un multitâche préemptif: si notre processus CSP commence à multiplier les matrices ou effectue une sorte d'appel de blocage, le système d'exploitation le poussera finalement hors du noyau de calcul et permettra aux autres processus CSP de fonctionner.

Il est possible de faire représenter chaque processus CSP par une coroutine (coroutine empilée). Il s'agit d'une solution beaucoup moins chère et évolutive. Mais ici, nous n'aurons que du multitâche coopératif. Par conséquent, si soudainement le processus CSP prend la multiplication de matrice, le thread de travail avec ce processus CSP et les autres processus CSP qui lui sont associés seront bloqués.

Il peut y avoir une autre astuce. Supposons que nous utilisons une bibliothèque tierce, à l'intérieur de laquelle nous ne pouvons pas influencer. Et à l'intérieur de la bibliothèque, des variables TLS sont utilisées (c'est-à-dire thread-local-stockage). Nous appelons la fonction bibliothèque et la bibliothèque définit la valeur d'une variable TLS. Ensuite, notre coroutine "se déplace" vers un autre thread de travail, et cela est possible, car en principe, les coroutines peuvent migrer d'un fil de travail à un autre. Nous effectuons l'appel suivant à la fonction de bibliothèque et la bibliothèque essaie de lire la valeur de la variable TLS. Mais il y a peut-être déjà un sens différent! Et la recherche d'un tel bug sera très difficile.

Par conséquent, vous devez soigneusement considérer le choix de la méthode pour implémenter les processus CSP-shnyh. Chacune des options a ses propres forces et faiblesses.

De nombreux processus ne sont pas toujours la solution.


Comme pour les acteurs, la possibilité de créer de nombreux processus CSP dans votre programme n'est pas toujours une solution à un problème appliqué, mais vous crée des problèmes supplémentaires.

De plus, la mauvaise visibilité de ce qui se passe à l'intérieur du programme n'est qu'une partie du problème. Je voudrais me concentrer sur un autre écueil.

Le fait est que sur les canaux CSP-shnyh, vous pouvez facilement obtenir un analogue de blocage. Le processus A tente d'écrire un message sur le canal C1 complet et le processus A est suspendu. À partir du canal C1, le processus B, qui a tenté d'écrire sur le canal C2, qui est plein, doit être lu, et par conséquent, le processus B a été suspendu. Et à partir du canal C2, le processus A devait être lu. C'est tout, nous avons eu une impasse.

Si nous n'avons que deux processus CSP, nous pouvons trouver un tel blocage pendant le débogage ou même avec la procédure de révision du code. Mais si nous avons des millions de processus dans le programme, ils communiquent activement entre eux, alors la probabilité de tels blocages augmente considérablement.

Où chercher, que prendre?


Si quelqu'un veut travailler avec CSP en C ++, alors le choix ici, malheureusement, n'est pas aussi grand que pour les acteurs. Eh bien, ou je ne sais pas où regarder et comment regarder. Dans ce cas, j'espère que les commentaires partageront d'autres liens.

Mais, si nous voulons utiliser CSP, nous devons d'abord regarder vers Boost.Fiber . Il existe des fibres (c'est-à-dire des coroutines) et des canaux, et même des primitives de bas niveau telles que mutex, condition_variable, barrière. Tout cela peut être pris et utilisé.

Si vous êtes satisfait des processus CSP sous forme de threads, vous pouvez regarder SObjectizer . Il existe également des analogues de canaux CSP et des applications multithread complexes sur SObjectizer peuvent être écrites sans aucun acteur.

Acteurs vs CSP


Les acteurs et les CSP sont très similaires les uns aux autres. À plusieurs reprises, je suis tombé sur l'affirmation selon laquelle ces deux modèles sont équivalents l'un à l'autre. C'est-à-dire ce qui peut être fait sur les acteurs peut être répété presque 1 en 1 sur les processus CSP et vice versa. Ils disent que c'est même prouvé mathématiquement. Mais ici, je ne comprends rien, donc je ne peux rien dire. Mais d'après mes propres pensées quelque part au niveau du bon sens quotidien, tout cela semble tout à fait plausible. Dans certains cas, en effet, les acteurs peuvent être remplacés par des processus CSP et les processus CSP par des acteurs.

Cependant, il existe plusieurs différences entre les acteurs et les DSP qui peuvent aider à déterminer où chacun de ces modèles est bénéfique ou désavantageux.

Canaux vs boîte aux lettres


Un acteur a un seul «canal» pour recevoir les messages entrants - c'est sa boîte aux lettres, qui est automatiquement créée pour chaque acteur. Et l'acteur en récupère les messages séquentiellement, exactement dans l'ordre dans lequel les messages étaient dans la boîte aux lettres.

Et c'est une question assez sérieuse. Disons qu'il y a trois messages dans la boîte aux lettres de l'acteur: M1, M2 et M3. L'acteur ne s'intéresse actuellement qu'à M3.Mais avant d'arriver à M3, l'acteur va d'abord extraire M1, puis M2. Et que va-t-il en faire?

Encore une fois, dans le cadre de cette conversation, nous n'aborderons pas les mécanismes de réception sélective d'Erlang et la dissimulation d'Akka.

Alors que le processus CSP-shny a la possibilité de sélectionner le canal à partir duquel il souhaite actuellement lire les messages. Ainsi, un processus CSP peut avoir trois canaux: C1, C2 et C3. Actuellement, le processus CSP ne s'intéresse qu'aux messages de C3. C'est ce canal que lit le processus. Et il reviendra au contenu des canaux C1 et C2 quand cela l'intéressera.

Réactivité et proactivité


En règle générale, les acteurs sont réactifs et ne fonctionnent que lorsqu'ils ont des messages entrants.

Alors que les processus CSP peuvent faire du travail même en l'absence de messages entrants. Dans certains scénarios, cette différence peut jouer un rôle important.

Machines d'état


En fait, les acteurs sont des machines à états finis (KA). Par conséquent, s'il existe de nombreuses machines à états finis dans votre domaine, et même s'il s'agit de machines à états finis hiérarchiques complexes, il peut être beaucoup plus facile pour vous de les implémenter sur la base du modèle d'acteur qu'en ajoutant une implémentation de vaisseau spatial à un processus CSP.

En C ++, il n'y a pas encore de support CSP natif.


L'expérience du langage Go montre à quel point il est facile et pratique d'utiliser le modèle CSP lorsque son support est implémenté au niveau d'un langage de programmation et de sa bibliothèque standard.

Dans Go, il est facile de créer des «processus CSP» (alias goroutines), il est facile de créer et de travailler avec des canaux, il existe une syntaxe intégrée pour travailler avec plusieurs canaux à la fois (Go-shny select, qui fonctionne non seulement pour la lecture mais aussi pour l'écriture), la bibliothèque standard connaît les goroutins et peut les changer lorsque goroutin fait un appel de blocage depuis stdlib.

En C ++, jusqu'à présent, il n'y a pas de support pour les coroutines empilées (au niveau du langage). Par conséquent, travailler avec CSP en C ++ peut sembler, par endroits, sinon une béquille, alors ... Cela nécessite certainement beaucoup plus d'attention à lui-même que dans le cas du même Go.

Approche n ° 3: tâches (asynchrones, futures, wait_all, ...)


À propos de l'approche basée sur les tâches dans les mots les plus courants


Le sens de l'approche basée sur les tâches est que si nous avons une opération complexe, nous divisons cette opération en étapes de tâche distinctes, où chaque tâche (c'est une tâche) effectue une seule sous-opération.

Nous commençons ces tâches avec l'opération spéciale async. L'opération asynchrone renvoie un futur objet dans lequel, une fois la tâche terminée, la valeur renvoyée par la tâche sera placée.

Après avoir lancé N tâches et reçu N objets-futur, nous devons en quelque sorte tricoter tout cela en chaîne. Il semble que lorsque les tâches n ° 1 et n ° 2 sont terminées, les valeurs renvoyées par celles-ci devraient tomber dans la tâche n ° 3. Et lorsque la tâche n ° 3 est terminée, la valeur renvoyée doit être transférée aux tâches n ° 4, n ° 5 et n ° 6. Etc., etc.

Pour une telle «cravate», des moyens spéciaux sont utilisés. Tels que, par exemple, la méthode .then () d'un futur objet, ainsi que les fonctions wait_all (), wait_any ().

Une telle explication «sur les doigts» n'est peut-être pas très claire, alors passons au code. Peut-être que dans une conversation sur un code spécifique, la situation deviendra plus claire (mais pas un fait).

Code Request_handler pour l'approche basée sur les tâches


Le code de traitement d'une requête HTTP entrante basée sur des tâches peut ressembler à ceci:
 void handle_request(const execution_context & ctx, request req) { auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); when_all(user_info_ft, original_image_ft).then( [&ctx, req](tuple<future<user_info>, future<image_loaded>> data) { async(ctx.image_mixer_ctx(), [&ctx, req, d=std::move(data)] { return mix_image(get<0>(d).get().watermark_image(), get<1>(d).get()); }) .then([req](future<mixed_image> mixed) { async(ctx.http_srv_ctx(), [req, im=std::move(mixed)] { make_reply(...); }); }); }); } 

Essayons de comprendre ce qui se passe ici.

Tout d'abord, nous créons une tâche qui doit être lancée dans le contexte de notre propre client HTTP et qui demande des informations sur l'utilisateur. L'objet futur renvoyé est stocké dans la variable user_info_ft.

Ensuite, nous créons une tâche similaire, qui devrait également s'exécuter dans le contexte de notre propre client HTTP et qui charge l'image d'origine. L'objet futur renvoyé est stocké dans la variable original_image_ft.

Ensuite, nous devons attendre que les deux premières tâches soient terminées. Ce que nous écrivons directement: when_all (user_info_ft, original_image_ft). Lorsque les deux futurs objets obtiendront leurs valeurs, nous exécuterons une autre tâche. Cette tâche prendra le bitmap avec le filigrane et l'image d'origine et exécutera une autre tâche dans le contexte d'ImageMixer. Cette tâche mélangera des images et lorsqu'elle sera terminée, une autre tâche sera lancée sur le contexte du serveur HTTP, ce qui générera une réponse HTTP.

Peut-être qu'une telle explication de ce qui se passe dans le code n'est pas beaucoup clarifiée. Par conséquent, numérotons nos tâches:

Et regardons les dépendances entre elles (d'où découle l'ordre des tâches):

Et si nous superposons maintenant cette image sur notre code source, j'espère que cela deviendra plus clair:


Caractéristiques de l'approche basée sur les tâches


Visibilité


La première fonctionnalité qui devrait déjà être évidente est la visibilité du code sur Task. Tout ne va pas bien avec elle.

Ici, vous pouvez mentionner une chose comme l'enfer de rappel. Les programmeurs Node.js le connaissent très bien. Mais les surnoms C ++ qui travaillent en étroite collaboration avec Task plongent également dans cet enfer de rappel.

Gestion des erreurs


Une autre caractéristique intéressante est la gestion des erreurs.

D'une part, dans le cas de l'utilisation asynchrone et future avec la livraison d'informations d'erreur à l'intéressé, cela peut être encore plus facile que dans le cas des acteurs ou du CSP. Après tout, si dans le processus CSP A envoie une demande au processus B et attend un message de réponse, alors lorsque B rencontre une erreur lors de l'exécution de la demande, nous devons décider comment remettre l'erreur au processus A:

  • ou nous créerons un type de message séparé et un canal pour le recevoir;
  • ou nous renvoyons le résultat avec un seul message, qui sera std :: variant pour un résultat normal et erroné.

Et dans le cas du futur, tout est plus simple: on extrait du futur soit un résultat normal, soit une exception nous est lancée.

Mais, d'un autre côté, nous pouvons facilement rencontrer une cascade d'erreurs. Par exemple, une exception s'est produite dans la tâche n ° 1, cette exception est tombée dans le futur objet, qui a été transmis à la tâche n ° 2. Dans la tâche n ° 2, nous avons essayé de prendre la valeur de l'avenir, mais nous avons reçu une exception. Et, très probablement, nous lèverons la même exception. En conséquence, il tombera dans le futur futur, qui ira à la tâche n ° 3. Il y aura également une exception qui, très probablement, sera également publiée. Etc.

Si nos exceptions sont enregistrées, alors dans le journal, nous pouvons voir la répétition répétée de la même exception, qui va d'une tâche de la chaîne à une autre tâche.

Annuler les tâches et les minuteries / délais d'expiration


Et une autre caractéristique très intéressante de la campagne basée sur les tâches est l'annulation des tâches en cas de problème. En fait, disons que nous avons créé 150 tâches, terminé les 10 premières et réalisé qu'il était inutile de poursuivre le travail. Comment annuler les 140 restants? C'est une très, très bonne question :)

Une autre question similaire est de savoir comment se faire des amis avec des minuteries et des temps morts. Supposons que nous accédions à un système externe et que nous voulons limiter le temps d'attente à 50 millisecondes. Comment régler la minuterie, comment réagir à l'expiration du délai, comment interrompre la chaîne de tâches si le délai a expiré? Encore une fois, demander est plus facile que de répondre :)

Tricherie


Eh bien, et pour parler des caractéristiques de l'approche basée sur les tâches. Dans l'exemple illustré, un peu de triche a été appliqué:
  auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); 

Ici, j'ai envoyé deux tâches au contexte de notre propre serveur HTTP, chacune effectuant une opération de blocage à l'intérieur. En effet, pour pouvoir traiter en parallèle deux requêtes vers des services tiers, il fallait ici créer ses propres chaînes de tâches asynchrones. Mais je ne l'ai pas fait pour rendre la solution plus ou moins visible et tenir sur la diapositive de présentation.

Acteurs / CSP vs tâches


Nous avons examiné trois approches et constaté que si les acteurs et les processus CSP sont similaires, l'approche basée sur les tâches ne ressemble à aucune d'entre elles. Et il peut sembler que Actors / CSP devrait être mis en contraste avec Task.

Mais personnellement, j'aime un point de vue différent.

Lorsque nous parlons du modèle des acteurs et du CSP, nous parlons alors de la décomposition de notre tâche. Dans notre tâche, nous distinguons des entités indépendantes distinctes et décrivons les interfaces de ces entités: quels messages ils envoient, lesquels ils reçoivent, par quels canaux les messages passent.

C'est-à-direen travaillant avec les acteurs et le CSP, nous parlons d'interfaces.

Mais supposons que nous divisions la tâche en acteurs et processus CSP séparés. Comment font-ils exactement leur travail?

Lorsque nous adoptons l'approche basée sur les tâches, nous commençons à parler de mise en œuvre. A propos de la façon dont un travail spécifique est effectué, quelles sous-opérations sont effectuées, dans quel ordre, comment ces sous-opérations sont connectées en fonction des données, etc.

C'est-à-diretravailler avec Task, nous parlons de mise en œuvre.

Par conséquent, les acteurs / CSP et les tâches ne sont pas tellement opposés les uns aux autres, mais se complètent. Les acteurs / CSP peuvent être utilisés pour décomposer les tâches et définir les interfaces entre les composants. Et les tâches peuvent ensuite être utilisées pour implémenter des composants spécifiques.

Par exemple, lorsque vous utilisez Actor, nous avons une entité telle que ImageMixer, qui doit être manipulée avec des images sur le pool de threads. En général, rien ne nous empêche d'utiliser l'acteur ImageMixer pour utiliser l'approche basée sur les tâches.

Où chercher, que prendre?


Si vous souhaitez travailler avec des tâches en C ++, vous pouvez regarder vers la bibliothèque standard du prochain C ++ 20. Ils ont déjà ajouté la méthode .then () à l'avenir, ainsi que les fonctions libres wait_all () et wait_any. Voir cppreference pour plus de détails .

Il y a aussi déjà loin d'une nouvelle bibliothèque async ++ . Dans lequel, en principe, il y a tout ce dont vous avez besoin, juste un peu avec une sauce différente.

Et il existe une bibliothèque Microsoft PPL encore plus ancienne . Ce qui donne aussi tout ce dont vous avez besoin, mais avec votre propre sauce.

Ajout séparé sur la bibliothèque Intel TBB. Cela n'a pas été mentionné dans l'histoire de l'approche basée sur les tâches car, à mon avis, les graphiques de tâches de TBB sont déjà une approche de flux de données. Et, si ce rapport continue, le discours sur Intel TBB viendra certainement, mais dans le contexte de l'histoire du flux de données.

Plus intéressant


Récemment ici, sur Habré, il y avait un article d'Anton Polukhin: "Nous nous préparons pour C ++ 20. Coroutines TS en utilisant un exemple réel ."

Il parle de combiner une approche basée sur les tâches avec des coroutines sans pile de C ++ 20. Et il s'est avéré que le code sur la base de la lisibilité des tâches se rapprochait de la lisibilité du code sur les processus CSP.

Donc, si quelqu'un s'intéresse à l'approche basée sur les tâches, il est logique de lire cet article.

Conclusion


Eh bien, il est temps de passer aux résultats, car ils ne sont pas si nombreux.

La principale chose que je veux dire est que dans le monde moderne, vous pouvez avoir besoin du multithread nu uniquement si vous développez une sorte de cadre ou résolvez une tâche spécifique et de bas niveau.

Et si vous écrivez du code d'application, vous n'avez guère besoin de threads nus, de primitives de synchronisation de bas niveau ou d'une sorte d'algorithmes sans verrouillage avec des conteneurs sans verrouillage. Il existe depuis longtemps des approches éprouvées et éprouvées:

  • acteurs
  • communication des processus séquentiels (CSP)
  • tâches (asynchrones, promesses, futurs, ...)
  • flux de données
  • programmation réactive
  • ...

Et surtout, il existe des outils prêts à l'emploi pour eux en C ++. Vous n'avez pas besoin de faire quoi que ce soit, vous pouvez prendre, essayer et, si vous le souhaitez, mettre en service.

Si simple: prenez, essayez et mettez en service.

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


All Articles