.NET: Værktøjer til håndtering af multitrådning og asynkronitet. Del 1

Jeg offentliggør den originale artikel på Habr, hvis oversættelse er blevet sat i virksomheden blog.

Nødvendigheden af at gøre noget asynkront, uden at vente på resultatet her og nu, eller at dele stort arbejde mellem flere enheder, der udfører det, har eksisteret længe før computerne kom. Med deres ankomst blev denne nødvendighed meget mærkbar. Nu, i 2019, mens jeg skriver denne artikel på en bærbar computer med en 8-core Intel Core processor, arbejder der samtidigt ikke blot hundrede processer, men endnu flere tråde. Ved siden af ligger en lettere slidt telefon, købt for et par år siden, der har en 8-core processor. Der er masser af artikler og videoer på tematiske ressourcer, hvor forfatterne beundrer dette års flagskibs-smartphones, der har 16-core processorer. MS Azure tilbyder en virtuel maskine med 128-core processor og 2 TB RAM for mindre end 20 $/time. Desværre er det umuligt at udnytte det maksimale og tæmme denne kraft uden at vide, hvordan man styrer interaktionen mellem trådene.

Terminologi

Proces (Process) — et OS-objekt, et isoleret adresseområde, indeholder tråde.
Tråd (Thread) — et OS-objekt, den mindste udførelsesenhed, del af en proces, tråde deler hukommelse og andre ressourcer indbyrdes i en proces.
Multitasking — et OS-kendetegn, muligheden for at udføre flere processer samtidig
Flere kerner — et processorkendetegn, muligheden for at bruge flere kerner til behandling af data
Multiprocessering — et computerkendetegn, muligheden for samtidigt at arbejde med flere fysiske processorer
Multithreading — et proceskendetegn, muligheden for at fordele databehandlingen mellem flere tråde.
Parallellitet — udførelse af flere handlinger fysisk samtidig på et givet tidspunkt
Asynkronitet — udførelse af en operation uden at vente på, at denne behandling afsluttes; resultatet kan dog behandles senere.

Metafor

Ikke alle definitioner er gode, og nogle har brug for yderligere forklaring, så jeg vil til den formelle indførte terminologi tilføje en metafor om at lave morgenmad. At lave morgenmad i denne metafor — process.

