.NET: Tools voor het werken met multithreading en asynchronie. Deel 1

Ik publiceer het originele artikel op Habr, waarvan de vertaling in het bedrijf is geplaatst blog.

De behoefte om iets asynchroon te doen, zonder hier en nu op het resultaat te wachten, of om groot werk te verdelen over verschillende eenheden die het uitvoeren, bestond al vóór de komst van computers. Met hun komst werd deze behoefte heel tastbaar. Nu, in 2019, typ ik dit artikel op een laptop met een 8-core Intel Core-processor, waarop meer dan honderd processen parallel draaien, en nog meer threads. In de buurt is er een ietwat armoedige telefoon, een paar jaar geleden gekocht, deze heeft een 8-coreprocessor aan boord. Thematische bronnen staan ​​vol met artikelen en video's waarin de auteurs de vlaggenschip-smartphones van dit jaar met 16-coreprocessors bewonderen. MS Azure biedt een virtuele machine met een 20-coreprocessor en 128 TB RAM voor minder dan $ 2/uur. Helaas is het onmogelijk om het maximale eruit te halen en deze kracht te benutten zonder de interactie van draden te kunnen beheersen.

terminologie

Proces - OS-object, geïsoleerde adresruimte, bevat threads.
Draad - een OS-object, de kleinste uitvoeringseenheid, onderdeel van een proces, threads delen geheugen en andere bronnen onderling binnen een proces.
multitasking - OS-eigenschap, de mogelijkheid om meerdere processen tegelijkertijd uit te voeren
Multi-core - een eigenschap van de processor, de mogelijkheid om meerdere kernen te gebruiken voor gegevensverwerking
Multiverwerking - een eigenschap van een computer, de mogelijkheid om fysiek tegelijkertijd met meerdere processors te werken
Multithreading — een eigenschap van een proces, de mogelijkheid om de gegevensverwerking over verschillende threads te verdelen.
Parallellisme - per tijdseenheid meerdere handelingen fysiek gelijktijdig uitvoeren
asynchronie — uitvoering van een bewerking zonder te wachten op de voltooiing van deze verwerking; het resultaat van de uitvoering kan later worden verwerkt.

Metafoor

Niet alle definities zijn goed en sommige hebben aanvullende uitleg nodig, dus ik zal een metafoor over het koken van ontbijt toevoegen aan de formeel geïntroduceerde terminologie. Ontbijt koken is in deze metafoor een proces.

Terwijl ik 's ochtends het ontbijt klaarmaakte (CPU) Ik kom naar de keuken (Computer). Ik heb 2 handen (Cores). Er zijn een aantal apparaten in de keuken (IO): oven, waterkoker, broodrooster, koelkast. Ik zet het gas aan, zet er een koekenpan op en giet er olie in zonder te wachten tot hij warm is (asynchroon, Non-Blocking-IO-Wait), haal ik de eieren uit de koelkast, breek ze in een bord en klop ze dan met één hand (Draad#1), en ten tweede (Draad#2) die het bord vasthoudt (gedeelde bron). Nu wil ik de waterkoker aanzetten, maar ik heb niet genoeg handen (Draad honger) Gedurende deze tijd warmt de koekenpan op (Bewerking van het resultaat) waarin ik giet wat ik heb opgeklopt. Ik reik naar de waterkoker, zet hem aan en kijk dom hoe het water erin kookt (Blokkeren-IO-Wacht), hoewel hij gedurende deze tijd het bord had kunnen afwassen waar hij de omelet opklopte.

Ik kookte een omelet met slechts 2 handen, en meer heb ik niet, maar tegelijkertijd vonden er op het moment dat ik de omelet klopte 3 handelingen tegelijk plaats: de omelet opkloppen, het bord vasthouden, de braadpan verwarmen De CPU is het snelste onderdeel van de computer, IO is wat meestal alles vertraagt, dus vaak is een effectieve oplossing om de CPU ergens mee bezig te houden terwijl je gegevens van IO ontvangt.

Als we de metafoor voortzetten:

  • Als ik tijdens het bereiden van een omelet ook zou proberen om van kleding te wisselen, zou dit een voorbeeld van multitasking zijn. Een belangrijke nuance: computers zijn hier veel beter in dan mensen.
  • Een keuken met meerdere chef-koks, bijvoorbeeld in een restaurant - een multicorecomputer.
  • Veel restaurants in een food court in een winkelcentrum - datacenter

