Ik publiceer op Habr de originele versie van het artikel, waarvan de vertaling is geplaatst in het bedrijfsinformatiesysteem. .
De noodzaak om iets asynchroon te doen, zonder de resultaten hier en nu af te wachten, of om een grote taak te splitsen tussen verschillende uitvoerende eenheden, bestond al voor de komst van computers. Met de komst van computers werd deze noodzaak echter veel voelbaarder. Nu, in 2019, als ik dit artikel typ op een laptop met een 8-core Intel Core processor, dat tegelijkertijd meer dan honderd processen en nog meer threads runt. Daarnaast ligt er een ietwat versleten smartphone die ik twee jaar geleden heb gekocht, met een 8-core processor aan boord. Er zijn talrijke artikelen en video's op thematische sites waar auteurs enthousiast zijn over de vlaggenschip-smartphones van dit jaar, die 16-core processors bevatten. MS Azure biedt voor minder dan $20/uur een virtuele machine met een 128-core processor en 2 TB RAM. Helaas is het onmogelijk om het maximale eruit te halen en deze kracht te beheersen zonder het beheer van de interactie tussen threads te begrijpen.
Terminologie
Proces (Process) ā een OS-object, een geĆÆsoleerde geheugenspace, bevat threads.
Thread (Thread) ā een OS-object, de kleinste uitvoeringseenheid, een onderdeel van een proces, threads delen geheugen en andere bronnen onderling binnen het proces.
Multitasking ā de mogelijkheid van een OS om meerdere processen gelijktijdig uit te voeren.
Multi-core ā een eigenschap van de processor, de mogelijkheid om meerdere cores te gebruiken voor dataverwerking.
Multiprocessing ā een eigenschap van de computer, de mogelijkheid om met meerdere processors fysiek tegelijkertijd te werken.
Multithreading ā een eigenschap van een proces, de mogelijkheid om de dataverwerking tussen meerdere threads te verdelen.
Parallelisme ā de uitvoering van meerdere acties fysiek gelijktijdig in een tijdseenheid.
Asynchroniciteit ā de uitvoering van een operatie zonder te wachten op de voltooiing van die verwerking; het resultaat kan later worden verwerkt.
Metafoor
Niet alle definities zijn goed en sommige hebben extra uitleg nodig, daarom voeg ik een metafoor toe over het bereiden van het ontbijt aan de formeel ingevoerde terminologie. Het bereiden van het ontbijt in deze metafoor is process.
Wanneer ik in de ochtend het ontbijt bereid, ga ik (CPU) de keuken in (Computer). Ik heb 2 handen (Cores). In de keuken zijn er verschillende apparaten (IO): oven, water kettle, toaster, refrigerator. Ik zet het gas aan, zet de pan erop en giet er olie in, zonder te wachten tot deze opwarmt (asynchroon, Non-Blocking-IO-Wait), haal ik eieren uit de koelkast en breek ze in een kom, waarna ik ze met ƩƩn hand klop (Thread#1), terwijl ik met de andere hand (Thread#2) de kom vasthoud (Shared Resource). Nu zou ik ook de waterkoker aan moeten zetten, maar ik heb niet genoeg handen (Thread Starvation) In deze tijd warmt de pan op (Verwerking van het resultaat) waar ik het geklopte mengsel in giet. Ik reik naar de waterkoker en zet deze aan en kijk simpelweg hoe het water kookt (Blocking-IO-Wait), terwijl ik in deze tijd de kom had kunnen afwassen waarin ik de omelet geklopt heb.
Ik maakte een omelet met slechts 2 handen, dat is alles wat ik heb, maar tijdens het kloppen van de omelet gebeurden er tegelijkertijd 3 taken: het kloppen van de omelet, het vasthouden van de kom en het verwarmen van de pan. De CPU is het snelste onderdeel van de computer, IO is wat het vaakst vertraagt, dus een effectieve oplossing is vaak om de CPU bezig te houden terwijl de gegevens van de IO worden opgehaald.
Terwijl ik de metafoor voortzet:
- Als ik tijdens het maken van de omelet ook nog zou proberen om me om te kleden, zou dat een voorbeeld van multitasking zijn. Een belangrijk detail: computers zijn hierin veel beter dan mensen.
- Een keuken met meerdere koks, bijvoorbeeld in een restaurant ā een multi-core computer.
- Verschillende restaurants in een foodcourt in een winkelcentrum ā datacenter
Tools .NET
Bij het werken met threads, net als bij veel andere dingen, is .NET goed. Met elke nieuwe versie biedt het steeds meer nieuwe tools voor het werken ermee, nieuwe abstractieniveaus bovenop de OS-threads. Bij het bouwen van abstracties gebruiken de ontwikkelaars van het framework een aanpak die de mogelijkheid geeft, bij het gebruik van een high-level abstractie, om een of meer niveaus lager te gaan. Vaak is dat niet nodig, sterker nog, het opent de mogelijkheid om jezelf een schot in de voet te geven met een shotgun, maar soms, in zeldzame gevallen, kan het de enige manier zijn om een probleem op te lossen dat niet op het huidige abstractieniveau kan worden aangepakt.
Met tools bedoel ik zowel software-interfaces (API's) die door het framework en externe pakketten worden aangeboden, als ook volledige software-oplossingen die het zoeken naar problemen met meer-draadcode vergemakkelijken.
Starten van een thread
De Thread-klasse, de meest basale in .NET voor het werken met threads. De constructor accepteert een van de twee delegaten:
- ThreadStart ā Zonder parameters
- ParametrizedThreadStart ā met ƩƩn parameter van het type object.
De delegate wordt uitgevoerd in de nieuw aangemaakte thread na het aanroepen van de Start-methode; als aan de constructor een delegaat van het type ParametrizedThreadStart is doorgegeven, moet in de Start-methode een object worden doorgegeven. Dit mechanisme is bedoeld voor het doorgeven van lokale informatie naar de thread. Het is belangrijk op te merken dat het aanmaken van een thread een kostbare operatie is, en de thread zelf is een zwaar object, vooral omdat er 1 MB geheugen voor de stack wordt toegewezen en het interactie vereist met de OS-API.
new Thread(...).Start(...);
De ThreadPool-klasse vertegenwoordigt het concept van een pool. In .NET is de threadpool een meesterwerk van engineering en hebben de ontwikkelaars van Microsoft veel moeite gedaan om deze optimaal te laten functioneren in verschillende scenario's.
Algemene concept:
Vanaf het moment dat de applicatie start, worden er op de achtergrond meerdere reserve-threads aangemaakt en kan men deze gebruiken. Als threads vaak en in grote aantallen worden gebruikt, wordt de pool uitgebreid om te voldoen aan de behoeften van de aanroepende code. Wanneer er op dat moment geen vrije threads in de pool beschikbaar zijn, wacht deze tot een van de threads terugkeert of creƫert een nieuwe. Hieruit volgt dat de threadpool uitstekend geschikt is voor kortdurende acties en minder geschikt is voor operaties die functioneren als services gedurende de hele looptijd van de applicaties.
Om een thread uit de pool te gebruiken, is er de methode QueueUserWorkItem, die een delegaat van het type WaitCallback accepteert, dat qua handtekening overeenkomt met ParametrizedThreadStart; de parameter die aan deze methode wordt doorgegeven, vervult dezelfde functie.
ThreadPool.QueueUserWorkItem(...);
Een minder bekende methode van de threadpool, RegisterWaitForSingleObject, dient voor het organiseren van niet-blokkerende IO-operaties. De delegate die aan deze methode is doorgegeven, zal worden aangeroepen wanneer de WaitHandle die aan de methode is doorgegeven, wordt 'vrijgegeven' (Released).
ThreadPool.RegisterWaitForSingleObject(...)
In .NET is er een thread-timer die verschilt van de timers in WinForms/WPF, omdat de handler ervan wordt aangeroepen in een thread die uit de pool is gehaald.
System.Threading.Timer
Er is ook een nogal exotische manier om een delegaat voor uitvoering naar een thread uit de pool te sturen ā de BeginInvoke-methode.
DelegateInstance.BeginInvoke
Ik wil ook kort stilstaan bij de functie die aan de basis ligt van veel van de bovengenoemde methoden ā CreateThread uit Kernel32.dll Win32 API. Er is een manier om deze functie aan te roepen dankzij het extern methode mechanisme. Ik heb zo'n aanroep slechts ƩƩn keer gezien in een vreselijk voorbeeld van legacy code, en de motivatie van de auteur die het op deze manier heeft gedaan blijft voor mij een raadsel.
Kernel32.dll CreateThread
Weergave en debugging van threads
De door uzelf gemaakte, evenals door derden en het .NET-pool, threads zijn te bekijken in het Threads-venster van Visual Studio. Dit venster toont informatie over threads alleen wanneer de applicatie in de debugging-modus en in Break-modus is. Hier kunt u gemakkelijk de stack, de namen en de prioriteiten van elke thread bekijken en de debugging naar een specifieke thread schakelen. Met de eigenschap Priority van de Thread-klasse kunt u de prioriteit van een thread instellen, die door de OS en CLR wordt waargenomen als een aanbeveling bij de verdeling van processor tijd tussen threads.

Task Parallel Library
Task Parallel Library (TPL) werd geĆÆntroduceerd in .NET 4.0. Het is nu de standaard en het belangrijkste hulpmiddel voor het werken met asynchroniteit. Elke code die gebruik maakt van oudere benaderingen wordt als legacy beschouwd. De belangrijkste eenheid van TPL is de Task-klasse uit de namespace System.Threading.Tasks. Een Task is een abstractie van een thread. Met de nieuwe versie van de programmeertaal C# kregen we een elegante manier om met Tasks te werken ā de async/await-operatoren. Deze concepten hebben het mogelijk gemaakt om asynchrone code te schrijven alsof deze eenvoudig en synchroon was, wat zelfs mensen met een beperkte kennis van de interne werking van threads in staat stelde om applicaties te schrijven die deze gebruiken, zonder dat ze vastlopen tijdens lange operaties. Het gebruik van async/await is een onderwerp voor een of zelfs meerdere artikelen, maar ik zal proberen de essentie in een paar zinnen samen te vatten:
- async is een modificator voor een methode die Task of void retourneert
- en await is de operator voor niet-blokkerende afwachting van een Task.
Nogmaals: de await-operator laat in de meeste gevallen (er zijn uitzonderingen) de huidige uitvoeringsthread verder gaan, en wanneer de Task zijn uitvoering heeft voltooid, zal de thread (het is eigenlijk preciezer om context te zeggen, maar dat later) vrij zijn en de methode verder uitvoeren. Binnen .NET is dit mechanisme geĆÆmplementeerd zoals yield return, waarbij de geschreven methode wordt omgevormd tot een complete klasse, die fungeert als een toestandsmachine en in afzonderlijke stukken kan worden uitgevoerd afhankelijk van deze toestanden. Iedereen die geĆÆnteresseerd is, kan enige eenvoudige code schrijven met behulp van async/await, deze compileren en de assembly bekijken met JetBrains dotPeek met ingeschakelde Compiler Generated Code.
Laten we de opties voor het starten en gebruiken van een Task bekijken. In de onderstaande code maken we een nieuwe taak aan die niets nuttigs doet (Thread.Sleep(10000)), maar in de echte wereld moet dit een soort complexe CPU-belastende taak zijn.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Actiecode wordt uitgevoerd in een andere context
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// Code na await wordt uitgevoerd in de vastgelegde context
}
Een Task wordt aangemaakt met een aantal opties:
- LongRunning ā een hint dat de taak niet snel zal worden uitgevoerd, wat betekent dat het misschien beter is om geen thread uit de pool te nemen, maar een aparte thread voor deze Task te creĆ«ren om andere taken niet te schaden.
- AttachedToParent ā Tasks kunnen in hiĆ«rarchieĆ«n worden georganiseerd. Als deze optie is gebruikt, kan de Task zich in een toestand bevinden waarin hij is uitgevoerd en wacht op de uitvoering van zijn dochtertaken.
- PreferFairness ā houdt in dat het goed zou zijn om eerder ingediende Tasks uit te voeren voordat diegene die later zijn ingediend. Maar dit is slechts een aanbeveling en het resultaat is niet gegarandeerd.
De tweede parameter in de methode is de CancellationToken. Voor een correcte verwerking van de annulering van een operatie na de start moet de uit te voeren code gevuld zijn met controles van de status van de CancellationToken. Als er geen controles zijn, kan de Cancel-methode die op het object CancellationTokenSource is aangeroepen, de uitvoering van de Task alleen vóór de start stoppen.
De laatste parameter is een object van het type TaskScheduler. Deze klasse en zijn afgeleiden zijn bedoeld voor het beheren van strategieƫn voor de distributie van taken over threads; standaard wordt de taak uitgevoerd op een willekeurige thread uit de pool.
De aangemaakte taak is toegepast met de await-operator, wat betekent dat de code die na de await komt, als deze er is, zal worden uitgevoerd in dezelfde context (vaak betekent dit op dezelfde thread) als de code ervoor.
De methode is gemarkeerd als async void, wat betekent dat het gebruik van de await-operator is toegestaan, maar de aanroepende code kan niet wachten op de uitvoering. Als deze mogelijkheid nodig is, moet de methode een Task retourneren. Methoden gemarkeerd met async void komen behoorlijk vaak voor: meestal zijn dit event handlers of andere methoden die werken op basis van het principe voer uit en vergeet (fire and forget). Als het noodzakelijk is om niet alleen de mogelijkheid te bieden om op de voltooiing te wachten, maar ook om een resultaat terug te geven, moet Task worden gebruikt.
Op de Task die door de methode StartNew is geretourneerd, evenals op elke andere, kan de methode ConfigureAwait met de parameter false worden aangeroepen; dan gaat de uitvoering na await niet verder in de gevangen context, maar op willekeurige wijze. Dit moet altijd worden gedaan wanneer de context van uitvoering voor de code na await niet essentieel is. Dit is ook een aanbeveling van MS bij het schrijven van code die in een bibliotheekvorm wordt geleverd.
Laten we even stilstaan bij hoe we kunnen wachten op de voltooiing van een taak. Hieronder een codevoorbeeld met opmerkingen over wanneer het wachten voorwaardelijk goed is gedaan en wanneer het voorwaardelijk slecht is.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // goed
result = AsyncMethod().Result; // slecht
AsyncMethod().Wait(); // slecht
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // goed
await Task.WhenAny(tasks); // goed
Task.WaitAll(tasks.ToArray()); // slecht
}
In het eerste voorbeeld wachten we op de uitvoering van de taak zonder de aanroepende thread te blokkeren; we komen pas terug naar de verwerking van het resultaat wanneer deze beschikbaar is, tot die tijd is de aanroepende thread op zichzelf aangewezen.
In de tweede optie blokkeren we de aanroepende thread totdat het resultaat van de methode is berekend. Dit is slecht, niet alleen omdat we een thread hebben bezet, een zo waardevolle hulpbron van het programma, met nutteloos wachten, maar ook omdat als er in de code van de methode die we aanroepen een await is, en de synchronisatiecontext terugkeer naar de aanroepende thread na de await veronderstelt, we een deadlock krijgen: de aanroepende thread wacht totdat het resultaat van de asynchrone methode is berekend, terwijl de asynchrone methode tevergeefs probeert verder te gaan in de aanroepende thread.
Een ander nadeel van deze aanpak is de gecompliceerde foutafhandeling. Het probleem is dat fouten in asynchrone code bij het gebruik van async/await heel gemakkelijk te verwerken zijn ā ze gedragen zich alsof de code synchroon is. Terwijl als we synchrone blokkering toepassen op een taak, het originele uitzondering wordt verpakt in AggregateException, wat betekent dat we het type InnerException moeten onderzoeken en zelf een keten van if-verklaringen binnen ƩƩn catch-blok moeten schrijven of de constructie catch when moeten gebruiken, in plaats van de meer gebruikelijke keten van catch-blokken in de C#-wereld.
De derde en laatste voorbeelden zijn net zo slecht om dezelfde reden en bevatten al diezelfde problemen.
De methoden WhenAny en WhenAll zijn uiterst handig voor het wachten op een groep taken; ze wikkelen een groep taken in ƩƩn die wordt geactiveerd ofwel bij de eerste activatie van ƩƩn van de taken uit de groep, of wanneer alle taken zijn voltooid.
Threads stoppen
Om verschillende redenen kan het nodig zijn om een thread te stoppen na de start. Hiervoor bestaan verschillende methoden. De klasse Thread heeft twee methoden met passende namen ā dit zijn Abort en Interrupt. De eerste wordt sterk afgeraden, omdat er na het aanroepen ervan op elk willekeurig moment, tijdens de verwerking van elke instructie, een uitzondering zal worden opgegooid. ThreadAbortedException. U verwacht toch niet dat zo'n uitzondering wordt opgegooid bij het inkrementeren van een geheel getal, toch? Maar met deze methode is dat een heel reĆ«le situatie. Als het nodig is om te voorkomen dat de CLR zo'n uitzondering genereert in een bepaald codefragment, kan dit worden gewikkeld in aanroepen van Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Dergelijke aanroepen wikkelen elke code geschreven in de finally-blok. Om deze reden vind je in de diepten van de frameworkcode blokken met een lege try, maar niet met een lege finally. Microsoft raadt het gebruik van deze methode dusdanig af dat ze het niet hebben opgenomen in .net core.
De Interrupt-methode werkt voorspelbaarder. Hij kan de thread onderbreken met een uitzondering. ThreadInterruptedException alleen op die momenten dat de thread in een wachttoestand verkeert. Deze toestand bereikt hij door te wachten op WaitHandle, lock, of na het aanroepen van Thread.Sleep.
Beide hierboven beschreven opties zijn slecht vanwege hun onvoorspelbaarheid. De oplossing is het gebruik van de structuur CancellationToken en de klasse CancellationTokenSource. Het idee is het volgende: een instantie van de klasse CancellationTokenSource wordt gemaakt en alleen degene die deze bezit, kan de operatie stoppen door de methode Cancelaan te roepen. In de operatie zelf wordt alleen de CancellationToken doorgegeven. Eigenaren van CancellationToken kunnen de operatie zelf niet annuleren, maar kunnen alleen controleren of de operatie is geannuleerd. Hiervoor is er de boolean eigenschap IsCancellationRequested en de methode ThrowIfCancelRequested. De laatste genereert een uitzondering TaskCancelledException als de Cancel-methode is aangeroepen op de gekoppelde CancellationToken- instantie van CancellationTokenSource. En dit is precies de methode die ik aanbeveel te gebruiken. Dit biedt een betere controle over de momenten waarop de operatie door een uitzondering kan worden onderbroken.
De meest drastische optie om een thread te stoppen, is het aanroepen van de Win32 API-functie TerminateThread. Het gedrag van CLR na het aanroepen van deze functie kan onvoorspelbaar zijn. MSDN beschrijft deze functie als volgt: āTerminateThread is een gevaarlijke functie die alleen in de meest extreme gevallen zou moeten worden gebruikt.ā
De transformatie van legacy-API naar op taken gebaseerde met behulp van de FromAsync-methode.
Als je het geluk hebt om aan een project te werken dat is gestart nadat de taken zijn geĆÆntroduceerd en de stille angst van de meeste ontwikkelaars heeft doen verdwijnen, dan hoef je niet veel met oudere API's om te gaan, zowel van derden als die door je team in het verleden zijn ontwikkeld. Gelukkig heeft het ontwikkelteam van het .NET Framework voor ons gezorgd, hoewel het misschien was om zich zelf te beschermen. Hoe dan ook, in .NET zijn er verschillende tools voor het soepel omzetten van code die is geschreven in oude benaderingen van asynchroon programmeren naar een nieuwe. Een daarvan is de methode FromAsync van TaskFactory. In het onderstaande voorbeeld wikkel ik de oude asynchrone methoden van de WebRequest-klasse in een Task met deze methode.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Dit is slechts een voorbeeld en je zult dit waarschijnlijk niet met ingebouwde types hoeven te doen, maar elk oud project staat vol met methoden BeginDoSomething die IAsyncResult retourneren en methoden EndDoSomething die deze accepteren.
Het omzetten van legacy-API's naar Task Based met behulp van de klasse TaskCompletionSource
Een andere belangrijke tool om te overwegen is de klasse TaskCompletionSource. Qua functionaliteit, bedoeling en werkingsprincipe lijkt het een beetje op de methode RegisterWaitForSingleObject van de klasse ThreadPool waar ik hierboven over heb geschreven. Met deze klasse kun je eenvoudig en handig oude asynchrone API's in Tasks wikkelen.
Je zou kunnen zeggen dat ik al heb gesproken over de methode FromAsync van de klasse TaskFactory die voor deze doeleinden is bedoeld. Hier moeten we de hele geschiedenis van de ontwikkeling van asynchrone modellen in .NET in herinnering roepen die Microsoft de afgelopen 15 jaar heeft aangeboden: vóór het Task-Based Asynchronous Pattern (TAP) bestond er het Asynchronous Programming Pattern (APP), dat over de methoden ging. BeginDoSomething die IAsyncResult retourneert en de methoden EndDoSomething die deze accepteert en voor legacy uit die tijd is de methode FromAsync daar perfect geschikt voor, maar na verloop van tijd is het Event Based Asynchronous Pattern (EAP), dat voorschrijft dat bij de voltooiing van een asynchrone operatie een gebeurtenis wordt aangeroepen.
TaskCompletionSource is ideaal voor het ompakken van legacy-API's die zijn gebouwd rond een evenementmodel in Task's. De essentie van zijn werking is als volgt: het object van deze klasse heeft een openbaar eigenschap van het type Task, waarvan de toestand kan worden beheerd via de methoden SetResult, SetException, enz. van de TaskCompletionSource-klasse. Op de plekken waar de await-operator op deze Task is toegepast, zal hij worden uitgevoerd of een uitzondering geven, afhankelijk van de toegepaste methode op de TaskCompletionSource. Als het nog steeds niet duidelijk is, laten we dan kijken naar dit voorbeeld van code, waar een oud API van de EAP-tijd wordt omgezet in een Task met behulp van TaskCompletionSource: wanneer het evenement wordt geactiveerd, wordt de Task in de status Completed gezet en hervat de methode die de await-operator op deze Task heeft toegepast zijn uitvoering, ontvangend een object. resultaat.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
TaskCompletionSource Tips & Tricks
Het ompakken van oude API's is niet het enige dat je kunt doen met TaskCompletionSource. Het gebruik van deze klasse opent interessante mogelijkheden voor het ontwerpen van verschillende API's, gebaseerd op Task's die geen threads gebruiken. En zoals we weten zijn threads dure middelen en het aantal is beperkt (hoofdelijk door de hoeveelheid RAM). Dit beperking is gemakkelijk te bereiken bij het ontwikkelen van bijvoorbeeld een zwaar web-applicatie met complexe bedrijfslogica. Laten we de mogelijkheden bespreken waar ik het over heb, aan de hand van zo'n truc als Long-Polling.
In het kort komt de kern van de truc hierop neer: je moet informatie van een API krijgen over bepaalde gebeurtenissen die zich daar voordoen, terwijl de API om bepaalde redenen niet kan melden dat er een gebeurtenis heeft plaatsgevonden, maar alleen de status kan terugsturen. Een voorbeeld hiervan zijn alle API's die zijn gebouwd bovenop HTTP vóór de tijd van WebSocket of bij het onvermogen om om welke reden dan ook deze technologie te gebruiken. De client kan de HTTP-server vragen. De HTTP-server kan niet zelf de communicatie met de client initiëren. Een eenvoudige oplossing is om de server periodiek te pollend, maar dit creëert extra belasting op de server en extra vertraging van gemiddeld TimerInterval / 2. Om dit te omzeilen is de truc Long Polling uitgevonden, die onderstelt dat het antwoord van de server wordt vertraagd totdat de Timeout verstrijkt of er een gebeurtenis plaatsvindt. Als de gebeurtenis heeft plaatsgevonden, wordt deze behandeld; zo niet, dan wordt het verzoek opnieuw verzonden.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Maar een dergelijke oplossing zal vreselijk blijken te zijn zodra het aantal clients dat op de gebeurtenis wacht toeneemt, aangezien elke client die op een gebeurtenis wacht een volledige thread in beslag neemt. Bovendien krijgen we een extra vertraging van 1 ms bij het optreden van de gebeurtenis; meestal is dit niet significant, maar waarom software slechter maken dan het kan zijn? Als we Thread.Sleep(1) weghalen, dan belasten we onterecht een CPU-kern voor 100% in nutteloze ongebruikte cycli. Met TaskCompletionSource kunnen we deze code gemakkelijk opnieuw ontwerpen en alle eerder genoemde problemen oplossen:
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);
}
}
Deze code is niet production-ready, maar slechts een demonstratie. Voor gebruik in echte situaties moet ook minimaal de situatie worden behandeld wanneer een bericht aankomt op een moment dat niemand het verwacht: in dat geval moet de methode AcceptMessageAsync een al voltooide Task teruggeven. Als dit geval het meest voorkomt, kan men ook nadenken over het gebruik van ValueTask.
Bij het ontvangen van een verzoek om een bericht creƫren we een TaskCompletionSource en voegen deze toe aan een woordenboek, waarna we afwachten wat eerder gebeurt: of de opgegeven tijdsinterval verstrijkt of er een bericht ontvangen wordt.
ValueTask: waarom en hoe
De async/await-operators genereren, net als de yield return-operator, een statusautomaat uit de methode, wat resulteert in de creatie van een nieuw object, wat vrijwel altijd onbelangrijk is, maar in zeldzame gevallen tot problemen kan leiden. Deze gevallen kunnen zich voordoen als de methode echt vaak aangeroepen wordt, zoals tientallen of honderden duizenden keren per seconde. Als zo'n methode zo geschreven is dat deze in de meeste gevallen een resultaat teruggeeft zonder alle await-methoden te doorlopen, biedt .NET een hulpmiddel om dit te optimaliseren ā de ValueTask-structuur. Om dit duidelijker te maken, bekijken we een voorbeeld van het gebruik hiervan: er is een cache die we zeer vaak raadplegen. Sommige waarden zijn aanwezig en dan geven we ze gewoon terug, als ze er niet zijn, gaan we voor hen naar een langzame IO. Het laatste willen we asynchroon doen, wat betekent dat de hele methode asynchroon wordt. Duidelijk gezien is de voor de hand liggende manier om de methode te schrijven als volgt:
public async Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return val;
return await RequestById(id);
}
Vanwege de wens om een beetje te optimaliseren en de lichte vrees over wat Roslyn genereert bij het compileren van deze code, kan dit voorbeeld als volgt herschreven worden:
public Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
De echt optimale oplossing in dit geval zou de hot-path optimalisatie zijn, namelijk het verkrijgen van waarden uit de woordenboek zonder extra toewijzingen en belasting van de GC, terwijl in de zeldzame gevallen dat we echt naar IO voor data moeten gaan, alles min of meer hetzelfde blijft:
public ValueTask<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask<string>(val);
return new ValueTask<string>(RequestById(id));
}
Laten we deze codefragment nader bekijken: bij aanwezigheid van een waarde in de cache creƫren we een structuur, in het andere geval zal de echte taak gewikkeld worden in een waarde. Voor de aanroepende code maakt het niet uit welke route deze code heeft genomen: ValueTask zal zich qua syntaxis in C# hetzelfde gedragen als een normale Task in dit geval.
TaskSchedulers: het beheren van startstrategieƫn van Tasks
De volgende API die we willen bekijken is de klasse TaskScheduler en afgeleiden. Ik heb eerder vermeld dat TPL de mogelijkheid biedt om strategieƫn voor de verdeling van taken over threads te beheren. Deze strategieƫn worden gedefinieerd in afgeleiden klassen van TaskScheduler. Bijna elke strategie die nodig kan zijn, is te vinden in de bibliotheek ParallelExtensionsExtras, ontwikkeld door Microsoft, maar geen deel uitmakend van .NET, en geleverd als een NuGet-pakket. Laten we kort enkele daarvan bekijken:
- CurrentThreadTaskScheduler ā voert taken uit op de huidige thread
- LimitedConcurrencyLevelTaskScheduler ā beperkt het aantal gelijktijdig uit te voeren taken door parameter N die in de constructor wordt doorgegeven
- OrderedTaskScheduler ā wordt gedefinieerd als LimitedConcurrencyLevelTaskScheduler(1), waardoor de taken sequentieel worden uitgevoerd.
- WorkStealingTaskScheduler ā implementeert benadering voor taakverdeling. Het is in feite een aparte ThreadPool. Dit lost het probleem op dat de .NET ThreadPool een statische klasse is, die voor alle toepassingen geldt, wat betekent dat overbelasting of verkeerd gebruik ervan in ƩƩn deel van de applicatie kan leiden tot bijwerkingen in een ander deel. Bovendien kan het erg moeilijk zijn om de oorzaak van dergelijke defecten te begrijpen. Daarom kan er behoefte zijn aan aparte WorkStealingTaskScheduler's in die delen van het programma waar het gebruik van de ThreadPool agressief en onvoorspelbaar kan zijn.
- QueuedTaskScheduler ā stelt in staat om taken uit te voeren volgens de regels van queue met prioriteiten
- ThreadPerTaskScheduler ā creĆ«ert een aparte thread voor elke taak die erop wordt uitgevoerd. Dit kan nuttig zijn voor taken die onvoorspelbaar lang duren.
Er is een goede gedetailleerde over TaskScheduler's op de Microsoft-blog.
Voor een handige debugging van alles wat met taken te maken heeft in Visual Studio is er een venster Taken. In dit venster kan de huidige status van de taak worden bekeken en kan worden overgeschakeld naar de momentane regel code die wordt uitgevoerd.