Når jeg laver morgenmad om morgenen (CPU) går jeg ind i køkkenet (Computer). Jeg har 2 hænder (Kernede). I køkkenet er der en række apparater (IO): ovn, elkedel, brødrister, køleskab. Jeg tænder for gasen, sætter en pande på og hælder olie i, uden at vente på at den bliver varm (asynkront, Non-Blocking-IO-Wait), jeg tager æggene ud af køleskabet og slår dem ud i en skål, hvorefter jeg pisker dem med den ene hånd (Tråd#1), og med den anden (Tråd#2) holder jeg skålen (Shared Resource). Jeg ville også tænde for kedlen, men jeg har ikke flere hænder (Thread Starvation) I mellemtiden bliver panden varm (Behandling af resultat), hvortil jeg hælder det, jeg har pisket. Jeg strækker mig efter kedlen og tænder den og ser bare dumt på, hvordan vandet koger i den (Blocking-IO-Wait), selvom jeg kunne have vasket skålen, hvor jeg piskede omeletten, i stedet.

Jeg lavede omelet med kun 2 hænder, for jeg har ikke flere, men samtidig skete der 3 operationer mens jeg piskede omeletten: piskning af omeletten, holde skålen, opvarmning af panden. CPU’en er den hurtigste del af computeren, IO er det, der oftest sætter en bremse, derfor er det ofte en effektiv løsning at beskæftige CPU’en, mens data hentes fra IO.

Fortsætter metaforen:

  • Hvis jeg under tilberedningen af omeletten også forsøgte at skifte tøj, ville det være et eksempel på multitasking. En vigtig nuance: computere er langt bedre til dette end mennesker.
  • Køkkenet med flere kokke, som i en restaurant — en flerkernecomputer.
  • Mange restauranter på foodcourten i indkøbscentret — datacenter

Værktøjer i .NET

I arbejdet med tråde, som med meget andet, er .NET godt. Med hver ny version præsenterer det flere nye værktøjer til at arbejde med dem, nye lag af abstraktion over OS-tråde. I arbejdet med at bygge abstraktioner bruger udviklere af rammeværket en tilgang, der giver mulighed for at gå et eller flere niveauer ned fra den højere abstraktionslag. Ofte er der ikke behov for det, tværtimod åbner det mulighed for at skyde sig selv i foden med et haglgevær, men nogle gange, i sjældne tilfælde, kan det vise sig at være den eneste måde at løse et problem, der ikke kan løses på det nuværende niveau af abstraktion.

Med værktøjer mener jeg både softwaregrænseflader (API'er) leveret af rammeværket og tredjeparts pakker, såvel som komplette softwareløsninger, der forenkler det at finde problemer relateret til multitrådet kode.

Start af tråd

Thread-klassen er den mest grundlæggende i .NET til arbejde med tråde. Konstruktøren tager en af to delegerede:

  • ThreadStart — Uden parametre
  • ParametrizedThreadStart — med en parameter af typen object.

Delegaten vil blive udført i den nyoprettede tråd efter opkaldet til Start-metoden, hvis en delegat af typen ParametrizedThreadStart blev givet til konstruktøren, så skal et objekt sendes til Start-metoden. Denne mekanisme er nødvendig for at overføre enhver lokal information til tråden. Det er værd at bemærke, at oprettelse af en tråd er en dyr operation, og selve tråden er et tungt objekt, i det mindste fordi 1 MB hukommelse allokeres til stakken, og den kræver interaktion med OS’s API.

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

ThreadPool-klassen repræsenterer konceptet for en pulje. I .NET er trådpoolen et teknisk mesterværk, og udviklerne fra Microsoft har investeret mange ressourcer for at sikre, at den fungerer optimalt i de mest varierede scenarier.

Generelt koncept:

Fra det øjeblik applikationen starter, opretter den i baggrunden flere reserve-tråde og giver mulighed for at tage dem i brug. Hvis trådene bruges ofte og i stort antal, udvides puljen for at imødekomme behovet fra den anropende kode. Når der ikke er ledige tråde i puljen på et givent tidspunkt, venter den enten på, at en tråd returneres, eller den opretter en ny. Dette viser, at trådpoolen er særligt velegnet til kortvarige handlinger og mindre egnet til operationer, der fungerer som en tjeneste i hele applikationens driftstid.

For at bruge en tråd fra puljen, findes der en metode QueueUserWorkItem, som tager en delegat af typen WaitCallback, der har samme signatur som ParametrizedThreadStart, og parameteren, der gives til den, udfører samme funktion.

ThreadPool.QueueUserWorkItem(...);

Mindre kendt er trådpoolmetoden RegisterWaitForSingleObject, som bruges til at organisere ikke-blokerende IO-operationer. Delegaten, der gives til denne metode, vil blive kaldt, når WaitHandle, der er sendt til metoden, bliver 'frigivet'.

ThreadPool.RegisterWaitForSingleObject(...)

I .NET findes der en tråd-timer, og den adskiller sig fra timerne i WinForms/WPF ved, at dens behandler vil blive kaldt i en tråd taget fra puljen.

System.Threading.Timer

Der er også en temmelig eksotisk måde at sende en delegat til udførelse i en tråd fra puljen — metoden BeginInvoke.

DelegateInstance.BeginInvoke

Jeg vil også kort nævne funktionen, som mange af de ovennævnte metoder henviser til — CreateThread fra Kernel32.dll Win32 API. Der er en måde at kalde denne funktion på ved hjælp af ekstern metode mekanismen. Jeg har kun set et sådant kald en gang i et uhyggeligt eksempel på legacy-kode, og motivet for forfatteren til at gøre det på den måde forbliver stadig en gåde for mig.

Kernel32.dll CreateThread

Gennemgang og fejlfinding af tråde

De tråde, du har oprettet selv, samt alle tredjeparts komponenter og .NET tråde, kan ses i fanen Tråde i Visual Studio. Denne fane viser kun tråddata, når applikationen er i fejlfinding og i pause tilstand (Break mode). Her kan du bekvemt se stakken, navnene og prioriteterne for hver tråd og skifte fejlfinding til en specifik tråd. Ved hjælp af Priority egenskaben i Thread klassen kan du angive trådens prioritet, som OS og CLR vil opfatte som en anbefaling ved fordeling af procesorkraft mellem tråde.

.NET: Værktøjer til håndtering af multitrådning og asynkronitet. Del 1

Task Parallel Library

Task Parallel Library (TPL) blev indført i .NET 4.0. Det er nu standard og det primære værktøj til at arbejde med asynchronitet. Enhver kode, der bruger ældre tilgange, betragtes som legacy. Den primære enhed i TPL er Task klassen fra namespace System.Threading.Tasks. Task repræsenterer en abstraktion over tråden. Med den nye version af C# har vi fået en elegant måde at arbejde med opgaver — async/await operatørerne. Disse koncepter har gjort det muligt at skrive asynkron kode, som om den var simpel og synkron, hvilket har gjort det lettere for folk, som ikke forstår trådenes indre funktioner, at skrive applikationer, der ikke fryser under lange operationer. Brug af async/await er et emne for en eller endda flere artikler, men jeg vil forsøge at opsummere essensen i nogle få sætninger:

  • async er en modificerer af metoden, der returnerer Task eller void
  • og await er en operator til ikke-blokkerende venten på en Task.

Endnu en gang: await-operatøren vil generelt (der er undtagelser) frigive den aktuelle udførelsestråd og fortsætte udførelsen, når Task'en er blevet gennemført, og tråden (det ville faktisk være mere præcist at sige konteksten, men det vender vi tilbage til senere) er ledig. Inden for .NET er denne mekanisme implementeret ligesom yield return, hvor den skrevne metode bliver til en hel klasse, der fungerer som en tilstandsmaskine og kan udføres i separate dele afhængigt af disse tilstande. Den, der er interesseret, kan skrive en hvilken som helst ikke-kompliceret kode ved hjælp af async/await, kompilere den og se samlingen med JetBrains dotPeek med Compiler Generated Code aktiveret.

Lad os se på måder at starte og bruge en Task. I koden nedenfor opretter vi en ny task, der ikke udfører noget nyttigt (Thread.Sleep(10000)), men i det virkelige liv skal dette være et eller andet kompleks, der involverer CPU-arbejde.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Handlingskoden vil blive udført i en anden kontekst
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    // Koden efter await vil blive udført i den fangede kontekst
}

En Task oprettes med en række muligheder:

  • LongRunning — en hentydning om, at opgaven ikke vil blive gennemført hurtigt, og dermed kan det være værd at overveje ikke at tage en tråd fra puljen, men at oprette en separat til denne Task for ikke at skade de øvrige.
  • AttachedToParent — Tasks kan organiseres i hierarkier. Hvis denne mulighed er brugt, kan Task'en befinde sig i en tilstand, hvor den selv er udført og venter på, at underordnede fuldfører.
  • PreferFairness — betyder, at det ville være godt at udføre Tasks, der er sendt til udførelse tidligere, før dem der er sendt senere. Men dette er kun en anbefaling, og resultaterne er ikke garanterede.

Det andet parameter i metoden er en CancellationToken. For korrekt håndtering af aflysningen af operationen efter dens start skal den udførte kode indeholde tjek af CancellationToken's tilstand. Hvis der ikke er nogen tjek, kan metoden Cancel, der kaldes på CancellationTokenSource-objektet, kun stoppe udførelsen af Task'en inden dens start.

Den sidste parameter er et objekt af typen TaskScheduler. Denne klasse og dens underklasser er designet til at styre strategierne for fordeling af opgaver på tråde, og som standard vil opgaven blive udført på en vilkårlig tråd fra puljen.

Den oprettede opgave anvender await-operatoren, hvilket betyder, at koden, der er skrevet efter den, hvis der er nogen, vil blive udført i den samme kontekst (det betyder ofte, at det er på den samme tråd), som koden før await.

Metoden er mærket som async void, hvilket betyder, at det er tilladt at bruge await-operatoren, men den kaldende kode vil ikke kunne vente på udførelsen. Hvis denne mulighed er nødvendig, skal metoden returnere Task. Metoder, der er mærket som async void, er ret almindelige: typisk er dette begivenhedshåndterere eller andre metoder, der fungerer efter princippet udfør og glem (fire and forget). Hvis det er nødvendigt ikke blot at give mulighed for at vente på afslutningen af udførelsen, men også at returnere et resultat, skal Task anvendes.

På den Task, som metoden StartNew returnerede, kan man, ligesom på enhver anden, kalde metoden ConfigureAwait med parameteren false; så vil udførelsen efter await fortsætte ikke i den fangede kontekst, men på en vilkårlig. Dette skal altid gøres, når konteksten af udførelsen ikke er vigtig for koden efter await. Dette er også en anbefaling fra MS ved skrivning af kode, der vil blive leveret pakket i biblioteksformat.

Lad os tage et øjeblik til at se på, hvordan man kan vente på afslutningen af udførelsen af Tasker. Nedenfor er et eksempel på kode, med kommentarer om hvornår venten er gjort forholdsvis godt, og hvornår det er gjort forholdsvis dårligt.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // godt

    result = AsyncMethod().Result; // dårligt

    AsyncMethod().Wait(); // dårligt

    IEnumerable tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // godt
    await Task.WhenAny(tasks); // godt

    Task.WaitAll(tasks.ToArray()); // dårligt
}

