.NET: Outils pour travailler avec le multithread et l'asynchronie - Partie 1

J'ai initialement publié cet article dans le blog CodingSight
La deuxiĂšme partie de l'article est disponible ici

La nĂ©cessitĂ© de faire les choses de maniĂšre asynchrone - c'est-Ă -dire de diviser les grandes tĂąches entre plusieurs unitĂ©s de travail - Ă©tait prĂ©sente bien avant l'apparition des ordinateurs. Cependant, lorsqu'ils sont apparus, ce besoin est devenu encore plus Ă©vident. Nous sommes en 2019 et j'Ă©cris cet article sur un ordinateur portable alimentĂ© par un processeur Intel Core Ă  8 cƓurs qui, en plus de cela, travaille simultanĂ©ment sur des centaines de processus, avec un nombre de threads encore plus important. À cĂŽtĂ© de moi, se trouve un smartphone lĂ©gĂšrement obsolĂšte que j'ai achetĂ© il y a quelques annĂ©es - et il abrite Ă©galement un processeur Ă  8 cƓurs. Les ressources Web spĂ©cialisĂ©es contiennent une grande variĂ©tĂ© d'articles faisant l'Ă©loge des smartphones phares de cette annĂ©e Ă©quipĂ©s de processeurs 16 cƓurs. Pour moins de 20 $ de l'heure, MS Azure peut vous donner accĂšs Ă  une machine virtuelle Ă  128 cƓurs avec 2 To de RAM. Mais, malheureusement, vous ne pouvez pas tirer le meilleur parti de ce pouvoir Ă  moins de savoir comment contrĂŽler l'interaction entre les threads.

Table des matiĂšres




Terminologie


Processus - un objet OS qui représente un espace d'adressage isolé contenant des threads.

Thread - un objet OS qui représente la plus petite unité d'exécution. Les threads sont des éléments constitutifs des processus, ils divisent la mémoire et d'autres ressources entre eux dans le cadre d'un processus.

Multitùche - une fonctionnalité du systÚme d'exploitation qui représente la capacité d'exécuter plusieurs processus simultanément.

Multi-core - une fonction CPU qui reprĂ©sente la possibilitĂ© d'utiliser plusieurs cƓurs pour le traitement des donnĂ©es

Multiprocessing - la caractéristique d'un ordinateur qui représente la capacité de travailler physiquement avec plusieurs CPU.

Multi-threading - la caractéristique d'un processus qui représente la capacité de diviser et de répartir le traitement des données entre plusieurs threads.

Parallélisme - exécution physique simultanée de plusieurs actions dans une unité de temps

Asynchronie - exécuter une opération sans attendre qu'elle soit entiÚrement traitée, laissant le calcul du résultat pour plus tard.


Une métaphore


Toutes les définitions ne sont pas efficaces et certaines d'entre elles nécessitent une élaboration, alors permettez-moi de fournir une métaphore culinaire pour la terminologie que je viens d'introduire.

Faire le petit déjeuner représente un processus dans cette métaphore.