.NET-hulpmiddelen

.NET is goed in het werken met threads, zoals met veel andere dingen. Met elke nieuwe versie introduceert het steeds meer nieuwe tools om ermee te werken, nieuwe abstractielagen over OS-threads. Bij het werken met de constructie van abstracties gebruiken raamwerkontwikkelaars een aanpak die de mogelijkheid biedt om, wanneer een abstractie op een hoog niveau wordt gebruikt, een of meer niveaus daaronder te gaan. Meestal is dit niet nodig; in feite opent het de deur om jezelf met een jachtgeweer in de voet te schieten, maar soms, in zeldzame gevallen, kan dit de enige manier zijn om een ​​probleem op te lossen dat niet op het huidige abstractieniveau is opgelost. .

Met tools bedoel ik zowel application programming interfaces (API's) die door het raamwerk als pakketten van derden worden geleverd, evenals complete softwareoplossingen die het zoeken naar problemen met betrekking tot multi-threaded code vereenvoudigen.

Een draad starten

De Thread-klasse is de meest elementaire klasse in .NET voor het werken met threads. De constructor accepteert een van de twee afgevaardigden:

  • ThreadStart — Geen parameters
  • ParametrizedThreadStart - met één parameter van het type object.

De delegatie wordt uitgevoerd in de nieuw gemaakte thread na het aanroepen van de Start-methode. Als een delegatie van het type ParametrizedThreadStart aan de constructor is doorgegeven, moet een object worden doorgegeven aan de Start-methode. Dit mechanisme is nodig om lokale informatie naar de stream over te brengen. Het is vermeldenswaard dat het maken van een thread een dure operatie is, en dat de thread zelf een zwaar object is, tenminste omdat deze 1 MB geheugen op de stapel toewijst en interactie met de OS API vereist.

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

De klasse ThreadPool vertegenwoordigt het concept van een pool. In .NET is de threadpool een stukje techniek, en de ontwikkelaars bij Microsoft hebben veel moeite gedaan om ervoor te zorgen dat deze optimaal werkt in een grote verscheidenheid aan scenario's.

Algemeen concept:

Vanaf het moment dat de applicatie start, worden er op de achtergrond verschillende threads in reserve gemaakt en is het mogelijk deze voor gebruik te gebruiken. Als threads vaak en in grote aantallen worden gebruikt, wordt de pool uitgebreid om aan de behoeften van de beller te voldoen. Als er op het juiste moment geen vrije threads in de pool zijn, wacht de pool tot een van de threads terugkeert, of wordt er een nieuwe aangemaakt. Hieruit volgt dat de threadpool geweldig is voor bepaalde kortetermijnacties en slecht geschikt is voor bewerkingen die gedurende de gehele werking van de applicatie als services worden uitgevoerd.

Om een ​​thread uit de pool te gebruiken, is er een QueueUserWorkItem-methode die een afgevaardigde van het type WaitCallback accepteert, die dezelfde handtekening heeft als ParametrizedThreadStart, en de parameter die eraan wordt doorgegeven, voert dezelfde functie uit.

ThreadPool.QueueUserWorkItem(...);

De minder bekende threadpoolmethode RegisterWaitForSingleObject wordt gebruikt om niet-blokkerende IO-bewerkingen te organiseren. De gedelegeerde die aan deze methode is doorgegeven, wordt aangeroepen wanneer de WaitHandle die aan de methode is doorgegeven “Released” is.

ThreadPool.RegisterWaitForSingleObject(...)

.NET heeft een threadtimer en verschilt van WinForms/WPF-timers doordat de handler wordt aangeroepen op een thread die uit de pool wordt gehaald.

System.Threading.Timer

Er is ook een nogal exotische manier om een ​​afgevaardigde voor uitvoering naar een thread uit de pool te sturen: de BeginInvoke-methode.

DelegateInstance.BeginInvoke

Ik wil kort stilstaan ​​bij de functie waarnaar veel van de bovenstaande methoden kunnen worden aangeroepen: CreateThread van Kernel32.dll Win32 API. Er is een manier, dankzij het mechanisme van externe methoden, om deze functie aan te roepen. Ik heb zo'n oproep slechts één keer gezien in een verschrikkelijk voorbeeld van verouderde code, en de motivatie van de auteur die precies dit deed, blijft voor mij een mysterie.

Kernel32.dll CreateThread

Threads bekijken en fouten opsporen

Threads die door u, alle componenten van derden en de .NET-pool zijn gemaakt, kunnen worden bekeken in het Threads-venster van Visual Studio. Dit venster geeft alleen threadinformatie weer als de toepassing zich in een debugfase bevindt en in de Break-modus staat. Hier kunt u gemakkelijk de stapelnamen en prioriteiten van elke thread bekijken, en de foutopsporing overschakelen naar een specifieke thread. Met behulp van de Priority-eigenschap van de Thread-klasse kunt u de prioriteit van een thread instellen, die de OC en CLR zullen zien als een aanbeveling bij het verdelen van de processortijd tussen threads.

.NET: Tools voor het werken met multithreading en asynchronie. Deel 1

Parallelle taakbibliotheek

Task Parallel Library (TPL) werd geïntroduceerd in .NET 4.0. Nu is het de standaard en het belangrijkste hulpmiddel voor het werken met asynchronie. Elke code die een oudere aanpak gebruikt, wordt als verouderd beschouwd. De basiseenheid van TPL is de klasse Task uit de naamruimte System.Threading.Tasks. Een taak is een abstractie over een draad. Met de nieuwe versie van de C#-taal hebben we een elegante manier om met Taken te werken: async/await-operatoren. Deze concepten maakten het mogelijk om asynchrone code te schrijven alsof deze eenvoudig en synchroon was. Dit maakte het zelfs voor mensen met weinig begrip van de interne werking van threads mogelijk om applicaties te schrijven die er gebruik van maken, applicaties die niet vastlopen bij het uitvoeren van lange bewerkingen. Het gebruik van async/await is een onderwerp voor een of zelfs meerdere artikelen, maar ik zal proberen de essentie ervan in een paar zinnen te vatten:

  • async is een modificator van een methode die Task of void retourneert
  • and wait is een niet-blokkerende Task Waiting-operator.

Nogmaals: de await-operator zal in het algemene geval (er zijn uitzonderingen) de huidige uitvoeringsthread verder vrijgeven, en wanneer de taak de uitvoering ervan beëindigt, en de thread (in feite zou het juister zijn om de context te zeggen , maar daarover later meer) zal doorgaan met het verder uitvoeren van de methode. Binnen .NET wordt dit mechanisme op dezelfde manier geïmplementeerd als yield return, waarbij de geschreven methode verandert in een hele klasse, die een toestandsmachine is en in afzonderlijke delen kan worden uitgevoerd, afhankelijk van deze toestanden. Iedereen die geïnteresseerd is, kan elke eenvoudige code schrijven met asynс/await, de assembly compileren en bekijken met JetBrains dotPeek met Compiler Generated Code ingeschakeld.

Laten we eens kijken naar de opties voor het starten en gebruiken van Task. In het onderstaande codevoorbeeld maken we een nieuwe taak die niets nuttigs doet (Draad.Slaap(10000)), maar in het echte leven zou dit complex CPU-intensief werk moeten zijn.

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
}