I det første eksempel venter vi på udførelsen af Tasken uden at blokere den kaldende tråd; vi vender tilbage til behandling af resultatet først, når det er tilgængeligt, indtil da er den kaldende tråd fristillet.

I den anden variant blokerer vi den kaldende tråd, indtil resultatet af metoden er blevet beregnet. Dette er problematisk, ikke kun fordi vi har optaget en tråd, som er en så værdifuld ressource i programmet, med meningsløshed, men også fordi, hvis der i den kaldte metode er et await, og synkroniseringskonteksten forudser, at kontrol skal returneres til den kaldende tråd efter await, så vil vi få en deadlock: den kaldende tråd venter på, at resultatet af den asynkrone metode bliver beregnet, mens den asynkrone metode forgæves forsøger at fortsætte sin udførelse i den kaldende tråd.

En anden ulempe ved denne tilgang er, at fejlhåndteringen bliver kompliceret. Problemet er, at fejl i asynkron kode med brug af async/await er meget nemmere at håndtere – de opfører sig, som om koden var synkron. Mens hvis vi anvender synkron ventetid på en Task, vil den originale undtagelse blive pakket ind i AggregateException. Derfor skal vi undersøge typen af InnerException for at håndtere undtagelsen og selv skrive en kæde af if-indstruktioner inden for en catch-blok eller bruge catch when-konstruktionen i stedet for den mere velkendte kæde af catch-blokke i C#-verdenen.