En faisant le petit dĂ©jeuner le matin, je ( CPU ) vais Ă  la cuisine ( Ordinateur ). J'ai deux mains ( noyaux ). Sur la cuisine, il y a un assortiment d'appareils ( IO ): cuisiniĂšre, bouilloire, grille-pain, rĂ©frigĂ©rateur. J'allume le poĂȘle, y mets une poĂȘle et y verse de l'huile vĂ©gĂ©tale. Sans attendre que l'huile se rĂ©chauffe ( asynchrone, non bloquant-IO-Wait ), je rĂ©cupĂšre des Ɠufs du rĂ©frigĂ©rateur, les casse sur un bol et puis je les fouette d'une main ( fil # 1 ). Pendant ce temps, la seconde main (Thread # 2) tient le bol en place ( Shared Resource ). Je voudrais allumer la bouilloire, mais je n'ai pas assez de mains libres pour le moment ( Thread Starvation ). Pendant que je fouettais les Ɠufs, la poĂȘle est devenue assez chaude (traitement des rĂ©sultats), alors j'ai versĂ© les Ɠufs fouettĂ©s dedans. J'atteins la bouilloire, l'allume et regarde l'eau bouillante ( Blocking-IO-Wait ) - mais j'aurais pu utiliser cette fois pour laver le bol.

Je n'ai utilisĂ© que 2 mains pour faire l'omelette (car je n'en ai pas plus), mais il y a eu 3 opĂ©rations simultanĂ©es en cours d'exĂ©cution: fouetter les Ɠufs, tenir le bol, chauffer la poĂȘle. Le CPU est la partie la plus rapide de l'ordinateur et IO est la partie qui nĂ©cessite d'attendre le plus souvent, il est donc assez efficace de charger le CPU avec du travail pendant qu'il attend les donnĂ©es d'IO.

Pour étendre la métaphore:

  • Si j'essayais Ă©galement de changer de vĂȘtements pendant le petit-dĂ©jeuner, j'aurais Ă©tĂ© multitĂąche . Les ordinateurs sont bien meilleurs Ă  cet Ă©gard que les humains.
  • Une cuisine avec plusieurs cuisiniers - par exemple, dans un restaurant - est un ordinateur multicƓur .
  • Une aire de restauration de centre commercial avec de nombreux restaurants reprĂ©senterait un centre de donnĂ©es .



Outils .NET


.NET est vraiment bon quand il s'agit de travailler avec des threads - ainsi qu'Ă  bien d'autres choses. Avec chaque nouvelle version, il fournit plus d'outils pour travailler avec des threads et de nouvelles couches d'abstraction de threads OS. Lorsqu'ils travaillent avec des abstractions, les dĂ©veloppeurs travaillant avec le framework utilisent une approche qui leur permet de descendre une ou plusieurs couches tout en utilisant des abstractions de haut niveau. Dans la plupart des cas, cela n'est pas vraiment nĂ©cessaire (et cela peut introduire une possibilitĂ© de se tirer une balle dans le pied), mais parfois, cela peut ĂȘtre le seul moyen de rĂ©soudre un problĂšme qui ne peut pas ĂȘtre rĂ©solu au niveau d'abstraction actuel.

Lorsque j'ai parlé des outils plus tÎt, je parlais à la fois des interfaces de programme (API) fournies par le cadre ou des packages tiers et des solutions logicielles à part entiÚre qui simplifient le processus de recherche de problÚmes liés au code multithread.


Démarrage d'un fil


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

  • ThreadStart - aucun paramĂštre
  • ParametrizedThreadStart - un paramĂštre de type objet.


Le dĂ©lĂ©guĂ© sera exĂ©cutĂ© dans un thread nouvellement créé aprĂšs avoir appelĂ© la mĂ©thode Start. Si le dĂ©lĂ©guĂ© ParametrizedThreadStart a Ă©tĂ© passĂ© au constructeur, alors un objet doit ĂȘtre passĂ© Ă  la mĂ©thode Start. Ce processus est nĂ©cessaire pour transmettre toute information locale au thread. Je dois souligner qu'il faut beaucoup de ressources pour crĂ©er un thread et que le thread lui-mĂȘme est un objet lourd - au moins parce qu'il nĂ©cessite une interaction avec l'API du systĂšme d'exploitation et 1 Mo de mĂ©moire est allouĂ© Ă  la pile.

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

La classe ThreadPool reprĂ©sente le concept d'un pool. Dans .NET, le pool de threads est une Ɠuvre d'art d'ingĂ©nierie et les dĂ©veloppeurs Microsoft ont investi beaucoup d'efforts pour le faire fonctionner de maniĂšre optimale dans toutes sortes de scĂ©narios.

Le concept général:
Au démarrage, l'application crée quelques threads en arriÚre-plan, permettant d'y accéder en cas de besoin. Si les threads sont utilisés fréquemment et en grand nombre, le pool est étendu pour répondre aux besoins du code appelant. Si le pool n'a pas suffisamment de threads libres au bon moment, il attendra que l'un des threads actifs devienne inoccupé ou en créera un nouveau. Sur cette base, il s'ensuit que le pool de threads est parfait pour les actions courtes et ne fonctionne pas si bien pour les processus qui fonctionnent comme des services pendant toute la durée de fonctionnement de l'application.

La mĂ©thode QueueUserWorkItem permet d'utiliser des threads du pool. Cette mĂ©thode prend le dĂ©lĂ©guĂ© de type WaitCallback . Sa signature coĂŻncide avec la signature de ParametrizedThreadStart et le paramĂštre qui lui est transmis remplit le mĂȘme rĂŽle.

 ThreadPool.QueueUserWorkItem(...); 

La méthode de pool de threads RegisterWaitForSingleObject moins connue est utilisée pour organiser les opérations d'E / S non bloquantes. Le délégué qui est passé à cette méthode sera appelé lorsque le WaitHandle est libéré aprÚs avoir été passé à la méthode.

 ThreadPool.RegisterWaitForSingleObject(...) 


Il existe un temporisateur de threads dans .NET, et il diffÚre des temporisateurs WinForms / WPF en ce que son gestionnaire est appelé dans le thread extrait du pool.

 System.Threading.Timer 


Il existe également un moyen assez inhabituel d'envoyer le délégué à un thread à partir du pool - la méthode BeginInvoke.

 DelegateInstance.BeginInvoke 


Je voudrais Ă©galement jeter un coup d'Ɠil Ă  la fonction que la plupart des mĂ©thodes que j'ai mentionnĂ©es prĂ©cĂ©demment se rĂ©sument Ă  - CreateThread de l'API Win32 Kernel32.dll. Il existe un moyen d'appeler cette fonction Ă  l'aide du mĂ©canisme externe des mĂ©thodes. Je n'ai vu cela ĂȘtre utilisĂ© qu'une seule fois dans un cas particuliĂšrement mauvais de code hĂ©ritĂ© - et je ne comprends toujours pas les raisons de son auteur.
 Kernel32.dll CreateThread 



Affichage et débogage des threads


Tous les threads - qu'ils soient créés par vous, des composants tiers ou le pool .NET - peuvent ĂȘtre affichĂ©s dans la fenĂȘtre Threads de Visual Studio. Cette fenĂȘtre n'affichera les informations sur les threads que lorsque l'application est en cours de dĂ©bogage en mode ArrĂȘt. Ici, vous pouvez afficher les noms et les prioritĂ©s de chaque thread et concentrer le mode de dĂ©bogage sur des threads spĂ©cifiques. La propriĂ©tĂ© Priority de la classe Thread vous permet de dĂ©finir la prioritĂ© du thread. Cette prioritĂ© sera alors prise en considĂ©ration lorsque le systĂšme d'exploitation et le CLR rĂ©partissent le temps processeur entre les threads.




BibliothĂšque parallĂšle de tĂąches


La bibliothĂšque parallĂšle de tĂąches (TPL) est apparue pour la premiĂšre fois dans .NET 4.0. Actuellement, c'est l'outil principal pour travailler avec l'asynchronie. Tout code utilisant des approches plus anciennes sera considĂ©rĂ© comme du code hĂ©ritĂ©. L'unitĂ© principale de TPL est la classe Task de l'espace de noms System.Threading.Tasks. Les tĂąches reprĂ©sentent l'abstraction des threads. Avec la derniĂšre version de C #, nous avons acquis une nouvelle façon Ă©lĂ©gante de travailler avec les tĂąches - les opĂ©rateurs asynchrones / attendent. Celles-ci permettent d'Ă©crire du code asynchrone comme s'il Ă©tait simple et synchrone, de sorte que ceux qui ne connaissent pas bien la thĂ©orie des threads peuvent dĂ©sormais Ă©crire des applications qui n'auront pas de difficultĂ© avec de longues opĂ©rations. Utiliser async / wait est vraiment un sujet pour un article sĂ©parĂ© (ou mĂȘme quelques articles), mais je vais essayer de dĂ©crire les bases en quelques phrases:

  • async est un modificateur d'une mĂ©thode qui renvoie une tĂąche ou un vide
  • attend est un opĂ©rateur d'une tĂąche d'attente non bloquante.


Encore une fois: l'opĂ©rateur wait laissera gĂ©nĂ©ralement (il y a des exceptions) le thread courant et, lorsque la tĂąche sera exĂ©cutĂ©e et le thread (en fait, le contexte, mais nous y reviendrons plus tard) sera libre en tant que Par consĂ©quent, il continuera Ă  exĂ©cuter la mĂ©thode. Dans .NET, ce mĂ©canisme est implĂ©mentĂ© de la mĂȘme maniĂšre que yield return - une mĂ©thode est transformĂ©e en une classe de machine Ă  Ă©tats finis qui peut ĂȘtre exĂ©cutĂ©e en plusieurs morceaux en fonction de son Ă©tat. Si cela semble intĂ©ressant, je recommanderais d'Ă©crire n'importe quel morceau de code simple basĂ© sur async / wait, de le compiler et de regarder sa compilation Ă  l'aide de JetBrains dotPeek avec le code gĂ©nĂ©rĂ© par le compilateur activĂ©.

Examinons les options dont nous disposons lorsqu'il s'agit de démarrer et d'utiliser une tùche. Dans l'exemple ci-dessous, nous créons une nouvelle tùche qui ne fait rien de productif (Thread.Sleep (10000)). Cependant, dans les cas réels, nous devons le remplacer par un travail complexe qui utilise les ressources du processeur.

 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 } 


Une tùche est créée avec les options suivantes:

  • LongRunning - cette option indique que la tĂąche ne peut pas ĂȘtre effectuĂ©e rapidement. Par consĂ©quent, il est peut-ĂȘtre prĂ©fĂ©rable de crĂ©er un thread distinct pour cette tĂąche plutĂŽt que d'en prendre un existant dans le pool pour minimiser les dommages causĂ©s aux autres tĂąches.
  • AttachedToParent - Les tĂąches peuvent ĂȘtre organisĂ©es de maniĂšre hiĂ©rarchique. Si cette option est utilisĂ©e, la tĂąche attendra que ses tĂąches enfants soient exĂ©cutĂ©es aprĂšs avoir Ă©tĂ© exĂ©cutĂ©e elle-mĂȘme.
  • PreferFairness - cette option spĂ©cifie que la tĂąche doit ĂȘtre mieux exĂ©cutĂ©e avant les tĂąches qui ont Ă©tĂ© créées ultĂ©rieurement. Cependant, c'est plus une suggestion, donc le rĂ©sultat n'est pas toujours garanti.


Le deuxiĂšme paramĂštre transmis Ă  la mĂ©thode est CancellationToken. Pour que l'opĂ©ration soit correctement annulĂ©e aprĂšs son dĂ©marrage, le code exĂ©cutable doit contenir des vĂ©rifications d'Ă©tat CancellationToken. S'il n'y a pas de telles vĂ©rifications, la mĂ©thode Cancel appelĂ©e sur l'objet CancellationTokenSource ne pourra arrĂȘter l'exĂ©cution de la tĂąche qu'avant le dĂ©marrage de la tĂąche.

Pour le dernier paramÚtre, nous avons envoyé un objet de type TaskScheduler appelé ordonnanceur. Cette classe, avec ses classes enfants, est utilisée pour contrÎler la répartition des tùches entre les threads. Par défaut, une tùche sera exécutée sur un thread sélectionné au hasard dans le pool

L'opĂ©rateur d'attente est appliquĂ© Ă  la tĂąche créée. Cela signifie que le code Ă©crit aprĂšs (s'il existe un tel code) sera exĂ©cutĂ© dans le mĂȘme contexte (souvent, cela signifie «sur le mĂȘme thread») que le code Ă©crit avant l'attend.

Cette mĂ©thode est Ă©tiquetĂ©e comme async void, ce qui signifie que l'opĂ©rateur d'attente peut y ĂȘtre utilisĂ©, mais le code appelant ne pourra pas attendre l'exĂ©cution. Si une telle possibilitĂ© est nĂ©cessaire, la mĂ©thode doit renvoyer une tĂąche. Les mĂ©thodes Ă©tiquetĂ©es comme async void peuvent ĂȘtre vues assez souvent: ce sont gĂ©nĂ©ralement des gestionnaires d'Ă©vĂ©nements ou d'autres mĂ©thodes fonctionnant sous le principe du feu et de l'oubli. S'il est nĂ©cessaire d'attendre la fin de l'exĂ©cution et de renvoyer le rĂ©sultat, vous devez utiliser Task.

Pour les tĂąches qui renvoient la mĂ©thode StartNew, nous pouvons appeler ConfigureAwait avec le faux paramĂštre - puis, l'exĂ©cution aprĂšs attente se poursuivra sur un contexte alĂ©atoire au lieu d'un contexte capturĂ©. Cela doit toujours ĂȘtre fait si le code Ă©crit aprĂšs l'attente ne nĂ©cessite pas de contexte d'exĂ©cution spĂ©cifique. Il s'agit Ă©galement d'une recommandation de MS lorsqu'il s'agit d'Ă©crire du code fourni sous forme de bibliothĂšque.

Voyons comment nous pouvons attendre la fin d'une tùche. Ci-dessous, vous pouvez voir un exemple de code avec des commentaires indiquant quand l'attente est implémentée de maniÚre relativement bonne ou 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 soit exĂ©cutĂ©e sans bloquer le thread appelant, nous reviendrons donc au traitement du rĂ©sultat lorsqu'il sera prĂȘt. Avant cela, le thread appelant est laissĂ© seul.

Dans la deuxiĂšme tentative, nous bloquons le thread appelant jusqu'Ă  ce que le rĂ©sultat de la mĂ©thode soit calculĂ©. Il s'agit d'une mauvaise approche pour deux raisons. Tout d'abord, nous gaspillons un fil - une ressource trĂšs prĂ©cieuse - sur une simple attente. De plus, si la mĂ©thode que nous appelons contient une attente tandis qu'un retour au thread appelant aprĂšs l'attente est prĂ©vu par le contexte de synchronisation, nous obtiendrons un blocage. Cela se produit car le thread appelant attendra le rĂ©sultat d'une mĂ©thode asynchrone et la mĂ©thode asynchrone elle-mĂȘme tentera sans succĂšs de continuer son exĂ©cution dans le thread appelant.

Un autre inconvĂ©nient de cette approche est la complexitĂ© accrue de la gestion des erreurs. Les erreurs peuvent en fait ĂȘtre gĂ©rĂ©es assez facilement en code asynchrone si async / wait est utilisĂ© - le processus dans ce cas est identique Ă  celui en code synchrone. Cependant, lorsqu'une attente synchrone est appliquĂ©e Ă  une tĂąche, l'exception initiale est encapsulĂ©e dans AggregateException. En d'autres termes, pour gĂ©rer l'exception, nous aurions besoin d'explorer le type InnerException et d'Ă©crire manuellement une chaĂźne if dans un bloc catch ou, alternativement, d'utiliser la structure catch when au lieu de la chaĂźne plus habituelle de blocs catch.

Les deux derniers exemples sont Ă©galement Ă©tiquetĂ©s comme relativement mauvais pour les mĂȘmes raisons et contiennent tous les deux les mĂȘmes problĂšmes.

Les méthodes WhenAny et WhenAll sont trÚs utiles lorsqu'il s'agit d'attendre un groupe de tùches - elles regroupent ces tùches en une seule et elles seront exécutées soit lorsqu'une tùche du groupe est démarrée, soit lorsque toutes ces tùches sont exécutées avec succÚs.


ArrĂȘt des threads


Pour diverses raisons, il peut ĂȘtre nĂ©cessaire d'arrĂȘter un thread 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 . Je dĂ©conseille fortement d'utiliser le premier car, aprĂšs son appel, il y aurait une exception ThreadAbortedException Ă  tout moment alĂ©atoire lors du traitement de toute instruction choisie arbitrairement. Vous ne vous attendez pas Ă  ce qu'une telle exception se produise lorsqu'une variable entiĂšre est incrĂ©mentĂ©e, non? Eh bien, lorsque vous utilisez la mĂ©thode Abort, cela devient une rĂ©elle possibilitĂ©. Si vous devez refuser la capacitĂ© du CLR Ă  crĂ©er de telles exceptions dans une partie spĂ©cifique du code, vous pouvez l' encapsuler dans les appels Thread. BeginCriticalRegion et Thread.EndCriticalRegion . Tout code Ă©crit dans le bloc finally est encapsulĂ© dans ces appels. C'est pourquoi vous pouvez trouver des blocs avec un essai vide et un non-vide enfin dans les profondeurs du code framework. Microsoft n'aime pas cette mĂ©thode au point de ne pas l'inclure dans le noyau .NET.

La méthode d' interruption fonctionne d'une maniÚre beaucoup plus prévisible. Il peut interrompre un thread avec une ThreadInterruptedException uniquement lorsque le thread est en mode d'attente. Il passe à cet état lorsqu'il est suspendu en attendant WaitHandle, un verrou ou aprÚs l'appel de Thread.Sleep.

Ces deux mĂ©thodes prĂ©sentent un inconvĂ©nient d'imprĂ©visibilitĂ©. Pour Ă©chapper Ă  ce problĂšme, nous devons utiliser la structure CancellationToken et la classe CancellationTokenSource . L'idĂ©e gĂ©nĂ©rale est la suivante: une instance de la classe CancellationTokenSource est créée, et seuls ceux qui en sont propriĂ©taires peuvent arrĂȘter l'opĂ©ration en appelant la mĂ©thode Cancel . Seul CancellationToken est transmis Ă  l'opĂ©ration. Les propriĂ©taires de CancellationToken ne peuvent pas annuler l'opĂ©ration eux-mĂȘmes - ils peuvent uniquement vĂ©rifier si l'opĂ©ration a Ă©tĂ© annulĂ©e. Cela peut ĂȘtre rĂ©alisĂ© en utilisant une propriĂ©tĂ© boolĂ©enne IsCancellationRequested et la mĂ©thode ThrowIfCancelRequested . Le dernier gĂ©nĂ©rera une TaskCancelledException si la mĂ©thode Cancel a Ă©tĂ© appelĂ©e sur l'instance CancellationTokenSource qui a créé le CancellationToken. C'est la mĂ©thode que je recommande d'utiliser. Son avantage par rapport aux mĂ©thodes dĂ©crites prĂ©cĂ©demment rĂ©side dans le fait qu'il offre un contrĂŽle total sur les cas d'exception exacts dans lesquels une opĂ©ration peut ĂȘtre annulĂ©e.

La façon la plus brutale d'arrĂȘter un thread serait d'appeler une fonction API Win32 appelĂ©e TerminateThread. Une fois cette fonction appelĂ©e, le comportement du CLR peut ĂȘtre assez imprĂ©visible. Dans MSDN , ce qui suit est Ă©crit sur cette fonction: «TerminateThread est une fonction dangereuse qui ne doit ĂȘtre utilisĂ©e que dans les cas les plus extrĂȘmes. "


Transformer une API héritée en une API basée sur les tùches à l'aide de FromAsync


Si vous avez eu la chance de travailler sur un projet qui a Ă©tĂ© lancĂ© aprĂšs l'introduction des tĂąches (et lorsqu'elles n'incitent plus Ă  l'horreur existentielle chez la plupart des dĂ©veloppeurs), vous n'aurez pas Ă  vous occuper des anciennes API - Ă  la fois la tierce partie celles et ceux sur lesquels votre Ă©quipe a travaillĂ© dans le passĂ©. Heureusement, l'Ă©quipe de dĂ©veloppement de .NET Framework nous a facilitĂ© la tĂąche - mais cela aurait pu ĂȘtre une prise en charge personnelle, pour tout ce que nous savons. Dans tous les cas, .NET dispose de quelques outils qui permettent de rapprocher en toute transparence le code Ă©crit avec les anciennes approches Ă  une forme mise Ă  jour. L'un d'eux est la mĂ©thode TaskFactory appelĂ©e FromAsync. Dans l'exemple ci-dessous, j'encapsule les anciennes mĂ©thodes asynchrones de la classe WebRequest dans une tĂąche Ă  l'aide de FromAsync.

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

Ce n'est qu'un exemple, et vous ne ferez probablement pas quelque chose de ce genre avec des types intégrés. Cependant, les anciens projets regorgent de méthodes BeginDoSomething qui renvoient les méthodes IAsyncResult et EndDoSomething qui les reçoivent.


Transformer une API héritée en une API basée sur les tùches à l'aide de TaskCompletionSource


Un autre outil à explorer est la classe TaskCompletionSource . Dans sa fonctionnalité, son objectif et son principe de fonctionnement, il ressemble à la méthode RegisterWaitForSingleObject de la classe ThreadPool que j'ai mentionnée précédemment. Cette classe nous permet d'encapsuler facilement les anciennes API asynchrones dans des tùches.

Vous voudrez peut-ĂȘtre dire que j'ai dĂ©jĂ  parlĂ© de la mĂ©thode FromAsync de la classe TaskFactory qui a servi ces objectifs. Ici, nous devons nous souvenir de l'historique complet des modĂšles asynchrones fournis par Microsoft au cours des 15 derniĂšres annĂ©es: avant les modĂšles asynchrones basĂ©s sur les tĂąches (TAP), il existait des modĂšles de programmation asynchrones (APP). Les applications Ă©taient toutes destinĂ©es Ă  Begin DoSomething renvoyant IAsyncResult et Ă  la mĂ©thode End DoSomething qui l'accepte - et la mĂ©thode FromAsync est parfaite pour l'hĂ©ritage de ces annĂ©es. Cependant, au fil du temps, cela a Ă©tĂ© remplacĂ© par des modĂšles asynchrones basĂ©s sur les Ă©vĂ©nements (EAP) qui spĂ©cifiaient qu'un Ă©vĂ©nement est appelĂ© lorsqu'une opĂ©ration asynchrone est exĂ©cutĂ©e avec succĂšs.

TaskCompletionSource sont parfaits pour encapsuler des API hĂ©ritĂ©es construites autour du modĂšle d'Ă©vĂ©nement dans des tĂąches. Voici comment cela fonctionne: les objets de cette classe ont une propriĂ©tĂ© publique appelĂ©e Task, dont l'Ă©tat peut ĂȘtre contrĂŽlĂ© par diffĂ©rentes mĂ©thodes de la classe TaskCompletionSource (SetResult, SetException etc.). 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. Pour mieux le comprendre, regardons cet exemple de code. Ici, certaines anciennes API de l'Ăšre EAP sont enveloppĂ©es dans une tĂąche Ă  l'aide de TaskCompletionSource: lorsqu'un Ă©vĂ©nement est dĂ©clenchĂ©, la tĂąche passe Ă  l'Ă©tat TerminĂ© tandis que la mĂ©thode qui a appliquĂ© l'opĂ©rateur d'attente Ă  cette tĂąche continue son exĂ©cution. aprĂšs avoir reçu un 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


TaskCompletionSource peut faire plus que simplement encapsuler des API obsolÚtes. Cette classe ouvre une possibilité intéressante de concevoir diverses API basées sur des tùches qui n'occupent pas de threads. Un thread, comme nous nous en souvenons, est une ressource coûteuse limitée principalement par la RAM. Nous pouvons facilement atteindre cette limite lors du développement d'une application Web robuste avec une logique métier complexe. Examinons les capacités que j'ai mentionnées en action en implémentant une astuce connue sous le nom de Long Polling.

En bref, voici comment fonctionne l'interrogation longue:
Vous devez obtenir des informations d'une API sur les Ă©vĂ©nements qui se produisent de son cĂŽtĂ©, mais l'API, pour une raison quelconque, ne peut que renvoyer un Ă©tat plutĂŽt que de vous informer de l'Ă©vĂ©nement. Un exemple serait une API construite sur HTTP avant l'apparition de WebSocket ou dans des circonstances oĂč cette technologie ne peut pas ĂȘtre utilisĂ©e. Le client peut demander au serveur HTTP. Le serveur HTTP, en revanche, ne peut pas initier seul le contact avec le client. La solution la plus simple serait de demander au serveur pĂ©riodiquement Ă  l'aide d'un minuteur, mais cela crĂ©erait une charge supplĂ©mentaire pour le serveur et un dĂ©lai gĂ©nĂ©ral qui Ă©quivaut approximativement Ă  TimerInterval / 2. Pour contourner cela, le Long Polling a Ă©tĂ© inventĂ©. Cela implique de retarder la rĂ©ponse du serveur jusqu'Ă  l'expiration du dĂ©lai d'expiration ou jusqu'Ă  ce qu'un Ă©vĂ©nement se produise. Si un Ă©vĂ©nement se produit, il sera traitĂ©; sinon, la demande sera envoyĂ©e Ă  nouveau.

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

Cependant, l'efficacitĂ© de cette solution diminuera radicalement si le nombre de clients en attente de l'Ă©vĂ©nement augmente - chaque client en attente occupe un thread complet. De plus, nous obtenons un dĂ©lai supplĂ©mentaire de 1 ms pour le dĂ©clenchement d'Ă©vĂ©nements. Souvent, ce n'est pas vraiment crucial, mais pourquoi rendrions-nous notre logiciel pire qu'il ne pourrait l'ĂȘtre? D'un autre cĂŽtĂ©, si nous supprimons Thread.Sleep (1), l'un des cƓurs de processeur sera chargĂ© Ă  100% sans rien faire dans un cycle inutile. Avec l'aide de TaskCompletionSource, nous pouvons facilement transformer notre code pour rĂ©soudre tous les problĂšmes que nous avons mentionnĂ©s:

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

Veuillez garder Ă  l'esprit que ce morceau de code n'est qu'un exemple, et en aucun cas prĂȘt pour la production. Pour l'utiliser dans des cas rĂ©els, nous aurions au moins besoin d'ajouter un moyen de gĂ©rer les situations dans lesquelles un message est reçu alors que rien ne l'attendait: dans ce cas, la mĂ©thode AcceptMessageAsync devrait renvoyer une tĂąche dĂ©jĂ  terminĂ©e. Si ce cas est le plus courant, nous pouvons envisager d'utiliser ValueTask.

Lors de la réception d'une demande de message, nous créons un TaskCompletionSource, le plaçons dans un dictionnaire, puis attendons l'un des événements suivants: soit l'intervalle de temps spécifié est passé, soit un message est reçu.


ValueTask: pourquoi et comment


Les opĂ©rateurs asynchrones / attendent, tout comme l'opĂ©rateur return return, gĂ©nĂšrent une machine Ă  Ă©tats finis Ă  partir d'une mĂ©thode, ce qui signifie crĂ©er un nouvel objet - cela n'a pas vraiment d'importance la plupart du temps, mais peut toujours crĂ©er des problĂšmes dans de rares cas. Un de ces cas peut se produire avec des mĂ©thodes frĂ©quemment appelĂ©es - nous parlons de dizaines et de centaines de milliers d'appels par seconde. Si une telle mĂ©thode est Ă©crite d'une maniĂšre qui lui fait retourner le rĂ©sultat tout en contournant toutes les mĂ©thodes d'attente dans la plupart des cas, .NET fournit un outil d'optimisation pour cela - la structure ValueTask. Pour comprendre comment cela fonctionne, regardons un exemple. Supposons qu'il existe un cache auquel nous accĂ©dons rĂ©guliĂšrement. S'il y a des valeurs, nous les renvoyons simplement; s'il n'y a pas de valeurs - nous essayons de les obtenir Ă  partir d'un IO lent. Cette derniĂšre devrait idĂ©alement ĂȘtre effectuĂ©e de maniĂšre asynchrone, donc toute la mĂ©thode sera asynchrone. Ainsi, la façon la plus Ă©vidente d'implĂ©menter cette mĂ©thode sera la suivante:

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

Avec un désir de l'optimiser un peu et un souci de ce que Roslyn générera lors de la compilation de ce code, nous pourrions réécrire la méthode comme ceci:

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

Cependant, la meilleure solution dans ce cas serait d'optimiser le hot-path - en particulier, obtenir des valeurs de dictionnaire sans allocations inutiles et sans charge sur GC. Pendant ce temps, dans les cas peu frĂ©quents oĂč nous devons obtenir des donnĂ©es d'E / S, les choses resteront presque les mĂȘmes:

 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: si une valeur est présente dans le cache, nous créerons une structure; sinon, la vraie tùche sera enveloppée dans une ValueTask. Le chemin par lequel ce code est exécuté n'est pas important pour le code appelant: du point de vue de la syntaxe C #, une ValueTask se comportera comme une tùche habituelle.


TaskScheduler: ContrÎle des stratégies d'exécution des tùches


La prochaine API dont je voudrais parler est la classe TaskScheduler et celles qui en dérivent. J'ai déjà mentionné que TPL fournit une capacité de contrÎler exactement comment les tùches sont réparties entre les threads. Ces stratégies sont définies dans des classes héritées de TaskScheduler. Presque toutes les stratégies dont nous pourrions avoir besoin se trouvent dans la bibliothÚque ParallelExtensionsExtras . Cette bibliothÚque est développée par Microsoft, mais ne fait pas partie de .NET - elle est plutÎt distribuée sous forme de package Nuget. Voyons quelques-unes des stratégies:

  • CurrentThreadTaskScheduler - exĂ©cute des tĂąches sur le thread actuel
  • LimitedConcurrencyLevelTaskScheduler - limite le nombre de tĂąches exĂ©cutĂ©es simultanĂ©ment en utilisant le paramĂštre N qu'il accepte dans le constructeur
  • OrderedTaskScheduler - est dĂ©fini comme LimitedConcurrencyLevelTaskScheduler (1), donc les tĂąches seront exĂ©cutĂ©es sĂ©quentiellement.
  • WorkStealingTaskScheduler - implĂ©mente l'approche de vol de travail pour l'exĂ©cution des tĂąches. Essentiellement, il peut ĂȘtre considĂ©rĂ© comme un ThreadPool distinct. Cela aide Ă  rĂ©soudre le problĂšme de ThreadPool en tant que classe statique dans .NET - s'il est surchargĂ© ou utilisĂ© de maniĂšre incorrecte dans une partie de l'application, des effets secondaires dĂ©sagrĂ©ables peuvent se produire Ă  un endroit diffĂ©rent. Les causes rĂ©elles de ces dĂ©fauts peuvent ĂȘtre difficiles Ă  localiser, vous devrez donc peut-ĂȘtre utiliser des WorkStealingTaskSchedulers distincts dans les parties de l'application oĂč l'utilisation de ThreadPool peut ĂȘtre agressive et imprĂ©visible.
  • QueuedTaskScheduler - permet d'exĂ©cuter des tĂąches sur la base d'une file d'attente prioritaire
  • ThreadPerTaskScheduler - crĂ©e un thread sĂ©parĂ© pour chaque tĂąche qui y est exĂ©cutĂ©e. Cela peut ĂȘtre utile pour les tĂąches dont le temps d'exĂ©cution ne peut pas ĂȘtre estimĂ©.

Il y a un trÚs bon article sur TaskSchedulers sur le blog de Microsoft, alors n'hésitez pas à le consulter.

Dans Visual Studio, il existe une fenĂȘtre TĂąches qui peut vous aider Ă  dĂ©boguer tout ce qui concerne les tĂąches. Dans cette fenĂȘtre, vous pouvez voir l'Ă©tat de la tĂąche et accĂ©der Ă  la ligne de code actuellement exĂ©cutĂ©e.



PLinq et la classe parallĂšle


Mis Ă  part les tĂąches et toutes les choses qui leur sont liĂ©es, il existe deux outils supplĂ©mentaires dans .NET que nous pouvons trouver intĂ©ressants - PLinq (Linq2Parallel) et la classe Parallel . La premiĂšre promet l'exĂ©cution parallĂšle de toutes les opĂ©rations Linq sur tous les threads. Le nombre de threads peut ĂȘtre configurĂ© par une mĂ©thode d'extension WithDegreeOfParallelism. Malheureusement, dans la plupart des cas, PLinq en mode par dĂ©faut n'aura pas suffisamment d'informations sur la source de donnĂ©es pour fournir une augmentation significative de la vitesse. En revanche, le coĂ»t de l'essai est trĂšs faible: il suffit d'appeler AsParallel avant la chaĂźne de mĂ©thodes Linq et de rĂ©aliser des tests de performances. De plus, vous pouvez transmettre des informations supplĂ©mentaires sur la nature de votre source de donnĂ©es Ă  PLinq en utilisant le mĂ©canisme de partitions. Vous pouvez trouver plus d'informations ici et ici .

La classe statique Parallel fournit des mĂ©thodes pour Ă©numĂ©rer les collections en parallĂšle via Foreach, exĂ©cuter le cycle For et exĂ©cuter plusieurs dĂ©lĂ©guĂ©s en parallĂšle Ă  Invoke. L'exĂ©cution du thread actuel sera arrĂȘtĂ©e jusqu'Ă  ce que les rĂ©sultats soient calculĂ©s. Vous pouvez configurer le nombre de threads en passant ParallelOptions comme dernier argument. TaskScheduler et CancellationToken peuvent Ă©galement ĂȘtre dĂ©finis Ă  l'aide d'options.


Résumé


Quand j'ai commencé à écrire cet article sur la base de ma thÚse et des connaissances que j'ai acquises en travaillant aprÚs, je ne pensais pas qu'il y aurait autant d'informations. Maintenant, avec l'éditeur de texte qui me dit avec reproche que j'ai écrit prÚs de 15 pages, je voudrais tirer une conclusion intermédiaire. Nous examinerons d'autres techniques, API, outils visuels et dangers cachés dans le prochain article.

Conclusions:

  • Pour utiliser efficacement les ressources des PC modernes, vous auriez besoin de connaĂźtre les outils pour travailler avec les threads, l'asynchronie et le parallĂ©lisme.
  • Il existe de nombreux outils comme celui-ci dans .NET
  • Tous n'ont pas Ă©tĂ© créés en mĂȘme temps, vous pouvez donc souvent rencontrer du code hĂ©ritĂ© - mais il existe des moyens de transformer les anciennes API avec peu d'effort.
  • Dans .NET, les classes Thread et ThreadPool sont utilisĂ©es pour travailler avec les threads
  • La mĂ©thode Thread.Abort et Thread.Interrupt, ainsi que la fonction API Win32 TerminateThread, sont dangereuses et non recommandĂ©es. Au lieu de cela, il vaut mieux utiliser CancellationTokens
  • Les threads sont une ressource prĂ©cieuse et leur nombre est limitĂ©. Vous devez Ă©viter les cas dans lesquels les threads sont occupĂ©s en attendant les Ă©vĂ©nements. La classe TaskCompletionSource peut aider Ă  atteindre cet objectif.
  • Les tĂąches sont l'outil le plus puissant et le plus robuste dont dispose NET pour travailler avec le parallĂ©lisme et l'asynchronie.
  • Les opĂ©rateurs C # asynchrones / attendent implĂ©mentent le concept d'attente non bloquante
  • Vous pouvez contrĂŽler la rĂ©partition des tĂąches entre les threads Ă  l'aide de classes dĂ©rivĂ©es de TaskScheduler
  • La structure ValueTask peut ĂȘtre utilisĂ©e pour optimiser les raccourcis et le trafic mĂ©moire
  • Les fenĂȘtres TĂąches et Threads de Visual Studio fournissent de nombreuses informations utiles pour le dĂ©bogage de code multithread ou asynchrone
  • PLinq est un outil gĂ©nial, mais il peut ne pas avoir toutes les informations requises sur votre source de donnĂ©es - qui peuvent toujours ĂȘtre corrigĂ©es avec le mĂ©canisme de partitionnement

À suivre ...

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


All Articles