.NET: outils pour travailler avec le multithreading et l'asynchronie. Partie 1

Je publie l'article original sur Habr, dont la traduction est publiée sur le blog Codingsight .
La deuxième partie est disponible ici.

La nécessité de faire quelque chose de manière asynchrone, sans attendre le résultat ici et maintenant, ou de partager beaucoup de travail entre plusieurs unités qui l'exécutent, était même avant l'avènement des ordinateurs. Avec leur apparition, un tel besoin est devenu très tangible. Maintenant, en 2019, en tapant cet article sur un ordinateur portable avec un processeur Intel Core à 8 cœurs, sur lequel pas cent processus fonctionnent en même temps, mais encore plus de threads. À côté se trouve un téléphone légèrement usé, acheté il y a quelques années, avec un processeur à 8 cœurs à bord. Les ressources thématiques sont pleines d'articles et de vidéos où leurs auteurs admirent les smartphones phares de cette année où ils mettent des processeurs 16 cœurs. Pour moins de 20 $ / heure, MS Azure fournit une machine virtuelle avec 128 processeurs principaux et 2 To de RAM. Malheureusement, il est impossible de maximiser et de limiter cette puissance sans pouvoir contrôler l'interaction des flux.

Terminologie


Processus - Un objet OS, un espace d'adressage isolé, contient des threads.
Thread (Thread) - un objet OS, la plus petite unité d'exécution, une partie d'un processus, les threads partagent la mémoire et d'autres ressources entre eux au sein du processus.
Le multitâche est une fonctionnalité du système d'exploitation, la possibilité d'exécuter plusieurs processus en même temps
Multicore - une propriété du processeur, la possibilité d'utiliser plusieurs cœurs pour le traitement des données
Multiprocessing - une propriété d'un ordinateur, la capacité de travailler simultanément avec plusieurs processeurs physiquement
Le multithreading est une propriété d'un processus, la possibilité de répartir le traitement des données entre plusieurs threads.
Parallélisme - effectuer plusieurs actions physiquement en même temps par unité de temps
Asynchronie - l'exécution d'une opération sans attendre la fin de ce traitement, le résultat de l'exécution peut être traité ultérieurement.

Métaphore


Toutes les définitions ne sont pas bonnes et certaines nécessitent des explications supplémentaires, donc j'ajouterai une métaphore pour cuisiner le petit déjeuner à la terminologie officiellement introduite. Préparer le petit-déjeuner dans cette métaphore est un processus.