Den tredje og sidste eksempler er også præget af dårlige forhold af samme grund og indeholder alle de samme problemer.

Metoderne WhenAny og WhenAll er ekstremt nyttige til at vente på en gruppe af Tasks, da de pakker en gruppe af Tasks ind i en enkelt, der enten aktiveres ved den første Task, der aktiveres fra gruppen, eller når alle er færdige med deres udførelse.

Stop af tråde

Af forskellige grunde kan det blive nødvendigt at stoppe en tråd efter dens opstart. Der findes flere måder at gøre dette på. Klassen Thread har to metoder med passende navne — det er Abort og Interrupt. Den første anbefales stærkt imod at bruge, da der efter dens kald vil blive kastet en undtagelse på et vilkårligt tidspunkt, når enhver instruktion behandles. ThreadAbortedException. Forventer du, at en sådan undtagelse vil opstå ved at inkrementere en hvilken som helst heltalsvariabel, ikke? Men ved brugen af denne metode, er det en helt reel situation. Hvis det er nødvendigt at forbyde CLR at generere en sådan undtagelse i et bestemt kodeområde, kan du pakke det ind i kald til Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Disse udfordringer opstår med enhver kode skrevet i finally-blokken. Af denne grund kan man finde blokke med tom try, men ikke tom finally i kernen af rammeværket. Microsoft fraråder så meget at bruge denne metode, at den ikke er inkluderet i .net core.