Er wordt een taak aangemaakt met een aantal opties:

  • LongRunning is een hint dat de taak niet snel zal worden voltooid, wat betekent dat het de moeite waard kan zijn om geen thread uit de pool te nemen, maar een aparte thread voor deze taak te maken om anderen geen schade te berokkenen.
  • AttachedToParent - Taken kunnen in een hiërarchie worden gerangschikt. Als deze optie is gebruikt, bevindt de taak zich mogelijk in een staat waarin deze zelf is voltooid en wacht op de uitvoering van zijn onderliggende taken.
  • PreferFairness - betekent dat het beter is om taken die eerder zijn verzonden voor uitvoering uit te voeren dan taken die later worden verzonden. Maar dit is slechts een aanbeveling en de resultaten zijn niet gegarandeerd.

De tweede parameter die aan de methode wordt doorgegeven, is CancellationToken. Om het annuleren van een bewerking nadat deze is gestart correct af te handelen, moet de code die wordt uitgevoerd worden gevuld met controles voor de status CancellationToken. Als er geen controles zijn, kan de Cancel-methode die wordt aangeroepen op het CancellationTokenSource-object de uitvoering van de taak alleen stoppen voordat deze begint.

De laatste parameter is een plannerobject van het type TaskScheduler. Deze klasse en zijn afstammelingen zijn ontworpen om strategieën te beheren voor het distribueren van taken over threads; standaard wordt de taak uitgevoerd op een willekeurige thread uit de pool.