Cuisiner le petit déjeuner le matin, je ( CPU ) viens à la cuisine ( ordinateur ). J'ai 2 mains ( noyaux ). La cuisine dispose d'un certain nombre d'appareils ( IO ): four, bouilloire, grille-pain, réfrigérateur. J'allume le gaz, j'y mets une poêle et je verse de l'huile dedans, sans attendre qu'il se réchauffe ( asynchrone, Non-Blocking-IO-Wait ), je sors les œufs du réfrigérateur et les casse dans une assiette, puis je les frappe d'une main ( Fil # 1 ), et le second ( Thread # 2 ) je tiens l'assiette (Shared Resource). Maintenant, j'allumerais toujours la bouilloire, mais il n'y a pas assez de mains ( Thread Starvation ) Pendant ce temps, la poêle est chauffée (Traitement du résultat) où je verse ce que j'ai fouetté. J'atteins la bouilloire et l'allume et regarde stupidement comment l'eau y bouillonne ( Blocking-IO-Wait ), bien que je puisse laver l'assiette pendant ce temps, où je bat l'omelette.

J'ai fait cuire une omelette en utilisant seulement 2 mains, et je n'en ai pas plus, mais en même temps, 3 opérations ont eu lieu au moment de fouetter une omelette: fouetter une omelette, tenir une assiette, chauffer une poêle à frire. Le CPU est la partie la plus rapide de l'ordinateur, IO l'est le plus souvent ralentit tout, si souvent une solution efficace consiste à prendre quelque chose de CPU tout en recevant des données d'E / S.

Poursuivant la métaphore:

  • Si dans le processus de prĂ©paration d'une omelette, j'essayerais Ă©galement de changer de vĂŞtements, ce serait un exemple de multitâche. Une nuance importante: les ordinateurs avec cela sont bien meilleurs que les gens.
  • Une cuisine avec plusieurs chefs, par exemple dans un restaurant, est un ordinateur multicĹ“ur.
  • De nombreux restaurants de restauration dans un centre commercial - centre de donnĂ©es

Outils .NET


En travaillant avec des threads, comme dans beaucoup d'autres choses, .NET est bon. Avec chaque nouvelle version, il présente de plus en plus de nouveaux outils pour travailler avec eux, de nouvelles couches d'abstraction sur les threads OS. En travaillant avec la construction d'abstractions, les développeurs de framework utilisent l'approche qui laisse la possibilité lors de l'utilisation d'abstraction de haut niveau, elle descendra d'un ou plusieurs niveaux ci-dessous. Le plus souvent, cela n'est pas nécessaire.En outre, cela ouvre la possibilité qu'un fusil de chasse soit abattu dans le pied, mais parfois, dans de rares cas, cela peut être le seul moyen de résoudre un problème qui ne résout pas au niveau d'abstraction actuel.

Par outils, j'entends à la fois les interfaces de programme (API) fournies par le cadre et les packages tiers, et une solution logicielle complète qui simplifie la recherche de tout problème associé au code multithread.

Début du flux


La classe Thread, la classe la plus basique de .NET pour travailler avec les threads. Le constructeur accepte l'un des deux délégués:

  • ThreadStart - Aucun paramètre
  • ParametrizedThreadStart - avec un paramètre de type objet.

Le délégué sera exécuté dans le thread nouvellement créé après avoir appelé la méthode Start, si un délégué du type ParametrizedThreadStart a été passé au constructeur, alors un objet doit être passé à la méthode Start. Ce mécanisme est nécessaire pour transférer toute information locale vers le flux. Il convient de noter que la création d'un flux est une opération coûteuse et que le flux lui-même est un objet lourd, au moins parce que 1 Mo de mémoire est alloué à la pile et nécessite une interaction avec l'API du système d'exploitation.

new Thread(...).Start(...); 

La classe ThreadPool représente le concept d'un pool. Dans .NET, le pool de threads est un travail d'ingénierie et les développeurs Microsoft ont mis beaucoup d'efforts pour le faire fonctionner de manière optimale dans une grande variété de scénarios.

Concept général:

Depuis le début, l'application en arrière-plan crée plusieurs threads en réserve et offre la possibilité de les utiliser. Si les threads sont utilisés fréquemment et en grand nombre, le pool se développe pour répondre au besoin du code appelant. Lorsqu'il n'y a pas de flux libres dans le pool au bon moment, il attendra le retour de l'un des flux ou en créera un nouveau. Il s'ensuit que le pool de threads est excellent pour certaines actions courtes et mal adapté aux opérations qui fonctionnent en tant que service dans l'ensemble de l'application.

Pour utiliser un thread du pool, il existe une méthode QueueUserWorkItem qui accepte un délégué de type WaitCallback, qui est la même signature que ParametrizedThreadStart, et le paramètre qui lui est transmis remplit la même fonction.

 ThreadPool.QueueUserWorkItem(...); 

La méthode de pool de threads moins connue RegisterWaitForSingleObject est utilisée pour organiser les opérations d'E / S non bloquantes. Le délégué passé à cette méthode sera appelé lorsque le WaitHandle passé à la méthode est «Released».

 ThreadPool.RegisterWaitForSingleObject(...) 

.NET a un temporisateur de flux et il diffère des temporisateurs WinForms / WPF en ce que son gestionnaire sera appelé dans un flux extrait du pool.

 System.Threading.Timer 

Il existe également un moyen assez exotique d'envoyer un délégué au thread depuis le pool - la méthode BeginInvoke.

 DelegateInstance.BeginInvoke 

Je veux également m'attarder sur la transmission d'une fonction qui appelle la plupart des méthodes ci-dessus - CreateThread de Kernel32.dll Win32 API. Il existe un moyen, grâce au mécanisme des méthodes externes, d'appeler cette fonction. Je n'ai vu un tel défi qu'une seule fois dans un terrible exemple de code hérité, et la motivation de l'auteur à le faire est toujours un mystère pour moi.

 Kernel32.dll CreateThread 

Afficher et déboguer les threads


Les threads que vous avez créés personnellement par tous les composants tiers et le pool .NET peuvent être affichés dans la fenêtre Threads Visual Studio. Cette fenêtre affichera des informations sur les flux uniquement lorsque l'application est en cours de débogage et en mode arrêt (mode arrêt). Ici, vous pouvez facilement visualiser les noms de pile et les priorités de chaque thread, basculer le débogage sur un thread spécifique. La propriété Priority de la classe Thread vous permet de définir la priorité du thread, que OC et CLR percevront comme une recommandation lors de la division du temps CPU entre les threads.



Bibliothèque parallèle de tâches


La bibliothèque parallèle de tâches (TPL) est apparue dans .NET 4.0. Maintenant, c'est la norme et l'outil principal pour travailler avec l'asynchronie. Tout code utilisant une approche plus ancienne est considéré comme hérité. L'unité de base de TPL est la classe Task de l'espace de noms System.Threading.Tasks. La tâche est une abstraction sur un thread. Avec la nouvelle version de C #, nous avons eu une manière élégante de travailler avec les opérateurs Task - async / wait. Ces concepts ont permis d'écrire du code asynchrone comme s'il était simple et synchrone, ce qui a permis même aux personnes peu familiarisées avec la cuisine interne des threads d'écrire des applications qui les utilisent, des applications qui ne gèlent pas pendant de longues opérations. Utiliser async / wait est un sujet pour un ou même plusieurs articles, mais je vais essayer de comprendre l'essentiel de quelques phrases:

  • async est un modificateur de la mĂ©thode renvoyant Task ou void
  • et wait est l'instruction d'attente de non-blocage de la tâche.

Encore une fois: l'opérateur wait, dans le cas général (il y a des exceptions), libérera le thread d'exécution en cours, et lorsque la tâche terminera son exécution, et le thread (en fait, il est plus correct de dire le contexte, mais plus à ce sujet plus tard) sera libre de continuer la méthode. Dans .NET, ce mécanisme est implémenté de la même manière que yield return, lorsqu'une méthode écrite se transforme en une classe entière, qui est une machine à états et peut être exécutée en plusieurs parties en fonction de ces états. Toute personne intéressée peut écrire n'importe quel code simple en utilisant asyn / wait, compiler et afficher l'assembly à l'aide de JetBrains dotPeek avec le code généré par le compilateur activé.

Considérez les options de lancement et d'utilisation de Task. En utilisant l'exemple de code ci-dessous, nous créons une nouvelle tâche qui ne fait rien d'utile ( Thread.Sleep (10000) ), mais dans la vie réelle, cela devrait être une sorte de travail complexe impliquant du CPU.

 using TCO = System.Threading.Tasks.TaskCreationOptions; public static async void VoidAsyncMethod() { var cancellationSource = new CancellationTokenSource(); await Task.Factory.StartNew( // Code of action will be executed on other context () => Thread.Sleep(10000), cancellationSource.Token, TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness, scheduler ); // Code after await will be executed on captured context } 

La tâche est créée avec un certain nombre d'options:

  • LongRunning est un indice que la tâche ne sera pas terminĂ©e rapidement, ce qui signifie qu'il pourrait ĂŞtre utile de ne pas prendre un thread du pool, mais d'en crĂ©er un autre pour cette tâche afin de ne pas nuire aux autres.
  • AttachedToParent - Les tâches peuvent ĂŞtre organisĂ©es dans une hiĂ©rarchie. Si cette option a Ă©tĂ© utilisĂ©e, la tâche peut ĂŞtre dans un Ă©tat lorsqu'elle s'est terminĂ©e et attend que les enfants se terminent.
  • PreferFairness - signifie qu'il serait bien d'exĂ©cuter les tâches envoyĂ©es plus tĂ´t pour exĂ©cution avant celles qui ont Ă©tĂ© envoyĂ©es plus tard. Mais ce n'est qu'une recommandation et le rĂ©sultat n'est pas garanti.

Le deuxième paramètre de la méthode a passé CancellationToken. Afin de traiter correctement l'annulation d'une opération après son lancement, le code exécuté doit être rempli avec des vérifications d'état de CancellationToken. S'il n'y a aucune vérification, la méthode Cancel appelée sur l'objet CancellationTokenSource ne pourra arrêter l'exécution de la tâche qu'avant son démarrage.

Le dernier paramètre a passé l'objet planificateur de type TaskScheduler. Cette classe et ses descendants sont conçus pour contrôler les stratégies de distribution de Task'ov par thread, par défaut, Task sera exécutée sur un thread aléatoire du pool.

L'opérateur d'attente est appliqué à la tâche créée, ce qui signifie que le code écrit après, le cas échéant, sera exécuté dans le même contexte (souvent cela signifie qu'il se trouve sur le même thread) que le code avant wait.

La méthode est marquée comme async void, ce qui signifie que vous pouvez y utiliser l'opérateur wait, mais le code appelant ne peut pas attendre son exécution. Si cette fonctionnalité est nécessaire, la méthode doit renvoyer Task. Les méthodes marquées async void sont assez courantes: en règle générale, ce sont des gestionnaires d'événements ou d'autres méthodes qui fonctionnent sur le principe du feu et oublient. Si vous devez non seulement donner la possibilité d'attendre la fin de l'exécution, mais également renvoyer le résultat, vous devez utiliser Task.

Cependant, sur la tâche renvoyée par la méthode StartNew, comme sur n'importe quelle autre, vous pouvez appeler la méthode ConfigureAwait avec le faux paramètre, puis l'exécution après l'attente se poursuivra non pas sur le contexte capturé, mais sur un contexte arbitraire. Cela doit toujours être fait lorsque le contexte d'exécution n'est pas important pour le code après l'attente. Il est également une recommandation de MS lors de l'écriture de code qu'il sera emballé sous forme de bibliothèque.

Attardons-nous un peu plus sur la façon dont vous pouvez attendre la fin de la tâche. Voici un exemple de code, avec des commentaires, lorsque l'attente est conditionnellement bonne et conditionnellement mauvaise.

 public static async void AnotherMethod() { int result = await AsyncMethod(); // good result = AsyncMethod().Result; // bad AsyncMethod().Wait(); // bad IEnumerable<Task> tasks = new Task[] { AsyncMethod(), OtherAsyncMethod() }; await Task.WhenAll(tasks); // good await Task.WhenAny(tasks); // good Task.WaitAll(tasks.ToArray()); // bad } 

Dans le premier exemple, nous attendons que la tâche se termine et sans bloquer le thread appelant, nous ne reviendrons au traitement du résultat que lorsqu'il est déjà là, jusqu'à ce que le thread appelant soit laissé à lui-même.

Dans la deuxième option, nous bloquons le thread appelant jusqu'à ce que le résultat de la méthode soit calculé. C'est mauvais non seulement parce que nous avons pris le thread, une ressource si précieuse du programme, par simple oisiveté, mais aussi parce que si le code de la méthode que nous appelons a attendu et que le contexte de synchronisation implique de retourner au thread appelant après wait, nous obtiendrons un blocage. : le thread appelant attend le calcul du résultat de la méthode asynchrone, la méthode asynchrone essaie en vain de continuer son exécution dans le thread appelant.

Un autre inconvénient de cette approche est la gestion compliquée des erreurs. Le fait est que les erreurs dans le code asynchrone lors de l'utilisation de async / wait sont très faciles à gérer - elles se comportent comme si le code était synchrone. Alors que, si nous appliquons l' exorcisme, l' attente synchrone à la tâche, l'exception d'origine se transforme en une AggregateException, c'est-à-dire Pour gérer une exception, vous devrez examiner le type InnerException et écrire la chaîne if à l'intérieur d'un bloc catch ou utiliser la capture catch lors de la construction au lieu de la chaîne de blocs catch plus familière en C #.

Le troisième et dernier exemple sont également marqués mauvais pour la même raison et contiennent tous les mêmes problèmes.

Les méthodes WhenAny et WhenAll sont extrêmement pratiques pour attendre un groupe de Task'ov, elles enveloppent un groupe de Task'ov en un, qui fonctionnera soit sur la première opération de Task'a du groupe, soit lorsque tout le monde aura terminé son exécution.

Arrêt de débit


Pour diverses raisons, il peut être nécessaire d'arrêter le flux après son démarrage. Il existe plusieurs façons de procéder. La classe Thread a deux méthodes avec des noms appropriés - Abort et Interruption . Le premier n'est pas recommandé pour utilisation, car après son appel à tout moment aléatoire, au cours du traitement d'une instruction, une ThreadAbortedException sera levée. Vous ne vous attendez pas à ce qu'une telle exception se bloque lors de l'incrémentation d'une variable entière, non? Et lorsque vous utilisez cette méthode, c'est une situation très réelle. Si vous souhaitez empêcher le CLR de lever une telle exception dans une section spécifique du code, vous pouvez l' encapsuler dans des appels à Thread.BeginCriticalRegion , Thread.EndCriticalRegion . Tout code écrit dans un bloc finally est encapsulé avec de tels appels. Pour cette raison, dans les entrailles du code cadre, vous pouvez trouver des blocs avec un essai vide, mais pas un vide finalement. Microsoft ne recommande donc pas d'utiliser cette méthode, car ils ne l'ont pas incluse dans le noyau .net.

La méthode d'interruption fonctionne de manière plus prévisible. Il peut interrompre un thread à l'exception de ThreadInterruptedException uniquement lorsque le thread est à l'état inactif. Dans cet état, il se met en suspension en attendant WaitHandle, lock ou après avoir appelé Thread.Sleep.

Les deux options décrites ci-dessus sont mauvaises pour leur imprévisibilité. La solution consiste à utiliser la structure CancellationToken et la classe CancellationTokenSource . La conclusion est la suivante: une instance de la classe CancellationTokenSource est créée et seule la personne qui la possède peut arrêter l'opération en appelant la méthode Cancel . Seul le CancellationToken est transmis à l'opération elle-même. Les propriétaires du CancellationToken ne peuvent pas annuler l'opération eux-mêmes, mais peuvent uniquement vérifier si l'opération a été annulée. Pour ce faire, il existe une propriété booléenne IsCancellationRequested et la méthode ThrowIfCancelRequested . Ce dernier déclenchera une exception TaskCancelledException si la méthode Cancel est appelée sur l'instance CancellationToken annulée de CancellationTokenSource. Et c'est cette méthode que je recommande d'utiliser. C'est mieux que les options précédentes en obtenant un contrôle total sur les points où l'opération d'exception peut être interrompue.

L'option la plus cruelle pour arrêter le thread est d'appeler la fonction TerminateThread de l'API Win32. Le comportement du CLR après avoir appelé cette fonction peut être imprévisible. Sur MSDN, ce qui suit est écrit à propos de cette fonction: «TerminateThread est une fonction dangereuse qui ne doit être utilisée que dans les cas les plus extrêmes. "

Convertir l'ancienne API en tâche basée sur la méthode FromAsync


Si vous avez eu la chance de travailler sur un projet qui a été lancé après que les tâches ont été introduites et a cessé de provoquer une horreur silencieuse pour la plupart des développeurs, alors vous n'aurez pas à faire face à de nombreuses anciennes API, des tiers et votre équipe ont été torturés dans le passé. Heureusement, l'équipe de développement de .NET Framework a pris soin de nous, même si l'objectif était peut-être de prendre soin de nous. Quoi qu'il en soit, .NET dispose d'un certain nombre d'outils pour convertir sans peine du code écrit dans d'anciennes approches de programmation asynchrone en un nouveau. L'un d'eux est la méthode FromAsync de TaskFactory. En utilisant l'exemple de code ci-dessous, j'encapsule les anciennes méthodes asynchrones de la classe WebRequest dans Task en utilisant cette méthode.

 object state = null; WebRequest wr = WebRequest.CreateHttp("http://github.com"); await Task.Factory.FromAsync( wr.BeginGetResponse, we.EndGetResponse ); 

Ceci n'est qu'un exemple et il est peu probable que vous ayez à le faire avec des types intégrés, mais tout ancien projet regorge simplement de méthodes BeginDoSomething qui renvoient des méthodes IAsyncResult et EndDoSomething qui l'acceptent.

Conversion de l'API héritée en tâche basée sur la classe TaskCompletionSource


Un autre outil important à considérer est la classe TaskCompletionSource . En termes de fonctions, de but et de principe de fonctionnement, il peut en quelque sorte rappeler la méthode RegisterWaitForSingleObject de la classe ThreadPool sur laquelle j'ai écrit ci-dessus. En utilisant cette classe, vous pouvez facilement et commodément encapsuler les anciennes API asynchrones dans Task.

Vous direz que j'ai déjà parlé de la méthode FromAsync de la classe TaskFactory destinée à ces fins. Ici, nous devrons rappeler toute l'histoire du développement de modèles asynchrones dans .net que Microsoft propose depuis 15 ans: avant le modèle asynchrone basé sur les tâches (TAP), il y avait le modèle de programmation asynchrone (APP), qui concernait les méthodes Begin DoSomething qui renvoient les méthodes IAsyncResult et End DoSomething qui l'acceptent et la méthode FromAsync est très bien pour l'héritage de ces années, mais au fil du temps, elle a été remplacée par le modèle asynchrone basé sur les événements ( EAP ), qui supposait qu'un événement serait déclenché lorsque l'opération asynchrone serait terminée.

TaskCompletionSource est tout simplement génial pour encapsuler des tâches et des API héritées construites autour du modèle d'événement. L'essence de son travail est la suivante: un objet de cette classe a une propriété publique de type Task dont l'état peut être contrôlé via les méthodes SetResult, SetException, etc. de la classe TaskCompletionSource. Aux endroits où l'opérateur d'attente a été appliqué à cette tâche, il sera exécuté ou bloqué avec une exception, selon la méthode appliquée à TaskCompletionSource. Si tout n'est toujours pas clair, regardons cet exemple de code, où une ancienne API EAP est encapsulée dans la tâche à l'aide de TaskCompletionSource: lorsque l'événement est déclenché, la tâche sera transférée à l'état Terminé et la méthode qui a appliqué l'opérateur d'attente à cette tâche reprend l'exécution obtenir l'objet résultat .

 public static Task<Result> DoAsync(this SomeApiInstance someApiObj) { var completionSource = new TaskCompletionSource<Result>(); someApiObj.Done += result => completionSource.SetResult(result); someApiObj.Do(); result completionSource.Task; } 

Trucs et astuces de TaskCompletionSource


Envelopper les anciennes API n'est pas tout ce que vous pouvez faire avec TaskCompletionSource. L'utilisation de cette classe ouvre une possibilité intéressante de conception de diverses API sur des tâches qui n'occupent pas de threads. Et le flux, on s'en souvient, est une ressource chère et leur nombre est limité (principalement par la RAM). Cette limitation est facilement obtenue en développant, par exemple, une application Web chargée avec une logique métier complexe. Considérez les possibilités dont je parle d'implémenter une telle astuce comme l'interrogation longue.

En bref, l'essentiel de l'astuce est le suivant: vous devez obtenir des informations de l'API sur certains événements se produisant de son côté, tandis que l'API, pour une raison quelconque, ne peut pas signaler l'événement, mais ne peut que renvoyer l'état. Un exemple de cela est toutes les API construites sur HTTP avant l'époque de WebSocket ou lorsqu'il est impossible pour une raison quelconque d'utiliser cette technologie. Le client peut demander au serveur HTTP. Un serveur HTTP ne peut pas lui-même provoquer une communication avec un client. Une solution simple consiste à interroger le serveur par minuterie, mais cela crée une charge supplémentaire sur le serveur et un retard supplémentaire en moyenne TimerInterval / 2. Pour contourner ce problème, une astuce appelée Long Polling a été inventée, qui implique de retarder la réponse du serveur jusqu'à l'expiration du délai ou un événement se produira. Si un événement s'est produit, il est traité; sinon, la demande est envoyée à nouveau.

 while(!eventOccures && !timeoutExceeded) { CheckTimout(); CheckEvent(); Thread.Sleep(1); } 

Mais une telle solution se révélera terriblement dès que le nombre de clients en attente de l'événement augmentera, car Chacun de ces clients, en prévision de l'événement, occupe un flux entier. Oui, et nous obtenons un délai supplémentaire de 1 ms sur le déclenchement de l'événement, le plus souvent il n'est pas significatif, mais pourquoi rendre le logiciel pire qu'il ne peut l'être? Si vous supprimez Thread.Sleep (1), alors en vain, nous chargerons un cœur de processeur à 100% inactif, tournant dans un cycle inutile. En utilisant TaskCompletionSource, vous pouvez facilement refaire ce code et résoudre tous les problèmes identifiés ci-dessus:

 class LongPollingApi { private Dictionary<int, TaskCompletionSource<Msg>> tasks; public async Task<Msg> AcceptMessageAsync(int userId, int duration) { var cs = new TaskCompletionSource<Msg>(); tasks[userId] = cs; await Task.WhenAny(Task.Delay(duration), cs.Task); return cs.Task.IsCompleted ? cs.Task.Result : null; } public void SendMessage(int userId, Msg m) { if (tasks.TryGetValue(userId, out var completionSource)) completionSource.SetResult(m); } } 

Ce code n'est pas prêt pour la production, mais juste une démo. Pour l'utiliser dans des cas réels, vous devez également gérer au moins la situation lorsqu'un message arrive à un moment où personne ne l'attend: dans ce cas, la méthode AsseptMessageAsync doit renvoyer une tâche déjà terminée. Si ce cas est le plus fréquent, vous pouvez penser à utiliser ValueTask.

À la réception d'une demande de message, nous créons et plaçons TaskCompletionSource dans le dictionnaire, puis nous attendons ce qui se passe en premier: l'intervalle de temps spécifié expire ou un message est reçu.

ValueTask: pourquoi et comment


Les opérateurs asynchrones / attendent, comme l'opérateur de retour de rendement, génèrent une machine d'état à partir de la méthode, qui crée un nouvel objet, ce qui n'est presque toujours pas important, mais dans de rares cas, cela peut créer un problème. Ce cas peut être une méthode appelée très souvent, parlant de dizaines et de centaines de milliers d'appels par seconde. Si une telle méthode est écrite de sorte que dans la plupart des cas, elle renvoie un résultat en contournant toutes les méthodes en attente, alors .NET fournit un outil pour optimiser cela - la structure ValueTask. Pour être clair, considérons un exemple de son utilisation: il y a un cache auquel nous allons très souvent. Il y a quelques valeurs dedans et ensuite nous les retournons simplement, sinon, nous allons à un IO lent derrière eux. Je veux faire ce dernier de manière asynchrone, ce qui signifie que toute la méthode est asynchrone. Ainsi, la manière évidente d'écrire une méthode est la suivante:

 public async Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return val; return await RequestById(id); } 

En raison du désir d'optimiser un peu et d'une légère crainte de ce que Roslyn générera en compilant ce code, nous pouvons réécrire cet exemple comme suit:

 public Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return Task.FromResult(val); return RequestById(id); } 

En effet, la solution optimale dans ce cas est d'optimiser le hot-path, à savoir, obtenir la valeur du dictionnaire sans aucune allocation supplémentaire et charge sur le GC, tandis que dans les rares cas où nous devons encore aller à l'IO, tout restera plus / moins vieux:

 public ValueTask<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return new ValueTask<string>(val); return new ValueTask<string>(RequestById(id)); } 

Examinons de plus près ce fragment de code: s'il y a une valeur dans le cache, nous créons une structure, sinon la vraie tâche sera enveloppée dans une importante. Le code appelant ne se soucie pas de la façon dont ce code a été exécuté: ValueTask du point de vue de la syntaxe C # se comportera comme la tâche habituelle dans ce cas.

TaskSchedulers: gestion des stratégies de lancement de tâches


La prochaine API que je voudrais considérer est la classe TaskScheduler et ses dérivés. J'ai déjà mentionné ci-dessus que dans TPL il y a la possibilité de contrôler les stratégies de distribution de Task'ov par thread. Ces stratégies sont définies dans les descendants de la classe TaskScheduler. Presque toutes les stratégies qui peuvent être nécessaires se trouvent dans la bibliothèque ParallelExtensionsExtras , développée par Microsoft, mais ne faisant pas partie de .NET, mais livrée sous forme de package Nuget. Examinons brièvement certains d'entre eux:

  • CurrentThreadTaskScheduler - ExĂ©cute une tâche sur le thread actuel
  • LimitedConcurrencyLevelTaskScheduler - limite le nombre de tâches exĂ©cutĂ©es simultanĂ©ment au paramètre N, qui est acceptĂ© dans le constructeur
  • OrderedTaskScheduler — LimitedConcurrencyLevelTaskScheduler(1), .
  • WorkStealingTaskScheduler — work-stealing . ThreadPool. , .NET ThreadPool , , . . T.O. WorkStealingTaskScheduler' , ThreadPool .
  • QueuedTaskScheduler - vous permet d'effectuer des tâches selon les règles de file d'attente avec des prioritĂ©s
  • ThreadPerTaskScheduler - crĂ©e un thread sĂ©parĂ© pour chaque tâche qui s'exĂ©cute dessus. Il peut ĂŞtre utile pour des tâches d'une durĂ©e imprĂ©visible.

Il y a un bon article détaillé sur TaskSchedulers sur le blog Microsoft.

Pour un débogage pratique de tout ce qui concerne les tâches dans Visual Studio, il existe une fenêtre Tâches. Dans cette fenêtre, vous pouvez voir l'état actuel de la tâche et accéder à la ligne de code en cours d'exécution.



PLinq et la classe Parallel


En plus de Task et de tout ce qui a été dit avec eux dans .NET, il existe deux autres outils intéressants: PLinq (Linq2Parallel) et la classe Parallel. Le premier promet l'exécution parallèle de toutes les opérations Linq sur plusieurs threads. Le nombre de threads peut être configuré avec la méthode d'extension WithDegreeOfParallelism. Malheureusement, le plus souvent PLinq en mode exécution par défaut n'aura pas assez d'informations sur l'intérieur de votre source de données pour fournir un gain de vitesse significatif, d'autre part, le prix de la tentative est très bas: il vous suffit d'appeler la méthode AsParallel en face de la chaîne de méthode Linq et d'effectuer des tests de performances. De plus, il est possible de transférer vers PLinq des informations supplémentaires sur la nature de votre source de données en utilisant le mécanisme de partitions. Vous pouvez en lire plus ici et ici..

La classe statique Parallel fournit des méthodes pour itérer sur une collection Foreach en parallèle, exécuter une boucle For et exécuter plusieurs délégués en parallèle à Invoke. L'exécution du thread en cours sera arrêtée jusqu'à la fin des calculs. Le nombre de threads peut être configuré en passant ParallelOptions comme dernier argument. À l'aide des options, vous pouvez également spécifier TaskScheduler et CancellationToken.

Conclusions


Lorsque j'ai commencé à écrire cet article sur la base des éléments de mon rapport et des informations que j'ai collectées au cours de mon travail après, je ne m'attendais pas à ce qu'il aboutisse autant. Maintenant, lorsque l'éditeur de texte dans lequel je tape cet article me dit avec reproche que la 15e page est partie, je vais résumer les résultats intermédiaires. D'autres astuces, API, outils visuels et pièges seront abordés dans un prochain article.

Conclusions:

  • Vous devez connaĂ®tre les outils pour travailler avec les threads, l'asynchronie et le parallĂ©lisme afin d'utiliser les ressources des PC modernes.
  • .NET dispose de nombreux outils diffĂ©rents Ă  cet effet.
  • Ils ne sont pas tous apparus en mĂŞme temps, car il est souvent possible de trouver un hĂ©ritage, mais il existe des moyens de convertir les anciennes API sans trop d'effort.
  • Le travail avec les threads dans .NET est reprĂ©sentĂ© par les classes Thread et ThreadPool
  • Thread.Abort, Thread.Interrupt, Win32 API TerminateThread . CancellationToken'
  • — , . , . TaskCompletionSource
  • .NET Task'.
  • c# async/await
  • Task' TaskScheduler'
  • ValueTask hot-paths memory-traffic
  • Tasks Threads Visual Studio
  • PLinq , , partitioning
  • ...

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


All Articles