Interrupt-metoden fungerer mere forudsigeligt. Den kan afbryde tråden med en undtagelse. ThreadInterruptedException kun i de øjeblikke, hvor tråden er i ventetilstand. Den går ind i denne tilstand ved at hænge i ventetiden på WaitHandle, lock eller efter et kald til Thread.Sleep.

Begge ovenstående muligheder er dårlige på grund af deres uforudsigelighed. Løsningen er at bruge strukturen CancellationToken og klassen CancellationTokenSource. Essensen er som følger: der oprettes en instans af CancellationTokenSource-klassen, og kun den, der ejer den, kan stoppe operationen ved at kalde metoden Cancel. Til selve operationen sendes der kun CancellationToken. Ejere af CancellationToken kan ikke selv annullere operationen, men kun kontrollere, om operationen er blevet annulleret. Til dette formål er der en boolsk egenskab IsCancellationRequested og metoden ThrowIfCancelRequested. Den sidstnævnte vil generere en undtagelse TaskCancelledException hvis Cancel-metoden er blevet kaldt på den repræsenterende CancellationTokenSource. Og det er netop denne metode, jeg anbefaler at bruge. Det giver bedre kontrol over, hvornår undtagelsen kan afbrydes.

Den mest drastiske mulighed for at stoppe en tråd er at kalde Win32 API-funktionen TerminateThread. CLR's adfærd efter kaldet til denne funktion kan være uforudsigelig. Om denne funktion står der på MSDN: “TerminateThread is a dangerous function that should only be used in the most extreme cases.”

Omformning af legacy-API til Task Based ved hjælp af metoden FromAsync

Hvis du har været så heldig at arbejde på et projekt, der blev startet, efter at Task'erne blev indført og stoppede med at vække frygt i de fleste udviklere, så slipper du for at håndtere mange gamle API'er, både dem fra tredjepart og dem, din egen gruppe har udviklet tidligere. Heldigvis har .NET Framework's udviklingsteam taget hånd om os, selvom deres mål måske var at passe på sig selv. Uanset hvad, så findes der i .NET en række værktøjer til smertefri konvertering af kode skrevet i gamle tilgange til asynkron programmere til den nye. En af dem er metoden FromAsync i TaskFactory. I koden nedenfor indkapsler jeg gamle asynkrone metoder fra klassen WebRequest i Task ved hjælp af denne metode.

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

Dette er blot et eksempel, og du vil næppe skulle gøre sådan noget med indbyggede typer, men ethvert gammelt projekt er ofte fyldt med metoder som BeginDoSomething, der returnerer IAsyncResult og metoder som EndDoSomething, der accepterer det.

Konvertering af legacy-API'er til Task Based ved hjælp af klassen TaskCompletionSource

Et andet vigtigt værktøj at overveje er klassen TaskCompletionSource. Funktionsmæssigt, med dets formål og arbejdsmåde, kan det minde om metoden RegisterWaitForSingleObject i klassen ThreadPool, som jeg nævnte tidligere. Med denne klasse kan man nemt og bekvemt indkapsle gamle asynkrone API'er i Tasks.