De await-operator wordt toegepast op de gemaakte taak, wat betekent dat de code die erna wordt geschreven, als die er is, in dezelfde context zal worden uitgevoerd (vaak betekent dit in dezelfde thread) als de code ervoor.

De methode is gemarkeerd als async void, wat betekent dat deze de operator await kan gebruiken, maar dat de aanroepende code niet kan wachten op uitvoering. Als een dergelijke functie nodig is, moet de methode Task retourneren. Methoden die zijn gemarkeerd als async void zijn vrij gebruikelijk: in de regel zijn dit gebeurtenishandlers of andere methoden die werken volgens het 'fire and vergeet'-principe. Als u niet alleen de mogelijkheid wilt geven om te wachten tot het einde van de uitvoering, maar ook het resultaat wilt retourneren, dan moet u Task gebruiken.

Voor de taak die de StartNew-methode heeft geretourneerd, en voor elke andere, kunt u de ConfigureAwait-methode aanroepen met de parameter false, waarna de uitvoering na await niet doorgaat op de vastgelegde context, maar op een willekeurige context. Dit moet altijd worden gedaan als de uitvoeringscontext niet belangrijk is voor de code na het wachten. Dit is ook een aanbeveling van MS bij het schrijven van code die verpakt in een bibliotheek wordt afgeleverd.

Laten we even stilstaan ​​bij hoe u kunt wachten op de voltooiing van een taak. Hieronder staat een voorbeeld van code, met commentaar over wanneer de verwachting voorwaardelijk goed wordt uitgevoerd en wanneer deze voorwaardelijk slecht wordt uitgevoerd.

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
}

In het eerste voorbeeld wachten we tot de taak is voltooid zonder de oproepende thread te blokkeren; we zullen pas terugkeren naar het verwerken van het resultaat als het er al is; tot die tijd wordt de oproepende thread aan zijn lot overgelaten.

Bij de tweede optie blokkeren we de aanroepende thread totdat het resultaat van de methode is berekend. Dit is niet alleen slecht omdat we een thread, zo'n waardevolle hulpbron van het programma, hebben bezet met eenvoudig nietsdoen, maar ook omdat als de code van de methode die we aanroepen bevat, wacht, en de synchronisatiecontext vereist dat we daarna terugkeren naar de oproepende thread Wacht even, dan komen we in een impasse terecht: de oproepende thread wacht tot het resultaat van de asynchrone methode is berekend, de asynchrone methode probeert tevergeefs de uitvoering ervan in de oproepende thread voort te zetten.

Een ander nadeel van deze aanpak is de ingewikkelde foutafhandeling. Feit is dat fouten in asynchrone code bij het gebruik van async/await heel gemakkelijk te verhelpen zijn: ze gedragen zich hetzelfde alsof de code synchroon zou zijn. Terwijl als we synchroon wacht-exorcisme toepassen op een taak, de oorspronkelijke uitzondering verandert in een AggregateException, d.w.z. Om de uitzondering af te handelen, moet je het InnerException-type onderzoeken en een if-chain schrijven binnen één catch-blok, of de catch when-constructie gebruiken, in plaats van de keten van catch-blokken die meer bekend is in de C#-wereld.

Het derde en laatste voorbeeld is om dezelfde reden ook als slecht gemarkeerd en bevat dezelfde problemen.

De methoden WhenAny en WhenAll zijn uiterst handig bij het wachten op een groep taken; ze verpakken een groep taken in één, die wordt geactiveerd wanneer een taak uit de groep voor het eerst wordt geactiveerd, of wanneer ze allemaal hun uitvoering hebben voltooid.

Het stoppen van draadjes