PLinq en de klasse Parallel
Naast de Tasks en alles wat daarmee samenhangt in .NET zijn er nog twee interessante hulpmiddelen: PLinq (Linq2Parallel) en de Parallel-klasse. De eerste belooft parallelle uitvoering van alle Linq-bewerkingen op meerdere threads. Het aantal threads kan worden geconfigureerd met de extensiemethode WithDegreeOfParallelism. Helaas heeft PLinq in de standaard modus vaak niet genoeg informatie over de interne structuur van uw gegevensbron om een aanzienlijke snelheidstoename te garanderen, maar de prijs van een poging is zeer laag: u hoeft alleen maar de metode AsParallel aan te roepen voordat u een keten van Linq-methoden uitvoert en prestatietests uit te voeren. Bovendien is het mogelijk om PLinq extra informatie over de aard van uw gegevensbron door te geven met het Partitions-mechanisme. Meer gedetailleerde informatie kan worden gelezen. en .
De statische Parallel-klasse biedt methoden voor parallelle iteratie over collecties met Foreach, de uitvoering van een For-lus en de parallelle uitvoering van meerdere delegaten met Invoke. De uitvoering van de huidige thread zal worden onderbroken tot de berekeningen zijn voltooid. Het aantal threads kan worden geconfigureerd door ParallelOptions als laatste argument door te geven. Met opties kan ook de TaskScheduler en CancellationToken worden opgegeven.
Conclusies
Toen ik deze artikel begon te schrijven op basis van de materialen van mijn presentatie en de informatie die ik tijdens mijn werk heb verzameld, had ik niet verwacht dat het zo veel zou worden. Nu, terwijl de tekstverwerker waarin ik dit artikel schrijf me verwijt dat ik al op pagina 15 ben, zal ik de tussenresultaten samenbrengen. Andere trucs, API's, visuele hulpmiddelen en valkuilen zullen in het volgende artikel worden behandeld.
Conclusies:
- Je moet de tools voor het werken met threads, asynchronousiteit en parallelisme kennen om de middelen van moderne pc's te kunnen gebruiken.
- .NET heeft veel verschillende tools voor deze doeleinden.
- Niet allemaal zijn ze tegelijk verschenen, dus vaak komt men legacy tegen, maar er zijn manieren om oude API's zonder veel moeite om te zetten.
- Werken met threads in .NET wordt vertegenwoordigd door de klassen Thread en ThreadPool.
- De methoden Thread.Abort, Thread.Interrupt en de Win32 API-functie TerminateThread zijn gevaarlijk en worden niet aanbevolen. In plaats daarvan is het beter om gebruik te maken van het CancellationToken-mechanisme.
- Een stroom is een waardevolle hulpbron, en het aantal is beperkt. Situaties vermijden waarin stromen wachten op gebeurtenissen is essentieel. Het gebruik van de klasse TaskCompletionSource is hiervoor handig.
- De krachtigste en meest geavanceerde tool in .NET voor het omgaan met parallelisme en asynchroniciteit zijn deTasks.
- De C# async/await operators implementeren het concept van niet-blokkerende wacht.
- Het beheren van de verdeling van Tasks over threads kan met behulp van afgeleide klassen van TaskScheduler.
- De structuur ValueTask kan nuttig zijn voor het optimaliseren van hot-paths en geheugenverkeer.
- De vensters Tasks en Threads in Visual Studio bieden veel nuttige informatie voor het debuggen van multithreaded of asynchrone code.
- PLinq is een geweldig hulpmiddel, maar het kan mogelijk niet genoeg informatie hebben over uw gegevensbron; dit kan echter worden opgelost met behulp van partitionering.
- Wordt vervolgdā¦
Bron: habr.com