Du vil måske sige, at jeg allerede har talt om metoden FromAsync i klassen TaskFactory, der er beregnet til disse formål. Her må jeg huske hele historien om udviklingen af asynkrone modeller i .NET, som Microsoft har tilbudt i de sidste 15 år: Før Task-Based Asynchronous Pattern (TAP) eksisterede Asynchronous Programming Pattern (APP), som handlede om metoderne BeginDoSomething, der returnerer IAsyncResult og metoderne EndDoSomething, der accepterer det, og for disse legacy-metoder fra disse år passer metoden FromAsync perfekt. Men hen ad vejen blev den afløst af Event Based Asynchronous Pattern (EAP), som forudsatte, at der ville blive udløst et begivenhed ved afslutningen af en asynkron operation.

TaskCompletionSource er ideel til at pakke legacy-API'er, der er bygget omkring en begivenhedsmodel, ind i Tasks. Dets funktion er som følger: objektet i denne klasse har et offentligt egenskab af typen Task, hvis tilstand kan styres gennem metoderne SetResult, SetException osv. For TaskCompletionSource. I de steder, hvor der er anvendt await-operatoren på denne Task, vil den blive fuldført eller fejlet med en undtagelse afhængigt af metoden, der anvendtes på TaskCompletionSource. Hvis det stadig ikke er klart, lad os se på dette kodeeksempel, hvor et gammelt API fra EAP pakkes ind i en Task ved hjælp af TaskCompletionSource: når begivenheden udløses, vil Task'en blive ændret til tilstanden Completed, og metoden, der anvendte await-operatoren på denne Task, vil genoptage sin udførelse og modtage objektet. resultat.

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

Indpakning af gamle API'er er ikke det eneste, der kan gøres med TaskCompletionSource. Brug af denne klasse åbner op for interessante muligheder for at designe forskellige API'er, der arbejder med Tasks, som ikke optager tråde. Og som vi ved, er tråde en dyrebar ressource, og deres antal er begrænset (hovedsageligt af RAM-kapaciteten). Denne begrænsning kan let opnås ved at udvikle f.eks. en belastet webapplikation med kompleks forretningslogik. Lad os se på de muligheder, jeg taler om i implementeringen af et trick som Long-Polling.

Kort sagt, essensen af tricket er, at du skal modtage information fra API'et om visse hændelser, der sker på dets side. Af en eller anden grund kan API'et dog ikke rapportere en hændelse, men kan kun returnere status. Et eksempel på dette er alle API'er bygget over HTTP før WebSocket æraen, eller når det af en eller anden grund ikke er muligt at bruge denne teknologi. Klienten kan spørge HTTP-serveren. HTTP-serveren kan ikke selv initiere kommunikation med klienten. En simpel løsning er at anmode serveren på et timer, men det skaber en ekstra belastning på serveren og en gennemsnitlig forsinkelse på TimerInterval / 2. For at omgå dette blev tricket