Om verschillende redenen kan het nodig zijn om de stroom te stoppen nadat deze is begonnen. Er zijn een aantal manieren om dit te doen. De klasse Thread heeft twee methoden met de juiste naam: Afbreken и Onderbreken. De eerste wordt ten zeerste afgeraden voor gebruik, omdat na het aanroepen ervan op een willekeurig moment, tijdens de verwerking van een instructie, wordt er een uitzondering gegenereerd ThreadAbortedException. Je verwacht niet dat zo'n uitzondering wordt gegenereerd bij het verhogen van een integer-variabele, toch? En bij gebruik van deze methode is dit een zeer reële situatie. Als u wilt voorkomen dat de CLR een dergelijke uitzondering genereert in een bepaald codegedeelte, kunt u deze in aanroepen verpakken Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Elke code die in een definitief blok wordt geschreven, wordt in dergelijke aanroepen verpakt. Om deze reden kun je diep in de raamwerkcode blokken vinden met een lege poging, maar niet met een lege finale. Microsoft ontmoedigt deze methode zozeer dat ze deze niet in de .net core hebben opgenomen.

De Interrupt-methode werkt voorspelbaarder. Het kan de thread onderbreken, met een uitzondering ThreadInterruptedException alleen tijdens die momenten waarop de thread zich in een wachtstatus bevindt. Het komt in deze status terecht terwijl het blijft hangen tijdens het wachten op WaitHandle, vergrendelen of na het aanroepen van Thread.Sleep.

Beide hierboven beschreven opties zijn slecht vanwege hun onvoorspelbaarheid. De oplossing is om een ​​structuur te gebruiken AnnuleringToken en klasse AnnuleringTokenSource. Het punt is dit: er wordt een exemplaar van de klasse CancellationTokenSource gemaakt en alleen degene die de eigenaar is, kan de bewerking stoppen door de methode aan te roepen Annuleer. Alleen het CancellationToken wordt doorgegeven aan de bewerking zelf. CancellationToken-eigenaren kunnen de operatie niet zelf annuleren, maar kunnen alleen controleren of de operatie is geannuleerd. Hiervoor bestaat een Booleaanse eigenschap Is annulering aangevraagd en methode ThrowIfCancelRequested. Dit laatste zal een uitzondering opleveren TaskCancelledException als de Cancel-methode werd aangeroepen op de CancellationToken-instantie die werd gekopieerd. En dit is de methode die ik aanbeveel. Dit is een verbetering ten opzichte van de voorgaande opties doordat u volledige controle krijgt over het moment waarop een uitzonderingsoperatie kan worden afgebroken.

De meest brutale optie om een ​​thread te stoppen is door de Win32 API TerminateThread-functie aan te roepen. Het gedrag van de CLR na het aanroepen van deze functie kan onvoorspelbaar zijn. Op MSDN staat over deze functie het volgende geschreven: “TerminateThread is een gevaarlijke functie die alleen in de meest extreme gevallen gebruikt mag worden. “

Verouderde API converteren naar taakgebaseerd met behulp van de FromAsync-methode

Als je het geluk hebt om aan een project te werken dat is gestart nadat Tasks was geïntroduceerd en voor de meeste ontwikkelaars geen stille horror meer veroorzaakt, dan zul je niet te maken krijgen met veel oude API's, zowel die van derden als die van je team. heeft in het verleden gemarteld. Gelukkig zorgde het .NET Framework-team voor ons, hoewel het misschien de bedoeling was om voor onszelf te zorgen. Hoe het ook zij, .NET beschikt over een aantal hulpmiddelen voor het probleemloos converteren van code die is geschreven in oude asynchrone programmeerbenaderingen naar de nieuwe. Eén daarvan is de FromAsync-methode van TaskFactory. In het onderstaande codevoorbeeld verpak ik de oude asynchrone methoden van de WebRequest-klasse in een taak met behulp van deze methode.

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

Dit is slechts een voorbeeld en het is onwaarschijnlijk dat u dit met ingebouwde typen hoeft te doen, maar elk oud project wemelt simpelweg van de BeginDoSomething-methoden die IAsyncResult retourneren en de EndDoSomething-methoden die het ontvangen.

Converteer verouderde API naar taakgebaseerd met behulp van de TaskCompletionSource-klasse

Een ander belangrijk hulpmiddel om te overwegen is de klas Taakvoltooiingsbron. In termen van functies, doel en werkingsprincipe doet het misschien enigszins denken aan de RegisterWaitForSingleObject-methode van de ThreadPool-klasse, waarover ik hierboven schreef. Met deze klasse kunt u eenvoudig en gemakkelijk oude asynchrone API's in Taken verpakken.

