Je publie sur Habr l'original de l'article dont la traduction est publiée dans l'entreprise .
La nĂ©cessitĂ© de faire quelque chose de maniĂšre asynchrone, sans attendre le rĂ©sultat ici et maintenant, ou de diviser un gros travail entre plusieurs unitĂ©s qui l'exĂ©cutent, existait mĂȘme avant l'apparition des ordinateurs. Avec leur arrivĂ©e, cette nĂ©cessitĂ© est devenue trĂšs perceptible. Maintenant, en 2019, en tapant cet article sur un ordinateur portable Ă©quipĂ© d'un processeur Intel Core Ă 8 cĆurs, oĂč des centaines de processus, et mĂȘme plus de flux, fonctionnent simultanĂ©ment. Ă cĂŽtĂ©, se trouve un tĂ©lĂ©phone dĂ©jĂ un peu usĂ©, achetĂ© il y a quelques annĂ©es, qui a un processeur Ă 8 cĆurs. Les ressources thĂ©matiques regorgent d'articles et de vidĂ©os oĂč leurs auteurs s'Ă©merveillent des smartphones phares de cette annĂ©e Ă©quipĂ©s de processeurs Ă 16 cĆurs. MS Azure propose pour moins de 20 $/heure une machine virtuelle avec un processeur Ă 128 cĆurs et 2 To de RAM. Malheureusement, il est impossible de tirer le maximum et de dompter cette puissance sans savoir gĂ©rer l'interaction des flux.
Terminologie
Processus (Process) â objet du systĂšme d'exploitation, espace d'adresse isolĂ©, contient des flux.
Flux (Thread) â objet du systĂšme d'exploitation, la plus petite unitĂ© d'exĂ©cution, partie d'un processus, les flux partagent la mĂ©moire et d'autres ressources entre eux dans le cadre du processus.
MultitĂąche â propriĂ©tĂ© du systĂšme d'exploitation, capacitĂ© Ă exĂ©cuter plusieurs processus simultanĂ©ment
Multi-core â propriĂ©tĂ© d'un processeur, capacitĂ© Ă utiliser plusieurs cĆurs pour le traitement des donnĂ©es
Multiprocessing â propriĂ©tĂ© d'un ordinateur, capacitĂ© Ă travailler simultanĂ©ment avec plusieurs processeurs physiquement
Multithreading â propriĂ©tĂ© d'un processus, capacitĂ© Ă rĂ©partir le traitement des donnĂ©es entre plusieurs flux.
ParallĂ©lisme â exĂ©cution de plusieurs actions physiquement en mĂȘme temps dans une unitĂ© de temps
Asynchronie â exĂ©cution d'une opĂ©ration sans attendre la fin de ce traitement, le rĂ©sultat de l'exĂ©cution pouvant ĂȘtre traitĂ© ultĂ©rieurement.
Métaphore
Toutes les dĂ©finitions ne sont pas bonnes et certaines nĂ©cessitent des Ă©claircissements supplĂ©mentaires, donc Ă la terminologie formellement introduite, j'ajouterai une mĂ©taphore sur la prĂ©paration du petit dĂ©jeuner. La prĂ©paration du petit dĂ©jeuner dans cette mĂ©taphore â processus.
En prĂ©parant le petit dĂ©jeuner le matin, je (CPU) vais dans la cuisine (Ordinateur). J'ai 2 mains (CĆurs). Dans la cuisine, il y a une sĂ©rie d'appareils (IO): four, kettle, toaster, refrigerator. I turn on the gas, put the pan on it and pour in oil, not waiting for it to heat up (asynchronously, Non-Blocking-IO-Wait), I take eggs out of the refrigerator and crack them into a bowl, then I beat them with one hand (Thread#1), while the other (Thread#2) holds the bowl (Shared Resource). I would also turn on the kettle, but I donât have enough hands (Thread Starvation) Meanwhile, the pan heats up (Processing the result) where I pour what I've beaten. I reach for the kettle and turn it on, just staring at the water boiling in it (Blocking-IO-Wait), even though I could have washed the bowl I used for whipping the omelet during that time.
I was making an omelet using just my 2 hands, which is all I have, but while beating the omelet, there were 3 operations going on at the same time: beating the omelet, holding the bowl, heating the pan. The CPU is the fastest part of the computer, while IO is usually what slows things down, so often an effective solution is to keep the CPU busy while waiting for data from IO.
Continuing the metaphor:
- If during the omelet cooking process, I were also trying to change clothes, that would be an example of multitasking. An important nuance: computers are much better at this than humans.
- A kitchen with several chefs, for example in a restaurant â is like a multi-core computer.
- Many restaurants on a food court in a shopping mall â is like a data center.
Tools .NET
When working with threads, as with many other things, .NET is good. With each new version, it introduces more new tools for working with them, new layers of abstraction over OS threads. When building abstractions, the framework developers employ an approach that allows the use of high-level abstractions while also being able to dive down one or more levels lower. Often, there's no need for this, moreover, it opens up the possibility of shooting oneself in the foot, but sometimes, in rare cases, it might be the only way to solve a problem not solvable at the current level of abstraction.
By tools, I mean both the APIs provided by the framework and third-party packages, as well as entire software solutions that simplify the search for any problems related to multithreaded code.
Starting a thread
La classe Thread, la plus basique dans .NET pour travailler avec des threads. Le constructeur accepte l'un des deux délégués :
- ThreadStart â Sans paramĂštres
- ParametrizedThreadStart â avec un paramĂštre de type object.
Le dĂ©lĂ©guĂ© sera exĂ©cutĂ© dans un nouveau thread aprĂšs l'appel de la mĂ©thode Start. Si un dĂ©lĂ©guĂ© de type ParametrizedThreadStart a Ă©tĂ© passĂ© au constructeur, l'objet doit ĂȘtre passĂ© Ă la mĂ©thode Start. Ce mĂ©canisme est nĂ©cessaire pour transmettre toute information locale dans le thread. Il convient de noter que la crĂ©ation d'un thread est une opĂ©ration coĂ»teuse, et le thread lui-mĂȘme est un objet lourd, ne serait-ce que parce qu'il nĂ©cessite l'allocation de 1 Mo de mĂ©moire sur la pile et demande une interaction avec l'API du systĂšme d'exploitation.
new Thread(...).Start(...);
La classe ThreadPool reprĂ©sente le concept de pool. Dans .NET, le pool de threads est une Ćuvre d'ingĂ©nierie, et les dĂ©veloppeurs de Microsoft ont investi un grand nombre d'efforts pour qu'il fonctionne de maniĂšre optimale dans une variĂ©tĂ© de scĂ©narios.
Concept général :
Depuis le démarrage, l'application en arriÚre-plan crée plusieurs threads de réserve et permet de les utiliser. Si les threads sont souvent utilisés et en grande quantité, le pool s'étend pour répondre aux besoins du code appelant. Lorsque le pool n'a pas de threads disponibles à un moment donné, il attend le retour d'un des threads ou en crée un nouveau. Il s'ensuit que le pool de threads est trÚs adapté pour des actions courtes et peu adapté pour des opérations fonctionnant comme des services tout au long de l'exécution des applications.
Pour utiliser un thread du pool, il existe la mĂ©thode QueueUserWorkItem, qui accepte un dĂ©lĂ©guĂ© de type WaitCallback, qui correspond Ă la signature de ParametrizedThreadStart, et le paramĂštre qui lui est passĂ© remplit la mĂȘme fonction.
ThreadPool.QueueUserWorkItem(...);
Une méthode moins connue du pool de threads, RegisterWaitForSingleObject, sert à organiser des opérations IO non bloquantes. Le délégué passé à cette méthode sera appelé lorsque le WaitHandle passé à la méthode sera "libéré".
ThreadPool.RegisterWaitForSingleObject(...)
Dans .NET, il existe un minuteur de threads, qui se distingue des minuteurs WinForms/WPF par le fait que son gestionnaire est appelé dans un thread provenant du pool.
System.Threading.Timer
Il existe Ă©galement un moyen plutĂŽt exotique d'envoyer un dĂ©lĂ©guĂ© Ă exĂ©cuter dans un thread du pool â la mĂ©thode BeginInvoke.
DelegateInstance.BeginInvoke
Je voudrais briÚvement aborder la fonction vers laquelle se dirigent beaucoup des méthodes mentionnées 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 appel qu'une fois dans un exemple terrifiant de code legacy, et la motivation de l'auteur qui a fait cela reste pour moi un mystÚre.
Kernel32.dll CreateThread
Visualisation et débogage des threads
Les threads que vous avez créés vous-mĂȘme, ainsi que ceux de tous les composants tiers et du pool .NET, peuvent ĂȘtre visualisĂ©s dans la fenĂȘtre Threads de Visual Studio. Cette fenĂȘtre affichera des informations sur les threads uniquement lorsque l'application sera en mode dĂ©bogage et en mode d'arrĂȘt (Break mode). Vous pouvez ici facilement visualiser la pile, les noms et les prioritĂ©s de chaque thread, et basculer le dĂ©bogage vers un thread spĂ©cifique. La propriĂ©tĂ© Priority de la classe Thread permet de dĂ©finir la prioritĂ© du thread, qui sera perçue comme une recommandation par l'OC et le CLR lors de la rĂ©partition du temps du processeur entre les threads.

Task Parallel Library
La Task Parallel Library (TPL) est apparue dans .NET 4.0. C'est maintenant la norme et l'outil principal pour travailler avec l'asynchronisme. Tout code utilisant des approches plus anciennes est considĂ©rĂ© comme legacy. L'unitĂ© de base de la TPL est la classe Task du namespace System.Threading.Tasks. Task reprĂ©sente une abstraction au-dessus du thread. Avec la nouvelle version du langage C#, nous avons obtenu une maniĂšre Ă©lĂ©gante de travailler avec des Tasks : les opĂ©rateurs async/await. Ces concepts ont permis d'Ă©crire du code asynchrone comme s'il Ă©tait simple et synchrone, ce qui a permis mĂȘme Ă ceux qui comprennent peu le fonctionnement interne des threads d'Ă©crire des applications qui les utilisent, sans blocage lors de longues opĂ©rations. L'utilisation d'async/await est un sujet pour un ou plusieurs articles, mais j'essaierai de rĂ©sumer l'essence en quelques phrases :
- async est un modificateur de méthode qui renvoie un Task ou void
- et await est un opérateur d'attente non bloquante pour le Task.
Encore une fois : l'opĂ©rateur await, en rĂšgle gĂ©nĂ©rale (avec des exceptions), libĂ©rera le fil d'exĂ©cution actuel et, lorsque la tĂąche sera terminĂ©e, le contexte (ou plutĂŽt, il serait plus juste de dire le contexte, mais nous y reviendrons plus tard) sera libre pour continuer l'exĂ©cution de la mĂ©thode. Ă l'intĂ©rieur de .NET, ce mĂ©canisme est implĂ©mentĂ© de la mĂȘme maniĂšre que yield return, oĂč la mĂ©thode Ă©crite est transformĂ©e en une classe entiĂšre, qui est une machine Ă Ă©tats et peut ĂȘtre exĂ©cutĂ©e par morceaux en fonction de ces Ă©tats. Ceux que cela intĂ©resse peuvent Ă©crire un code simple en utilisant async/await, le compiler et examiner l'assemblage avec JetBrains dotPeek avec le Code gĂ©nĂ©rĂ© par le compilateur activĂ©.
Examinons les options de dĂ©marrage et d'utilisation de la tĂąche. Ă l'aide du 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 un certain travail complexe impliquant le CPU.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Le code de l'action sera exécuté dans un autre contexte
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// Le code aprÚs await sera exécuté dans le contexte capturé
}
La tùche est créée avec plusieurs options :
- LongRunning â c'est une indication que la tĂąche ne sera pas exĂ©cutĂ©e rapidement, et il peut donc ĂȘtre judicieux de ne pas prendre un fil du pool, mais d'en crĂ©er un sĂ©parĂ©ment pour cette tĂąche afin de ne pas nuire aux autres.
- AttachedToParent â les tĂąches peuvent ĂȘtre organisĂ©es en hiĂ©rarchies. Si cette option a Ă©tĂ© utilisĂ©e, la tĂąche peut ĂȘtre dans un Ă©tat oĂč elle s'est terminĂ©e et attend l'achĂšvement des tĂąches enfants.
- PreferFairness â cela signifie qu'il serait bon d'exĂ©cuter les tĂąches envoyĂ©es plus tĂŽt 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 passĂ© Ă la mĂ©thode est un CancellationToken. Pour traiter correctement l'annulation de l'opĂ©ration aprĂšs son lancement, le code exĂ©cutĂ© doit ĂȘtre rempli de vĂ©rifications de l'Ă©tat du CancellationToken. S'il n'y a pas de 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 son lancement.
Le dernier paramÚtre est un objet scheduler de type TaskScheduler. Cette classe et ses dérivés sont conçus pour gérer les stratégies de répartition des tùches sur des threads, par défaut, la tùche sera exécutée sur un thread aléatoire du pool.
Le Task créé a l'opĂ©rateur await appliquĂ©, ce qui signifie que le code Ă©crit aprĂšs lui, s'il y en a un, sera exĂ©cutĂ© dans le mĂȘme contexte (ce qui signifie souvent sur le mĂȘme thread) que le code avant await.
La méthode est marquée comme async void, ce qui signifie qu'il est permis d'utiliser l'opérateur await dans celle-ci, mais le code appelant ne pourra pas attendre l'exécution. Si cette possibilité est nécessaire, alors la méthode doit renvoyer un Task. Les méthodes marquées async void sont assez fréquentes : généralement, ce sont des gestionnaires d'événements ou d'autres méthodes qui fonctionnent selon le principe exécuter et oublier (fire and forget). Si vous devez non seulement permettre l'attente de la fin de l'exécution, mais aussi retourner un résultat, vous devez utiliser Task.
Sur le Task retournĂ© par la mĂ©thode StartNew, comme sur n'importe quel autre, vous pouvez appeler la mĂ©thode ConfigureAwait avec le paramĂštre false, alors l'exĂ©cution aprĂšs await continuera pas sur le contexte capturĂ©, mais sur un arbitraire. Cela doit toujours ĂȘtre fait lorsque le contexte d'exĂ©cution pour le code aprĂšs await n'est pas crucial. C'est Ă©galement une recommandation de MS lors de l'Ă©criture de code qui sera fourni sous forme de bibliothĂšque.
ArrĂȘtons-nous un peu sur la façon d'attendre la fin de l'exĂ©cution d'un Task. Voici un exemple de code, avec des commentaires, montrant quand l'attente est faite de maniĂšre relativement correcte et quand elle est moins bonne.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // bon
result = AsyncMethod().Result; // mauvais
AsyncMethod().Wait(); // mauvais
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // bon
await Task.WhenAny(tasks); // bon
Task.WaitAll(tasks.ToArray()); // mauvais
}
Dans le premier exemple, nous attendons l'exécution du Task sans bloquer le thread appelant, nous reviendrons au traitement du résultat seulement lorsqu'il sera là , jusqu'à ce point, le thread appelant est libre.
Dans la deuxiÚme option, nous bloquons le thread appelant jusqu'à ce que le résultat de la méthode soit calculé. Cela est problématique non seulement parce que nous avons occupé un thread, une ressource si précieuse pour le programme, avec une tùche inutile, mais aussi parce que si le code de la méthode que nous appelons contient un await, et que le contexte de synchronisation suppose un retour au thread appelant aprÚs l'await, nous obtiendrons un deadlock : le thread appelant attend que le résultat de la méthode asynchrone soit calculé, tandis que la méthode asynchrone essaie en vain de reprendre son exécution dans le thread appelant.
Un autre inconvĂ©nient de cette approche est la complexitĂ© accrue du traitement des erreurs. En effet, il est trĂšs facile de gĂ©rer les erreurs dans le code asynchrone utilisant async/await â elles se comportent comme si le code Ă©tait synchrone. Pendant ce temps, si nous appliquons l'attente synchrone sur un Task, l'exception d'origine est enveloppĂ©e dans une AggregateException. Ainsi, pour gĂ©rer une exception, il faudra examiner le type d'InnerException et crĂ©er soi-mĂȘme une chaĂźne de if Ă l'intĂ©rieur d'un bloc catch ou utiliser la construction catch when, au lieu de la chaĂźne de blocs catch plus habituelle dans le monde C#.
Le troisiĂšme et dernier exemple est Ă©galement marquĂ© par des dĂ©fauts pour la mĂȘme raison et contient tous les mĂȘmes problĂšmes.
Les mĂ©thodes WhenAny et WhenAll sont extrĂȘmement utiles pour attendre un groupe de Task, elles enveloppent un groupe de Task dans un seul qui se dĂ©clenche soit dĂšs que l'un des Task du groupe s'exĂ©cute, soit lorsque tous ont terminĂ© leur exĂ©cution.
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 mĂ©thodes pour cela. La classe Thread propose deux mĂ©thodes aux noms appropriĂ©s â ce sont Abort et Interrupt. La premiĂšre est fortement dĂ©conseillĂ©e, car aprĂšs son appel, Ă tout moment alĂ©atoire, dans le traitement de n'importe quelle instruction, une exception sera levĂ©e ThreadAbortedException. Vous ne vous attendez pas Ă ce qu'une telle exception se produise lors de l'incrĂ©mentation d'une variable entiĂšre, n'est-ce pas ? Mais avec ce mĂ©thode, c'est une situation tout Ă fait rĂ©aliste. Si vous devez empĂȘcher le CLR de gĂ©nĂ©rer une telle exception dans une section de code, vous pouvez l'encapsuler dans des appels Ă Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Tous les codes exĂ©cutĂ©s dans le bloc finally sont enveloppĂ©s par de tels appels. Pour cette raison, il est courant de trouver des blocs avec un try vide, mais pas un finally vide dans le code des frameworks. Microsoft dĂ©conseille tellement cette mĂ©thode qu'elle ne l'a mĂȘme pas inclus dans .net core.
La méthode Interrupt fonctionne de maniÚre plus prévisible. Elle peut interrompre un thread avec une exception. ThreadInterruptedException uniquement lorsque le thread est en état d'attente. Ce statut est atteint lorsqu'il est bloqué en attente d'un WaitHandle, lors d'un lock, ou aprÚs avoir appelé Thread.Sleep.
Les deux options dĂ©crites ci-dessus sont problĂ©matiques en raison de leur imprĂ©visibilitĂ©. La solution consiste Ă utiliser la structure CancellationToken et la classe CancellationTokenSource. L'idĂ©e est la suivante : un exemplaire de la classe CancellationTokenSource est créé, et seul son propriĂ©taire peut arrĂȘter l'opĂ©ration en appelant la mĂ©thode Cancel. Dans l'opĂ©ration, seul le CancellationToken est passĂ©. Les propriĂ©taires du CancellationToken ne peuvent pas annuler eux-mĂȘmes l'opĂ©ration, mais peuvent seulement vĂ©rifier si elle a Ă©tĂ© annulĂ©e. Pour cela, il y a la propriĂ©tĂ© boolĂ©enne IsCancellationRequested et une mĂ©thode ThrowIfCancelRequested. Ce dernier gĂ©nĂšre une exception TaskCancelledException si la mĂ©thode Cancel a Ă©tĂ© appelĂ©e sur l'exemplaire associĂ© au CancellationTokenSource. Et c'est prĂ©cisĂ©ment cette mĂ©thode que je recommande d'utiliser. Elle offre un meilleur contrĂŽle global sur les moments oĂč l'opĂ©ration peut ĂȘtre interrompue par une exception.
La mĂ©thode la plus drastique pour arrĂȘter un thread est d'appeler la fonction API Win32 TerminateThread. Le comportement du CLR aprĂšs l'appel de cette fonction peut ĂȘtre imprĂ©visible. Sur MSDN, il est dit Ă son sujet : âTerminateThread is a dangerous function that should only be used in the most extreme cases.â
Conversion de l'API legacy en méthode basée sur les tùches via la méthode FromAsync
Si vous avez eu la chance de travailler sur un projet qui a Ă©tĂ© lancĂ© aprĂšs l'introduction des tĂąches et qui n'a plus suscitĂ© la terreur silencieuse de la plupart des dĂ©veloppeurs, vous n'aurez pas Ă gĂ©rer un grand nombre d'anciennes API, qu'elles soient tierces ou créées de maniĂšre laborieuse par votre Ă©quipe par le passĂ©. Heureusement, l'Ă©quipe de dĂ©veloppement de .NET Framework a pensĂ© Ă nous, bien que l'objectif ait peut-ĂȘtre Ă©tĂ© de penser Ă elle-mĂȘme. Quoi qu'il en soit, .NET dispose d'une sĂ©rie d'outils pour convertir le code Ă©crit selon d'anciens paradigmes de programmation asynchrone en un nouveau. L'un d'eux est la mĂ©thode FromAsync de TaskFactory. Dans l'exemple de code ci-dessous, j'encapsule d'anciennes mĂ©thodes asynchrones de la classe WebRequest dans une tĂąche Ă l'aide de cette mĂ©thode.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Ceci n'est qu'un exemple et vous n'aurez probablement pas à faire cela avec les types intégrés, mais tout ancien projet regorge de méthodes BeginDoSomething retournant un IAsyncResult et de méthodes EndDoSomething les acceptant.
Conversion des API héritées en tùche basée à l'aide de la classe TaskCompletionSource
Un autre outil important à considérer est la classe TaskCompletionSource. En termes de fonctions, d'objectifs et de fonctionnement, elle peut faire penser à la méthode RegisterWaitForSingleObject de la classe ThreadPool dont j'ai parlé précédemment. Avec cette classe, il est facile et pratique d'encapsuler d'anciennes API asynchrones dans des tùches.
Vous direz que j'ai déjà parlé de la méthode FromAsync de la classe TaskFactory destinée à ces fins. Ici, il est nécessaire de se rappeler toute l'histoire du développement des modÚles asynchrones dans .NET que Microsoft a proposés au cours des 15 derniÚres années : avant le Task-Based Asynchronous Pattern (TAP), il y avait l'Asynchronous Programming Pattern (APP), qui traitait des méthodes BeginDoSomething retournant IAsyncResult et des méthodes EndDoSomething l'acceptant, et pour les héritages de ces années-là , la méthode FromAsync convient parfaitement, mais avec le temps, elle a été remplacée par l'Event Based Asynchronous Pattern (EAP), qui supposait qu'à la fin de l'opération asynchrone, un événement serait déclenché.
TaskCompletionSource est parfaitement adaptĂ© pour encapsuler les tĂąches des API hĂ©ritĂ©es construites autour d'un modĂšle basĂ© sur des Ă©vĂ©nements. Son fonctionnement repose sur le fait que cet objet possĂšde une propriĂ©tĂ© publique de type Task dont l'Ă©tat peut ĂȘtre contrĂŽlĂ© via les mĂ©thodes SetResult, SetException, etc. Pour le classe TaskCompletionSource. Dans les endroits oĂč l'opĂ©rateur await a Ă©tĂ© appliquĂ© Ă cette tĂąche, elle sera exĂ©cutĂ©e ou Ă©chouera avec une exception en fonction de la mĂ©thode utilisĂ©e sur TaskCompletionSource. Si ce n'est pas encore clair, examinons cet exemple de code, oĂč une ancienne API de l'Ă©poque de l'EAP est enveloppĂ©e dans une tĂąche Ă l'aide de TaskCompletionSource : lorsque l'Ă©vĂ©nement se dĂ©clenche, la tĂąche sera mise dans l'Ă©tat Completed, et la mĂ©thode qui a appliquĂ© l'opĂ©rateur await Ă cette tĂąche reprendra son exĂ©cution en recevant l'objet. result.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
Astuces et conseils sur TaskCompletionSource
L'encapsulation des anciennes API n'est pas la seule chose que l'on peut rĂ©aliser avec TaskCompletionSource. L'utilisation de cette classe ouvre la possibilitĂ© de concevoir divers API sur des tĂąches, qui ne consomment pas de threads. Or, comme nous le savons, le thread est une ressource coĂ»teuse et leur nombre est limitĂ© (principalement par la taille de la RAM). Cette limitation est facilement atteignable lors de la conception, par exemple, d'une application web fortement chargĂ©e avec une logique mĂ©tier complexe. Voyons les possibilitĂ©s dont je parle Ă travers la mise en Ćuvre d'un tel tour que l'on appelle Long-Polling.
En rĂ©sumĂ©, le truc est le suivant : vous devez obtenir des informations d'un API sur certains Ă©vĂ©nements qui se produisent de son cĂŽtĂ©, mais pour une raison quelconque, l'API ne peut pas signaler l'Ă©vĂ©nement, elle peut seulement retourner un Ă©tat. Un exemple de cela serait tous les API construits au-dessus d'HTTP avant l'Ăšre des WebSockets ou lorsque, pour une raison quelconque, cette technologie ne peut pas ĂȘtre utilisĂ©e. Le client peut interroger le serveur HTTP. Le serveur HTTP ne peut pas provoquer lui-mĂȘme une communication avec le client. Une solution simple est de scruter le serveur avec un minuteur, mais cela entraĂźne une charge supplĂ©mentaire sur le serveur et un dĂ©lai supplĂ©mentaire en moyenne Ă©gal Ă TimerInterval / 2. Pour contourner cela, un truc appelĂ© Long Polling a Ă©tĂ© inventĂ©, qui suppose un dĂ©lai de rĂ©ponse du serveur jusqu'Ă ce qu'un Timeout se produise ou qu'un Ă©vĂ©nement se produise. Si l'Ă©vĂ©nement se produit, il est traitĂ© ; sinon, la demande est renvoyĂ©e.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Cependant, cette solution se rĂ©vĂ©lera horrible dĂšs que le nombre de clients attendant un Ă©vĂ©nement augmentera, car chaque client en attente d'un Ă©vĂ©nement occupe un thread entier. De plus, cela ajoute un dĂ©lai supplĂ©mentaire de 1 ms lors du dĂ©clenchement de l'Ă©vĂ©nement, ce qui est souvent nĂ©gligeable, mais pourquoi rendre un logiciel moins performant qu'il ne pourrait l'ĂȘtre ? En retirant Thread.Sleep(1), on va Ă l'encontre d'un cĆur de processeur Ă 100% en tournant en boucle inutilement. Avec TaskCompletionSource, il est facile de réécrire ce code et de rĂ©soudre tous les problĂšmes susmentionnĂ©s :
class LongPollingApi {
private Dictionary<int, TaskCompletionSource> tasks;
public async Task AcceptMessageAsync(int userId, int duration) {
var cs = new TaskCompletionSource();
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, c'est juste une dĂ©monstration. Pour une utilisation dans des cas rĂ©els, il faut Ă©galement traiter la situation oĂč un message arrive au moment oĂč personne ne l'attend : dans ce cas, la mĂ©thode AcceptMessageAsync doit retourner un Task dĂ©jĂ complĂ©tĂ©. Si ce cas s'avĂšre ĂȘtre le plus frĂ©quent, il peut ĂȘtre intĂ©ressant d'envisager l'utilisation de ValueTask.
Lorsqu'une demande de message est reçue, nous créons et plaçons dans un dictionnaire TaskCompletionSource, puis attendons ce qui se produira en premier : l'expiration de l'intervalle de temps défini ou la réception d'un message.
ValueTask : pourquoi et comment
Les opérateurs async/await ainsi que l'opérateur yield return génÚrent une machine à états à partir de la méthode, ce qui signifie la création d'un nouvel objet, ce qui est presque toujours négligeable, mais dans de rares cas, peut poser problÚme. C'est le cas d'une méthode appelée trÚs fréquemment, avec des dizaines et des centaines de milliers d'appels par seconde. Si une telle méthode est écrite de maniÚre à renvoyer des résultats dans la plupart des cas sans passer par tous les await, .NET fournit un outil pour optimiser cela : la structure ValueTask. Pour mieux comprendre, examinons un exemple d'utilisation : il s'agit d'un cache auquel nous accédons trÚs souvent. Si des valeurs y sont présentes, nous les renvoyons simplement, sinon nous allons chercher dans un I/O lent. Nous souhaitons effectuer cette derniÚre opération de maniÚre asynchrone, ce qui rend toute la méthode asynchrone. Par conséquent, une option évidente pour écrire la méthode est la suivante :
public async Task GetById(int id) {
if (cache.TryGetValue(id, out string val))
return val;
return await RequestById(id);
}
Dans le but d'optimiser un peu et par une légÚre inquiétude sur ce que Roslyn pourrait générer en compilant ce code, nous pouvons réécrire cet exemple comme suit :
public Task GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
La solution la plus optimale dans ce cas consiste Ă optimiser le chemin critique, Ă savoir obtenir une valeur du dictionnaire sans allocations superflues ni charge sur le GC, tandis que dans les rares cas oĂč nous devons encore accĂ©der Ă l'I/O pour obtenir des donnĂ©es, tout se passera presque comme avant :
public ValueTask GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask(val);
return new ValueTask(RequestById(id));
}
Examinons plus en dĂ©tail ce fragment de code : en cas de valeur dans le cache, nous crĂ©ons une structure, sinon la tĂąche rĂ©elle sera encapsulĂ©e dans une valeur. Le code appelant n'a pas Ă se soucier de la maniĂšre dont ce code est exĂ©cutĂ© : du point de vue de la syntaxe C#, ValueTask se comportera de la mĂȘme maniĂšre qu'un Task normal dans ce cas.
TaskSchedulers : gestion des stratégies de lancement des Tasks
La prochaine API que nous aimerions examiner est la classe TaskScheduler et ses dĂ©rivĂ©s. J'ai dĂ©jĂ mentionnĂ© ci-dessus qu'il est possible dans TPL de gĂ©rer les stratĂ©gies de distribution des tĂąches entre les threads. Ces stratĂ©gies sont dĂ©finies dans les sous-classes de la classe TaskScheduler. Pratiquement toute stratĂ©gie nĂ©cessaire peut ĂȘtre trouvĂ©e dans la bibliothĂšque. ParallelExtensionsExtras, dĂ©veloppĂ©e par Microsoft, mais qui ne fait pas partie de .NET, et qui est fournie sous forme de package Nuget. Examinons briĂšvement certaines d'entre elles :
- CurrentThreadTaskScheduler â exĂ©cute des tĂąches sur le thread en cours
- LimitedConcurrencyLevelTaskScheduler â limite le nombre de tĂąches exĂ©cutĂ©es simultanĂ©ment par le paramĂštre N, qui est acceptĂ© dans le constructeur
- OrderedTaskScheduler â est dĂ©fini comme LimitedConcurrencyLevelTaskScheduler(1), donc les tĂąches seront exĂ©cutĂ©es sĂ©quentiellement.
- WorkStealingTaskScheduler â implĂ©mente pour la distribution des tĂąches. Il s'agit essentiellement d'un pool de threads sĂ©parĂ©. Il rĂ©sout le problĂšme du fait que le ThreadPool dans .NET est une classe statique, unique pour toutes les applications, donc sa surcharge ou son utilisation incorrecte dans une partie du programme peut avoir des effets secondaires dans une autre partie. De plus, comprendre la cause de ces dĂ©fauts est extrĂȘmement compliquĂ©. Ainsi, il peut y avoir un besoin d'utiliser des WorkStealingTaskSchedulers sĂ©parĂ©s dans les parties du programme oĂč l'utilisation du ThreadPool peut ĂȘtre agressive et imprĂ©visible.
- QueuedTaskScheduler â permet d'exĂ©cuter des tĂąches selon des rĂšgles de file d'attente avec prioritĂ©s
- ThreadPerTaskScheduler â crĂ©e un thread distinct pour chaque tĂąche qui y est exĂ©cutĂ©e. Cela peut ĂȘtre utile pour des tĂąches dont la durĂ©e d'exĂ©cution est imprĂ©visible.
Il existe un bon article détaillé sur les TaskSchedulers dans le blog de Microsoft.
Pour faciliter le dĂ©bogage de tout ce qui concerne les tĂąches dans Visual Studio, il existe une fenĂȘtre Tasks. Dans cette fenĂȘtre, vous pouvez voir l'Ă©tat actuel de la tĂąche et passer Ă la ligne de code en cours d'exĂ©cution.

PLinq et la classe Parallel
En plus des Tasks et de tout ce qui a Ă©tĂ© dit Ă leur sujet dans .NET, il existe encore deux outils intĂ©ressants : PLinq (Linq2Parallel) et la classe Parallel. Le premier promet une exĂ©cution parallĂšle de toutes les opĂ©rations Linq sur plusieurs threads. Le nombre de threads peut ĂȘtre configurĂ© Ă l'aide de la mĂ©thode d'extension WithDegreeOfParallelism. Malheureusement, le plus souvent, PLinq en mode par dĂ©faut ne dispose pas des informations nĂ©cessaires sur les dĂ©tails internes de votre source de donnĂ©es pour offrir un gain de vitesse significatif. Cependant, le coĂ»t de cette tentative est trĂšs faible : il suffit d'appeler la mĂ©thode AsParallel avant la chaĂźne de mĂ©thodes Linq et d'effectuer des tests de performance. De plus, il est possible de transmettre Ă PLinq des informations supplĂ©mentaires sur la nature de votre source de donnĂ©es Ă l'aide du mĂ©canisme Partitions. Vous pouvez lire plus de dĂ©tails. et .
La classe statique Parallel fournit des mĂ©thodes pour parcourir une collection de maniĂšre parallĂšle avec Foreach, exĂ©cuter une boucle For et exĂ©cuter plusieurs dĂ©lĂ©guĂ©s en parallĂšle via Invoke. L'exĂ©cution du thread actuel sera suspendue jusqu'Ă la fin des calculs. Le nombre de threads peut ĂȘtre configurĂ© en passant ParallelOptions comme dernier argument. GrĂące aux options, vous pouvez Ă©galement spĂ©cifier TaskScheduler et CancellationToken.
Conclusions
Lorsque j'ai commencé à écrire cet article basé sur les matériaux de ma présentation et les informations que j'ai collectées depuis, je ne m'attendais pas à ce qu'il devienne si long. Maintenant, alors que l'éditeur de texte dans lequel je rédige cet article me dit réprimandé que j'en suis à la 15Úme page, je vais tirer quelques conclusions intermédiaires. D'autres astuces, API, outils visuels et écueils seront abordés dans le prochain article.
Conclusions :
- Il est essentiel de connaßtre les outils pour travailler avec des threads, l'asynchronicité et le parallélisme afin d'utiliser efficacement les ressources des PC modernes.
- .NET propose de nombreux outils variés pour ces objectifs.
- Tous ne sont pas apparus en mĂȘme temps, donc il est souvent possible de rencontrer des Ă©lĂ©ments legacy. Toutefois, des mĂ©thodes existent pour convertir de vieilles API sans trop d'efforts.
- Le travail avec des threads dans .NET est représenté par les classes Thread et ThreadPool.
- Les méthodes Thread.Abort, Thread.Interrupt et la fonction Win32 API TerminateThread sont dangereuses et ne sont pas recommandées. Il vaut mieux utiliser le mécanisme des CancellationTokens.
- Le flux est une ressource prĂ©cieuse, leur nombre est limitĂ©. Il est nĂ©cessaire d'Ă©viter les situations oĂč les flux sont occupĂ©s Ă attendre des Ă©vĂ©nements. Pour cela, il est pratique d'utiliser la classe TaskCompletionSource.
- L'outil le plus puissant et avancé de .NET pour gérer le parallélisme et l'asynchronicité est les Tasks.
- Les opérateurs C# async/await implémentent le concept d'attente non bloquante.
- La gestion de la répartition des Tasks entre les threads peut se faire à l'aide de classes dérivées de TaskScheduler.
- La structure ValueTask peut ĂȘtre utile pour optimiser les chemins chauds et le trafic mĂ©moire.
- Les fenĂȘtres Tasks et Threads de Visual Studio fournissent de nombreuses informations utiles pour le dĂ©bogage du code multithread ou asynchrone.
- PLinq est un outil gĂ©nial, mais il peut manquer d'informations sur votre source de donnĂ©es ; cependant, cela peut ĂȘtre corrigĂ© grĂące au mĂ©canisme de partitionnement.
- Ă suivre...
Source : habr.com