while(!eventOccures && !timeoutExceeded)  {

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

Men denne løsning vil vise sig at være frygtelig, så snart antallet af klienter, der venter på en hændelse, vokser, da hver klient i ventetiden optager en hel tråd. Og vi får også en ekstra forsinkelse på 1 ms ved hændelsens udløsning. Normalt er dette ikke betydeligt, men hvorfor gøre softwaren dårligere end den kan være? Hvis vi fjerner Thread.Sleep(1), vil vi unødvendigt belaste et CPU-kerne 100% ved at køre i en ubrugelig løkke. Med TaskCompletionSource kan vi let omstrukturere denne kode og løse alle de ovennævnte problemer:

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

Denne kode er ikke production-ready, men blot en demonstration. For at bruge den i virkelige scenarier skal vi også behandle situationen, hvor en besked kommer i det øjeblik, hvor ingen venter på den: i så fald skal metoden AcceptMessageAsync returnere en allerede afsluttet Task. Hvis dette tilfælde er det mest almindelige, kan man overveje at bruge ValueTask.

Når vi modtager en beskedforespørgsel, opretter vi og placerer et TaskCompletionSource i en ordbog, hvorefter vi venter på, hvad der sker først: den angivne tidsramme udløber, eller en besked modtages.

ValueTask: hvorfor og hvordan

Async/await-operatører samt yield return operatøren genererer en tilstandsmaskine fra metoden, hvilket skaber et nyt objekt. Dette er næsten altid uvæsentligt, men i sjældne tilfælde kan det skabe problemer. Dette tilfælde kan være, når en metode kaldes meget hyppigt, op til titusinder og hundrede tusinder af kald pr. sekund. Hvis en sådan metode er skrevet på en måde, så den i de fleste tilfælde returnerer resultatet uden at bruge alle await-metoderne, tilbyder .NET et værktøj til at optimere dette — ValueTask-strukturen. For at gøre det klart, lad os overveje et eksempel: der er en cache, som vi ofte tilgår. Hvis nogle værdier er der, returnerer vi dem blot, hvis ikke, går vi langsomt i IO for dem. Det sidste vil vi gerne gøre asynkront, hvilket betyder, at hele metoden bliver asynkron. Den åbenlyse måde at skrive metoden på er følgende:

public async Task GetById(int id) {

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

På grund af ønsket om at optimere lidt og en let frygt for, hvad Roslyn genererer ved at kompilere denne kode, kan dette eksempel omskrives som følger:

public Task GetById(int id) {

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

Det mest optimale løsning i dette tilfælde ville faktisk være at optimere hot-path, nemlig at hente værdien fra ordbogen uden unødvendige allokeringer og belastning af GC. I de sjældne tilfælde, hvor vi alligevel skal gå til IO for data, forbliver alt stort set som før:

public ValueTask GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask(val);
    return new ValueTask(RequestById(id));
}

Lad os se nærmere på dette kodefragment: hvis der er en værdi i cachen, opretter vi en struktur. I modsat fald vil den faktiske task blive indkapslet i en betydelig. Den kaldende kode er ligeglad med, hvilken vej koden kørte: ValueTask opfører sig syntaktisk set ligesom en almindelig Task i dette tilfælde.

TaskScheduler: administration af strategier til opstart af Tasks

Det næste API, vi gerne vil se på, er klassen TaskScheduler og og dets afledte. Jeg nævnte tidligere, at der i TPL er mulighed for at styre strategier for fordeling af opgaver mellem tråde. Disse strategier defineres i underklasser af TaskScheduler. Næsten enhver strategi, der måtte være nødvendig, kan findes i biblioteket ParallelExtensionsExtras, udviklet af Microsoft, men som ikke er en del af .NET, og leveres som en Nuget-pakke. Lad os kort se på nogle af dem:

  • CurrentThreadTaskScheduler — udfører opgaver på den nuværende tråd
  • LimitedConcurrencyLevelTaskScheduler — begrænser antallet af samtidig udførte opgaver med parameteren N, som tages i konstruktøren
  • OrderedTaskScheduler — defineres som LimitedConcurrencyLevelTaskScheduler(1), hvilket betyder, at opgaverne udføres sekventielt.
  • WorkStealingTaskScheduler — implementerer work-stealing metoden til opgavefordeling. Det er i princippet et separat ThreadPool. Det løser problemet med, at .NET ThreadPool er en statisk klasse, som er fælles for alle applikationer, hvilket betyder, at overbelastning eller forkert brug i en del af programmet kan føre til bivirkninger i en anden. Desuden er det meget svært at forstå årsagen til sådanne fejl. Derfor kan det være nødvendigt at bruge separate WorkStealingTaskScheduler i de dele af programmet, hvor brugen af ThreadPool kan være aggressiv og uforudsigelig.
  • QueuedTaskScheduler — tillader udførelse af opgaver efter køregler med prioriteter
  • ThreadPerTaskScheduler — opretter en separat tråd for hver opgave, der udføres. Kan være nyttig for opgaver, der kører uforudsigeligt længe.

Der er en god detaljeret artikel om TaskSchedulers i Microsofts blog.

For nem fejlfinding af alt relateret til opgaver i Visual Studio er der et vindue kaldet Tasks. I dette vindue kan man se den aktuelle status for opgaven og navigere til den linje kode, der lige nu udføres.

.NET: Værktøjer til håndtering af multitrådning og asynkronitet. Del 1

PLinq og klassen Parallel

Udover Tasks og alt, hvad der er sagt om dem, er der i .NET også to interessante værktøjer, nemlig PLinq (Linq2Parallel) og klassen Parallel. Det første lover parallel udførelse af alle Linq-operationer på flere tråde. Antallet af tråde kan konfigureres med udvidelsesmetoden WithDegreeOfParallelism. Desværre er PLinq i standarddriftsmode oftest ikke i stand til at indhente tilstrækkelig information om din datakilde for at sikre en væsentlig hastighedsgevinst; på den anden side er prisen for at prøve meget lav: man skal blot kalde metoden AsParallel før kæden af Linq-metoder og udføre ydeevnetests. Desuden er der mulighed for at overføre yderligere information til PLinq om din datakilde ved hjælp af mekanismen Partitions. Yderligere oplysninger kan findes her og her.

Den statiske klasse Parallel tilbyder metoder til parallelt at gennemgå en samling med Foreach, udføre en For-løkke og køre flere delegerede i parallelt med Invoke. Udførelsen af den nuværende tråd vil blive stoppet, indtil beregningerne er afsluttet. Antallet af tråde kan konfigureres ved at overføre ParallelOptions som den sidste parameter. Med indstillingerne kan man også angive TaskScheduler og CancellationToken.

Konklusioner

Da jeg begyndte at skrive denne artikel baseret på mit oplæg og det materiale, jeg har indsamlet siden da, forventede jeg ikke, at det ville blive så meget. Nu, når tekstbehandleren, som jeg skriver denne artikel i, skamfuldt fortæller mig, at det er den 15. side, vil jeg opsummere lidt. Andre tricks, API'er, visuelle værktøjer og faldgruber vil blive behandlet i næste artikel.

Konklusioner:

  • Det er vigtigt at kende værktøjerne til at arbejde med tråde, asynkronitet og parallelisme for at udnytte ressourcerne i moderne PC'er.
  • .NET indeholder mange forskellige værktøjer til disse formål.
  • Ikke alle de er dukket op samtidigt, så man kan ofte støde på legacy; dog findes der måder at omdanne gamle API'er uden større anstrengelse.
  • Arbejdet med tråde i .NET er repræsenteret ved klasserne Thread og ThreadPool.
  • Metoderne Thread.Abort, Thread.Interrupt og Win32 API-funktionen TerminateThread er farlige og anbefales ikke til brug. I stedet er det bedre at bruge CancellationToken-mekanismen.
  • Strømme er en værdifuld ressource, og deres antal er begrænset. Det er vigtigt at undgå situationer, hvor strømme er optaget med at vente på begivenheder. For dette formål er det praktisk at bruge klassen TaskCompletionSource.
  • Det mest kraftfulde og avancerede værktøj i .NET til arbejde med parallelisme og asynkronitet er Tasks.
  • C# async/await-operatørerne implementerer konceptet for ikke-blokerende ventetid.
  • Styring af fordelingen af Tasks på tråde kan håndteres med afledte klasser af TaskScheduler.
  • Strukturen ValueTask kan være nyttig til optimering af hot-paths og hukommelsestrafik.
  • Tasks og Threads vinduerne i Visual Studio giver mange nyttige oplysninger til fejlfinding af multitrådede eller asynkrone koder.
  • PLinq er et fantastisk værktøj, men det kan mangle tilstrækkelige oplysninger om din datakilde, dog kan dette rettes ved hjælp af partitioneringsmekanismen.
  • Fortsætter…

Kilde: habr.com

Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere 🔥 Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere | ProHoster