Je zult zeggen dat ik al heb gesproken over de FromAsync-methode van de TaskFactory-klasse die voor deze doeleinden is bedoeld. Hier zullen we de hele geschiedenis van de ontwikkeling van asynchrone modellen in .net moeten onthouden die Microsoft de afgelopen vijftien jaar heeft aangeboden: vóór het Task-Based Asynchronous Pattern (TAP) was er het Asynchronous Programming Pattern (APP), dat ging over methoden BeginnenDoe iets terug IAsyncResultaat en methoden EindeDoSomething dat het accepteert en voor de erfenis van deze jaren is de FromAsync-methode gewoon perfect, maar na verloop van tijd werd deze vervangen door het Event Based Asynchronous Pattern (EAP), waarbij werd aangenomen dat er een gebeurtenis zou plaatsvinden wanneer de asynchrone bewerking was voltooid.

TaskCompletionSource is perfect voor het inpakken van taken en oudere API's die rond het gebeurtenismodel zijn gebouwd. De essentie van zijn werk is als volgt: een object van deze klasse heeft een openbare eigenschap van het type Task, waarvan de status kan worden gecontroleerd via de methoden SetResult, SetException, enz. van de klasse TaskCompletionSource. Op plaatsen waar de operator await op deze taak is toegepast, wordt deze uitgevoerd of mislukt deze met een uitzondering, afhankelijk van de methode die is toegepast op de TaskCompletionSource. Als het nog steeds niet duidelijk is, laten we dan eens kijken naar dit codevoorbeeld, waarin een oude EAP-API in een taak is verpakt met behulp van een TaskCompletionSource: wanneer de gebeurtenis wordt geactiveerd, wordt de taak overgebracht naar de status Voltooid en de methode die de await-operator heeft toegepast voor deze taak zal de uitvoering ervan hervatten nadat het object is ontvangen resultaat.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

Taakvoltooiing Brontips en -trucs

Het inpakken van oude API's is niet het enige dat u kunt doen met TaskCompletionSource. Het gebruik van deze klasse opent een interessante mogelijkheid om verschillende API's te ontwerpen voor taken die geen threads bezetten. En de stream is, zoals we ons herinneren, een dure hulpbron en hun aantal is beperkt (vooral door de hoeveelheid RAM). Deze beperking kan eenvoudig worden bereikt door bijvoorbeeld een geladen webapplicatie met complexe bedrijfslogica te ontwikkelen. Laten we eens kijken naar de mogelijkheden waar ik het over heb bij het implementeren van een truc als Long-Polling.

Kortom, de essentie van de truc is dit: je moet informatie van de API ontvangen over bepaalde gebeurtenissen die aan zijn kant plaatsvinden, terwijl de API om de een of andere reden de gebeurtenis niet kan rapporteren, maar alleen de status kan retourneren. Een voorbeeld hiervan zijn alle API's die bovenop HTTP zijn gebouwd vóór de tijd van WebSocket of toen het om een ​​of andere reden onmogelijk was om deze technologie te gebruiken. De client kan de HTTP-server vragen. De HTTP-server kan zelf geen communicatie met de client initiëren. Een eenvoudige oplossing is om de server te pollen met behulp van een timer, maar dit zorgt voor extra belasting van de server en een extra vertraging op gemiddeld TimerInterval / 2. Om dit te omzeilen is een truc genaamd Long Polling bedacht, waarbij de reactie van de server totdat de time-out afloopt of er een gebeurtenis plaatsvindt. Als de gebeurtenis heeft plaatsgevonden, wordt deze verwerkt. Als dit niet het geval is, wordt het verzoek opnieuw verzonden.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

Maar zo'n oplossing zal verschrikkelijk blijken zodra het aantal wachtende klanten op het evenement toeneemt, want... Elke klant bezet een hele thread, wachtend op een gebeurtenis. Ja, en we krijgen een extra vertraging van 1 ms wanneer de gebeurtenis wordt geactiveerd. Meestal is dit niet significant, maar waarom zou je de software erger maken dan deze kan zijn? Als we Thread.Sleep(1) verwijderen, zullen we tevergeefs één processorkern 100% inactief laden, roterend in een nutteloze cyclus. Met TaskCompletionSource kunt u deze code eenvoudig opnieuw maken en alle hierboven genoemde problemen oplossen:

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

Deze code is niet productieklaar, maar slechts een demo. Om het in echte gevallen te gebruiken, moet u op zijn minst ook de situatie aanpakken waarin een bericht arriveert op een tijdstip waarop niemand het verwacht: in dit geval moet de AsseptMessageAsync-methode een reeds voltooide taak retourneren. Als dit het meest voorkomende geval is, kunt u overwegen ValueTask te gebruiken.

Wanneer we een verzoek om een ​​bericht ontvangen, maken we een TaskCompletionSource aan en plaatsen deze in het woordenboek, en wachten vervolgens op wat er eerst gebeurt: het opgegeven tijdsinterval verstrijkt of er wordt een bericht ontvangen.

WaardeTaken: waarom en hoe

De async/await-operatoren genereren, net als de yield return-operator, een toestandsmachine op basis van de methode, en dit is de creatie van een nieuw object, wat bijna altijd niet belangrijk is, maar in zeldzame gevallen kan het een probleem veroorzaken. Dit geval kan een methode zijn die heel vaak wordt aangeroepen, we hebben het over tien- en honderdduizenden oproepen per seconde. Als een dergelijke methode zo is geschreven dat deze in de meeste gevallen een resultaat retourneert waarbij alle await-methoden worden omzeild, dan biedt .NET een hulpmiddel om dit te optimaliseren: de ValueTask-structuur. Laten we, om het duidelijk te maken, eens kijken naar een voorbeeld van het gebruik ervan: er is een cache waar we heel vaak naar toe gaan. Er zitten een aantal waarden in en dan geven we ze gewoon terug; zo niet, dan gaan we naar een langzame IO om ze op te halen. Dat laatste wil ik asynchroon doen, waardoor de hele werkwijze asynchroon blijkt te zijn. De voor de hand liggende manier om de methode te schrijven is dus 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 een lichte angst voor wat Roslyn zal genereren bij het compileren van deze code, kun je dit voorbeeld als volgt herschrijven:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

De optimale oplossing in dit geval zou inderdaad zijn om het hot-path te optimaliseren, namelijk het verkrijgen van een waarde uit het woordenboek zonder onnodige toewijzingen en belasting van de GC, terwijl we in die zeldzame gevallen waarin we nog steeds naar IO moeten gaan voor gegevens , alles blijft een plus/minus op de oude manier:

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 dit stukje code eens nader bekijken: als er een waarde in de cache zit, creëren we een structuur, anders wordt de echte taak in een betekenisvolle taak verpakt. Het maakt de aanroepende code niet uit in welk pad deze code is uitgevoerd: ValueTask zal zich in dit geval vanuit het oogpunt van de C#-syntaxis hetzelfde gedragen als een gewone taak.

TaskSchedulers: beheer van taaklanceringsstrategieën

De volgende API die ik zou willen overwegen, is de klasse taakplanner en zijn derivaten. Ik heb hierboven al vermeld dat TPL de mogelijkheid heeft om strategieën te beheren voor het distribueren van taken over threads. Dergelijke strategieën worden gedefinieerd in de afstammelingen van de klasse TaskScheduler. Bijna elke strategie die je nodig hebt, kun je vinden in de bibliotheek. ParallelExtensionsExtras, ontwikkeld door Microsoft, maar geen onderdeel van .NET, maar geleverd als Nuget-pakket. Laten we er een paar kort bekijken:

  • CurrentThreadTaskScheduler — voert taken uit op de huidige thread
  • LimitedConcurrencyLevelTaskScheduler — beperkt het aantal taken dat gelijktijdig wordt uitgevoerd door parameter N, die wordt geaccepteerd in de constructor
  • TaskScheduler besteld — is gedefinieerd als LimitedConcurrencyLevelTaskScheduler(1), dus taken worden opeenvolgend uitgevoerd.
  • WorkStealingTaskScheduler - werktuigen werkstelen benadering van taakverdeling. In wezen is het een afzonderlijke ThreadPool. Lost het probleem op dat ThreadPool in .NET een statische klasse is, één voor alle applicaties, wat betekent dat overbelasting of onjuist gebruik in het ene deel van het programma kan leiden tot bijwerkingen in een ander deel. Bovendien is het uiterst moeilijk om de oorzaak van dergelijke defecten te begrijpen. Dat. Het kan nodig zijn om afzonderlijke WorkStealingTaskSchedulers te gebruiken in delen van het programma waar het gebruik van ThreadPool agressief en onvoorspelbaar kan zijn.
  • QueuedTaskScheduler — hiermee kunt u taken uitvoeren volgens prioriteitswachtrijregels
  • ThreadPerTaskScheduler — creëert een aparte thread voor elke taak die erop wordt uitgevoerd. Kan handig zijn voor taken die onvoorspelbaar veel tijd in beslag nemen.

Er is een goede gedetailleerde artikel over TaskSchedulers op de microsoft blog.

Voor het gemakkelijk opsporen van fouten in alles wat met Taken te maken heeft, heeft Visual Studio een Taken-venster. In dit venster kunt u de huidige status van de taak zien en naar de coderegel springen die momenteel wordt uitgevoerd.

.NET: Tools voor het werken met multithreading en asynchronie. Deel 1

PLinq en de Parallel-klasse

Naast Taken en alles wat erover wordt gezegd, zijn er nog twee interessante tools in .NET: PLinq (Linq2Parallel) en de klasse Parallel. De eerste belooft een parallelle uitvoering van alle Linq-bewerkingen op meerdere threads. Het aantal threads kan worden geconfigureerd met behulp van de uitbreidingsmethode WithDegreeOfParallelism. Helaas heeft PLinq in de standaardmodus meestal niet genoeg informatie over de interne onderdelen van uw gegevensbron om een ​​aanzienlijke snelheidswinst te bieden. Aan de andere kant zijn de kosten van het proberen erg laag: u hoeft alleen maar de AsParallel-methode aan te roepen voordat de keten van Linq-methoden en voer prestatietests uit. Bovendien is het mogelijk om via het Partitions-mechanisme aanvullende informatie over de aard van uw gegevensbron aan PLinq door te geven. Je kunt meer lezen hier и hier.

De statische klasse Parallel biedt methoden voor het parallel doorlopen van een Foreach-verzameling, het uitvoeren van een For-lus en het parallel uitvoeren van meerdere afgevaardigden Invoke. De uitvoering van de huidige thread wordt gestopt totdat de berekeningen zijn voltooid. Het aantal threads kan worden geconfigureerd door ParallelOptions als laatste argument door te geven. U kunt TaskScheduler en CancellationToken ook opgeven met behulp van opties.

Bevindingen

Toen ik dit artikel begon te schrijven op basis van de materialen van mijn rapport en de informatie die ik tijdens mijn werk daarna verzamelde, had ik niet verwacht dat er zoveel van zou zijn. Als de teksteditor waarin ik dit artikel typ mij verwijtend vertelt dat pagina 15 verdwenen is, zal ik de tussentijdse resultaten samenvatten. Andere trucs, API’s, visuele tools en valkuilen komen aan bod in het volgende artikel.

Conclusies:

  • U moet de tools kennen voor het werken met threads, asynchronie en parallellisme om de bronnen van moderne pc's te kunnen gebruiken.
  • .NET beschikt hiervoor over veel verschillende tools
  • Ze zijn niet allemaal tegelijk verschenen, dus je kunt vaak oudere API's vinden. Er zijn echter manieren om oude API's zonder veel moeite te converteren.
  • Het werken met threads in .NET wordt vertegenwoordigd door de klassen Thread en ThreadPool
  • De methoden Thread.Abort, Thread.Interrupt en Win32 API TerminateThread zijn gevaarlijk en worden niet aanbevolen voor gebruik. In plaats daarvan is het beter om het CancellationToken-mechanisme te gebruiken
  • Flow is een waardevolle hulpbron en het aanbod ervan is beperkt. Situaties waarin threads bezig zijn met wachten op gebeurtenissen moeten worden vermeden. Hiervoor is het handig om de klasse TaskCompletionSource te gebruiken
  • De krachtigste en meest geavanceerde .NET-tools voor het werken met parallellisme en asynchronie zijn Taken.
  • De c# async/await-operatoren implementeren het concept van niet-blokkerend wachten
  • U kunt de distributie van taken over threads beheren met behulp van van TaskScheduler afgeleide klassen
  • De ValueTask-structuur kan nuttig zijn bij het optimaliseren van hot-paths en geheugenverkeer
  • De vensters Taken en Threads van Visual Studio bieden veel informatie die nuttig is voor het opsporen van fouten in multi-threaded of asynchrone code
  • PLinq is een coole tool, maar het bevat mogelijk niet voldoende informatie over uw gegevensbron, maar dit kan worden opgelost met behulp van het partitiemechanisme
  • Wordt vervolgd ...

Bron: www.habr.com

Voeg een reactie