.NET: Verktøy for arbeid med multithreading og asynkroni. Del 1

Jeg publiserer den originale artikkelen på Habr, hvis oversettelse er lagt ut i selskapet blogginnlegg.

Behovet for å gjøre noe asynkront, uten å vente på resultatet her og nå, eller å dele stort arbeid mellom flere enheter som utfører det, eksisterte før datamaskinen kom. Med deres ankomst ble dette behovet veldig håndgripelig. Nå, i 2019, skriver jeg denne artikkelen på en bærbar PC med en 8-kjerners Intel Core-prosessor, der mer enn hundre prosesser kjører parallelt, og enda flere tråder. I nærheten ligger det en litt lurvete telefon, kjøpt for et par år siden, den har en 8-kjerners prosessor ombord. Tematiske ressurser er fulle av artikler og videoer der forfatterne deres beundrer årets flaggskipsmarttelefoner som har 16-kjerners prosessorer. MS Azure tilbyr en virtuell maskin med en 20 kjerner prosessor og 128 TB RAM for mindre enn $2/time. Dessverre er det umulig å trekke ut det maksimale og utnytte denne kraften uten å kunne styre samspillet mellom tråder.

terminologi

Prosess - OS-objekt, isolert adresserom, inneholder tråder.
Tråd - et OS-objekt, den minste utførelsesenheten, en del av en prosess, tråder deler minne og andre ressurser seg imellom i en prosess.
multitasking - OS-egenskap, muligheten til å kjøre flere prosesser samtidig
Flerkjerne - en egenskap til prosessoren, muligheten til å bruke flere kjerner for databehandling
Multiprosessering - en egenskap ved en datamaskin, muligheten til å jobbe med flere prosessorer samtidig fysisk
Multithreading — en egenskap ved en prosess, evnen til å distribuere databehandling mellom flere tråder.
Parallellisme - utføre flere handlinger fysisk samtidig per tidsenhet
Asynkroni — utførelse av en operasjon uten å vente på fullføringen av denne behandlingen kan behandles senere.

metafor

Ikke alle definisjoner er gode, og noen trenger ytterligere forklaring, så jeg vil legge til en metafor om å lage frokost til den formelt introduserte terminologien. Å lage frokost i denne metaforen er en prosess.

Mens jeg tilberedte frokost om morgenen,prosessor) Jeg kommer til kjøkkenet (datamaskin). jeg har 2 hender (Kjerner). Det er en rekke enheter på kjøkkenet (IO): stekeovn, vannkoker, brødrister, kjøleskap. Jeg slår på gassen, setter en stekepanne på den og heller olje i den uten å vente på at den skal varmes opp (asynkront, Non-Blocking-IO-Wait), tar jeg eggene ut av kjøleskapet og bryter dem i en tallerken, og pisk dem med én hånd (Tråd #1), og andre (Tråd #2) holder platen (Shared Resource). Nå vil jeg gjerne slå på vannkokeren, men jeg har ikke nok hender (Tråd sult) I løpet av denne tiden varmes stekepannen opp (Behandler resultatet) som jeg heller det jeg har pisket i. Jeg strekker meg etter vannkokeren og slår den på og ser dumt på at vannet koker i den (Blokkering-IO-Vent), selv om han i løpet av denne tiden kunne ha vasket tallerkenen der han pisket omeletten.

Jeg tilberedte en omelett med bare 2 hender, og jeg har ikke flere, men samtidig, i det øyeblikket jeg pisket omeletten, skjedde det 3 operasjoner på en gang: pisking av omeletten, hold i platen, oppvarming av stekepannen CPU'en er den raskeste delen av datamaskinen, IO er det som oftest går langsommere, så ofte er en effektiv løsning å okkupere CPU'en med noe mens du mottar data fra IO.

Fortsetter metaforen:

  • Hvis jeg i ferd med å tilberede en omelett også ville prøve å bytte klær, ville dette være et eksempel på multitasking. En viktig nyanse: datamaskiner er mye bedre på dette enn mennesker.
  • Et kjøkken med flere kokker, for eksempel i en restaurant - en multi-core datamaskin.
  • Mange restauranter i en food court i et kjøpesenter - datasenter

.NET-verktøy

.NET er flink til å jobbe med tråder, som med mange andre ting. Med hver nye versjon introduserer den flere og flere nye verktøy for å jobbe med dem, nye lag av abstraksjon over OS-tråder. Når man jobber med konstruksjon av abstraksjoner, bruker rammeutviklere en tilnærming som gir muligheten til, ved bruk av høynivåabstraksjoner, å gå ned ett eller flere nivåer under. Oftest er dette ikke nødvendig, faktisk åpner det døren for å skyte deg selv i foten med en hagle, men noen ganger, i sjeldne tilfeller, kan det være den eneste måten å løse et problem som ikke er løst på det nåværende abstraksjonsnivået .

Med verktøy mener jeg både applikasjonsprogrammeringsgrensesnitt (API) levert av rammeverket og tredjepartspakker, samt hele programvareløsninger som forenkler søket etter eventuelle problemer knyttet til flertrådskode.

Starter en tråd

Thread-klassen er den mest grunnleggende klassen i .NET for å jobbe med tråder. Konstruktøren godtar en av to delegater:

  • ThreadStart — Ingen parametere
  • ParametrizedThreadStart - med én parameter av typen objekt.

Delegaten vil bli utført i den nyopprettede tråden etter å ha kalt Start-metoden. Hvis en delegat av typen ParametrizedThreadStart ble sendt til konstruktøren, må et objekt sendes til Start-metoden. Denne mekanismen er nødvendig for å overføre lokal informasjon til strømmen. Det er verdt å merke seg at å lage en tråd er en kostbar operasjon, og selve tråden er et tungt objekt, i det minste fordi den tildeler 1 MB minne på stabelen og krever interaksjon med OS API.

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

ThreadPool-klassen representerer konseptet med et basseng. I .NET er trådpoolen et stykke ingeniørkunst, og utviklerne hos Microsoft har lagt mye arbeid i å sørge for at den fungerer optimalt i en lang rekke scenarier.

Generelt konsept:

Fra det øyeblikket applikasjonen starter, lager den flere tråder i reserve i bakgrunnen og gir muligheten til å ta dem til bruk. Hvis tråder brukes ofte og i stort antall, utvides bassenget for å møte oppringerens behov. Når det ikke er ledige tråder i bassenget til rett tid, vil den enten vente på at en av trådene kommer tilbake, eller opprette en ny. Det følger at trådpoolen er flott for noen kortsiktige handlinger og dårlig egnet for operasjoner som kjører som tjenester gjennom hele driften av applikasjonen.

For å bruke en tråd fra bassenget, er det en QueueUserWorkItem-metode som godtar en delegat av typen WaitCallback, som har samme signatur som ParametrizedThreadStart, og parameteren som sendes til den utfører samme funksjon.

ThreadPool.QueueUserWorkItem(...);

Den mindre kjente trådpoolmetoden RegisterWaitForSingleObject brukes til å organisere ikke-blokkerende IO-operasjoner. Delegaten som sendes til denne metoden vil bli kalt opp når WaitHandle som sendes til metoden er "frigitt".

ThreadPool.RegisterWaitForSingleObject(...)

.NET har en trådtimer, og den skiller seg fra WinForms/WPF-timere ved at dens behandler vil bli kalt på en tråd hentet fra bassenget.

System.Threading.Timer

Det er også en ganske eksotisk måte å sende en delegat for utførelse til en tråd fra bassenget - BeginInvoke-metoden.

DelegateInstance.BeginInvoke

Jeg vil kort dvele ved funksjonen som mange av metodene ovenfor kan kalles - CreateThread fra Kernel32.dll Win32 API. Det er en måte, takket være mekanismen til eksterne metoder, å kalle denne funksjonen. Jeg har sett en slik samtale bare én gang i et forferdelig eksempel på eldre kode, og motivasjonen til forfatteren som gjorde akkurat dette er fortsatt et mysterium for meg.

Kernel32.dll CreateThread

Vise og feilsøke tråder

Tråder opprettet av deg, alle tredjepartskomponenter og .NET-poolen kan sees i Threads-vinduet i Visual Studio. Dette vinduet vil bare vise trådinformasjon når programmet er under feilsøking og i pausemodus. Her kan du enkelt se stabelnavnene og prioriteringene til hver tråd, og bytte feilsøking til en bestemt tråd. Ved å bruke Priority-egenskapen til Thread-klassen kan du angi prioriteten til en tråd, som OC og CLR vil oppfatte som en anbefaling når du deler prosessortid mellom tråder.

.NET: Verktøy for arbeid med multithreading og asynkroni. Del 1

Task Parallell Library

Task Parallel Library (TPL) ble introdusert i .NET 4.0. Nå er det standarden og hovedverktøyet for å jobbe med asynkroni. Enhver kode som bruker en eldre tilnærming regnes som arv. Grunnenheten til TPL er Task-klassen fra System.Threading.Tasks-navneområdet. En oppgave er en abstraksjon over en tråd. Med den nye versjonen av C#-språket fikk vi en elegant måte å jobbe med Tasks på – async/wait-operatorer. Disse konseptene gjorde det mulig å skrive asynkron kode som om den var enkel og synkron, dette gjorde det mulig selv for personer med liten forståelse av den interne virkemåten til tråder å skrive applikasjoner som bruker dem, applikasjoner som ikke fryser når de utfører lange operasjoner. Å bruke async/wait er et emne for én eller til og med flere artikler, men jeg skal prøve å få essensen av det i noen få setninger:

  • async er en modifikator av en metode som returnerer Task eller void
  • og await er en ikke-blokkerende Task waiting-operatør.

Nok en gang: vent-operatøren, i det generelle tilfellet (det finnes unntak), vil frigi den nåværende utførelsestråden videre, og når oppgaven er ferdig, og tråden (faktisk ville det være mer korrekt å si konteksten , men mer om det senere) vil fortsette å utføre metoden videre. Inne i .NET er denne mekanismen implementert på samme måte som yield return, når den skriftlige metoden blir til en hel klasse, som er en tilstandsmaskin og kan kjøres i separate deler avhengig av disse tilstandene. Alle som er interessert kan skrive hvilken som helst enkel kode ved å bruke asynс/avvent, kompilere og se sammenstillingen ved å bruke JetBrains dotPeek med kompilatorgenerert kode aktivert.

La oss se på alternativer for å starte og bruke Task. I kodeeksemplet nedenfor lager vi en ny oppgave som ikke gjør noe nyttig (Thread.Sleep(10000)), men i det virkelige liv bør dette være noe komplekst CPU-intensivt arbeid.

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
}

En oppgave opprettes med en rekke alternativer:

  • LongRunning er et hint om at oppgaven ikke blir fullført raskt, noe som betyr at det kan være verdt å vurdere å ikke ta en tråd fra bassenget, men lage en egen for denne oppgaven for ikke å skade andre.
  • AttachedToParent - Oppgaver kan ordnes i et hierarki. Hvis dette alternativet ble brukt, kan oppgaven være i en tilstand der den selv er fullført og venter på henrettelsen av barna.
  • PreferFairness - betyr at det vil være bedre å utføre oppgaver som sendes til utførelse tidligere før de som sendes senere. Men dette er bare en anbefaling og resultater er ikke garantert.

Den andre parameteren som sendes til metoden er CancellationToken. For å håndtere kansellering av en operasjon på riktig måte etter at den har startet, må koden som utføres fylles med sjekker for CancellationToken-tilstanden. Hvis det ikke er noen kontroller, vil Avbryt-metoden kalt CancellationTokenSource-objektet kunne stoppe utførelsen av oppgaven bare før den starter.

Den siste parameteren er et planleggerobjekt av typen TaskScheduler. Denne klassen og dens etterkommere er designet for å kontrollere strategier for fordeling av oppgaver på tvers av tråder som standard, oppgaven vil bli utført på en tilfeldig tråd fra bassenget.

Vent-operatoren brukes på den opprettede oppgaven, som betyr at koden skrevet etter den, hvis det er en, vil bli utført i samme kontekst (ofte betyr dette på samme tråd) som koden før avvent.

Metoden er merket som asynkron void, noe som betyr at den kan bruke vent-operatøren, men anropskoden vil ikke kunne vente på utførelse. Hvis en slik funksjon er nødvendig, må metoden returnere Task. Metoder merket med async void er ganske vanlige: som regel er dette hendelsesbehandlere eller andre metoder som fungerer etter brann og glem-prinsippet. Hvis du ikke bare trenger å gi muligheten til å vente til slutten av utførelsen, men også returnere resultatet, må du bruke Task.

På oppgaven som StartNew-metoden returnerte, så vel som på en hvilken som helst annen, kan du kalle ConfigureAwait-metoden med den falske parameteren, så vil utførelse etter await fortsette ikke på den fangede konteksten, men på en vilkårlig. Dette bør alltid gjøres når utførelseskonteksten ikke er viktig for koden etter venting. Dette er også en anbefaling fra MS ved skriving av kode som skal leveres pakket i et bibliotek.

La oss dvele litt mer på hvordan du kan vente på fullføringen av en oppgave. Nedenfor er et eksempel på kode, med kommentarer om når forventningen gjøres betinget godt og når det gjøres betinget dårlig.

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
}

I det første eksemplet venter vi på at oppgaven skal fullføres uten å blokkere den anropende tråden, vi vil bare gå tilbake til å behandle resultatet når den allerede er der.

I det andre alternativet blokkerer vi anropstråden til resultatet av metoden er beregnet. Dette er dårlig, ikke bare fordi vi har okkupert en tråd, en så verdifull ressurs i programmet, med enkel tomgang, men også fordi hvis koden til metoden som vi kaller inneholder venter, og synkroniseringskonteksten krever at vi går tilbake til den kallende tråden etter vent, da vil vi få en vranglås: Den kallende tråden venter mens resultatet av den asynkrone metoden beregnes, den asynkrone metoden prøver forgjeves å fortsette sin utførelse i den anropende tråden.

En annen ulempe med denne tilnærmingen er komplisert feilhåndtering. Faktum er at feil i asynkron kode ved bruk av async/wait er veldig enkle å håndtere - de oppfører seg på samme måte som om koden var synkron. Mens hvis vi bruker synkron venteeksorsisme på en oppgave, blir det opprinnelige unntaket til et AggregateException, dvs. For å håndtere unntaket, må du undersøke InnerException-typen og skrive en if-kjede selv inne i en catch-blokk eller bruke catchen når du konstruerer, i stedet for kjeden av catch-blokker som er mer kjent i C#-verdenen.

Det tredje og siste eksemplet er også merket som dårlig av samme grunn og inneholder alle de samme problemene.

WhenAny- og WhenAll-metodene er ekstremt praktiske for å vente på en gruppe oppgaver, de samler en gruppe oppgaver inn i én, som utløses enten når en oppgave fra gruppen først utløses, eller når alle har fullført utførelsen.

Stopper tråder

Av ulike årsaker kan det være nødvendig å stoppe strømmen etter at den har startet. Det finnes en rekke måter å gjøre dette på. Thread-klassen har to passende navngitte metoder: Abort и Avbryte. Den første anbefales sterkt ikke for bruk, fordi etter å ha ringt den til et hvilket som helst tilfeldig øyeblikk, under behandlingen av en instruksjon, vil et unntak bli kastet ThreadAbortedException. Du forventer ikke at et slikt unntak blir kastet når du øker en heltallsvariabel, ikke sant? Og når du bruker denne metoden, er dette en veldig reell situasjon. Hvis du trenger å forhindre CLR fra å generere et slikt unntak i en bestemt del av koden, kan du pakke det inn i samtaler Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Enhver kode skrevet i en endelig blokk er pakket inn i slike samtaler. Av denne grunn kan du i dybden av rammekoden finne blokker med et tomt forsøk, men ikke et tomt til slutt. Microsoft fraråder denne metoden så mye at de ikke inkluderte den i .net-kjerne.

Avbruddsmetoden fungerer mer forutsigbart. Det kan avbryte tråden med et unntak Trådavbrutt unntak bare i de øyeblikkene når tråden er i ventetilstand. Den går inn i denne tilstanden mens den henger mens den venter på WaitHandle, lås eller etter å ha ringt Thread.Sleep.

Begge alternativene beskrevet ovenfor er dårlige på grunn av deres uforutsigbarhet. Løsningen er å bruke en struktur Cancellation Token og klasse CancellationTokenSource. Poenget er dette: en forekomst av klassen CancellationTokenSource opprettes og bare den som eier den kan stoppe operasjonen ved å kalle metoden Kansellere. Bare CancellationToken sendes til selve operasjonen. CancellationToken-eiere kan ikke avbryte operasjonen selv, men kan kun sjekke om operasjonen er avbrutt. Det er en boolsk egenskap for dette IsCancellationRequested og metode ThrowIfCancelRequested. Sistnevnte vil gi et unntak TaskCancelledException hvis Cancel-metoden ble kalt på CancellationToken-forekomsten som ble parrotert. Og dette er metoden jeg anbefaler å bruke. Dette er en forbedring i forhold til de tidligere alternativene ved å få full kontroll over på hvilket tidspunkt en unntaksoperasjon kan avbrytes.

Det mest brutale alternativet for å stoppe en tråd er å kalle opp Win32 API TerminateThread-funksjonen. Oppførselen til CLR etter å ha kalt denne funksjonen kan være uforutsigbar. På MSDN er følgende skrevet om denne funksjonen: "TerminateThread er en farlig funksjon som bare bør brukes i de mest ekstreme tilfeller. "

Konvertering av eldre API til Task Based ved hjelp av FromAsync-metoden

Hvis du er heldig nok til å jobbe med et prosjekt som ble startet etter at Tasks ble introdusert og sluttet å forårsake stille skrekk for de fleste utviklere, så slipper du å forholde deg til mange gamle APIer, både tredjeparts og de teamet ditt har torturert tidligere. Heldigvis tok .NET Framework-teamet seg av oss, selv om målet kanskje var å ta vare på oss selv. Uansett så har .NET en rekke verktøy for smertefritt å konvertere kode skrevet i gamle asynkrone programmeringsmetoder til den nye. En av dem er FromAsync-metoden til TaskFactory. I kodeeksemplet nedenfor pakker jeg inn de gamle asynkroniseringsmetodene til WebRequest-klassen i en oppgave ved å bruke denne metoden.

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

Dette er bare et eksempel, og det er usannsynlig at du trenger å gjøre dette med innebygde typer, men ethvert gammelt prosjekt vrimler rett og slett av BeginDoSomething-metoder som returnerer IAsyncResult og EndDoSomething-metoder som mottar det.

Konverter eldre API til Task Based ved å bruke TaskCompletionSource-klassen

Et annet viktig verktøy å vurdere er klassen TaskCompletionSource. Når det gjelder funksjoner, formål og operasjonsprinsipp, kan det minne litt om RegisterWaitForSingleObject-metoden til ThreadPool-klassen, som jeg skrev om ovenfor. Ved å bruke denne klassen kan du enkelt og praktisk pakke inn gamle asynkrone API-er i Tasks.

Du vil si at jeg allerede har snakket om FromAsync-metoden til TaskFactory-klassen beregnet for disse formålene. Her må vi huske hele historien om utviklingen av asynkrone modeller i .net som Microsoft har tilbudt de siste 15 årene: før Task-Based Asynchronous Pattern (TAP), var det Asynchronous Programming Pattern (APP), som handlet om metoder BegynnGjør noe kommer tilbake IAsyncResult og metoder SluttDoSomething som aksepterer det, og for arven fra disse årene er FromAsync-metoden perfekt, men over tid ble den erstattet av Event Based Asynchronous Pattern (EAP), som antok at en hendelse ville oppstå når den asynkrone operasjonen var fullført.

TaskCompletionSource er perfekt for å pakke inn oppgaver og eldre APIer bygget rundt hendelsesmodellen. Essensen av arbeidet er som følger: et objekt av denne klassen har en offentlig egenskap av typen Task, hvis tilstand kan kontrolleres gjennom metodene SetResult, SetException, etc. i TaskCompletionSource-klassen. På steder der vent-operatøren ble brukt på denne oppgaven, vil den bli utført eller mislykkes med et unntak, avhengig av metoden som ble brukt på TaskCompletionSource. Hvis det fortsatt ikke er klart, la oss se på dette kodeeksemplet, der et gammelt EAP API er pakket inn i en oppgave ved hjelp av en TaskCompletionSource: når hendelsen utløses, vil oppgaven bli plassert i Fullført-tilstanden, og metoden som brukte await-operatoren til denne oppgaven vil gjenoppta utførelsen etter å ha mottatt objektet resultere.

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

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

    result completionSource.Task;
}

TaskCompletionSource tips og triks

Å pakke inn gamle APIer er ikke alt som kan gjøres med TaskCompletionSource. Å bruke denne klassen åpner for en interessant mulighet for å designe ulike APIer på oppgaver som ikke opptar tråder. Og strømmen, som vi husker, er en dyr ressurs, og antallet er begrenset (hovedsakelig av mengden RAM). Denne begrensningen kan enkelt oppnås ved å utvikle for eksempel en lastet webapplikasjon med kompleks forretningslogikk. La oss vurdere mulighetene jeg snakker om når vi implementerer et slikt triks som Long-Polling.

Kort sagt, essensen av trikset er dette: du må motta informasjon fra API om noen hendelser som skjer på dens side, mens API av en eller annen grunn ikke kan rapportere hendelsen, men bare returnere tilstanden. Et eksempel på disse er alle API-er bygget på toppen av HTTP før WebSockets tider eller da det av en eller annen grunn var umulig å bruke denne teknologien. Klienten kan spørre HTTP-serveren. HTTP-serveren kan ikke selv starte kommunikasjon med klienten. En enkel løsning er å polle serveren ved hjelp av en timer, men dette skaper en ekstra belastning på serveren og en ekstra forsinkelse i gjennomsnitt TimerInterval / 2. For å komme rundt dette ble det oppfunnet et triks som heter Long Polling, som går ut på å forsinke responsen fra serveren til tidsavbruddet utløper eller en hendelse vil oppstå. Hvis hendelsen har skjedd, behandles den, hvis ikke, sendes forespørselen på nytt.

while(!eventOccures && !timeoutExceeded)  {

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

Men en slik løsning vil vise seg å være forferdelig så snart antallet kunder som venter på arrangementet øker, fordi... Hver slik klient opptar en hel tråd som venter på en hendelse. Ja, og vi får ytterligere 1 ms forsinkelse når hendelsen utløses, oftest er dette ikke signifikant, men hvorfor gjøre programvaren verre enn den kan være? Hvis vi fjerner Thread.Sleep(1), vil vi forgjeves laste én prosessorkjerne 100% inaktiv, roterende i en ubrukelig syklus. Ved å bruke TaskCompletionSource kan du enkelt lage denne koden på nytt og løse alle problemene identifisert ovenfor:

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

Denne koden er ikke produksjonsklar, men bare en demo. For å bruke det i virkelige tilfeller, må du også, som et minimum, håndtere situasjonen når en melding kommer på et tidspunkt da ingen forventer det: i dette tilfellet skal AsseptMessageAsync-metoden returnere en allerede fullført oppgave. Hvis dette er det vanligste tilfellet, kan du tenke på å bruke ValueTask.

Når vi mottar en forespørsel om en melding, oppretter og plasserer vi en TaskCompletionSource i ordboken, og venter deretter på hva som skjer først: det angitte tidsintervallet utløper eller en melding mottas.

Verdioppgave: hvorfor og hvordan

Async/await-operatørene, som yield return-operatøren, genererer en tilstandsmaskin fra metoden, og dette er opprettelsen av et nytt objekt, som nesten alltid ikke er viktig, men i sjeldne tilfeller kan det skape et problem. Denne saken kan være en metode som kalles veldig ofte, vi snakker om titalls og hundretusener av samtaler per sekund. Hvis en slik metode er skrevet på en slik måte at den i de fleste tilfeller returnerer et resultat som går utenom alle await-metoder, så gir .NET et verktøy for å optimalisere dette - ValueTask-strukturen. For å gjøre det klart, la oss se på et eksempel på bruken: det er en cache som vi går til veldig ofte. Det er noen verdier i den, og så returnerer vi dem ganske enkelt; Jeg vil gjøre det siste asynkront, noe som betyr at hele metoden viser seg å være asynkron. Dermed er den åpenbare måten å skrive metoden på som følger:

public async Task<string> GetById(int id) {

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

På grunn av ønsket om å optimalisere litt, og en liten frykt for hva Roslyn vil generere når du kompilerer denne koden, kan du omskrive dette eksemplet som følger:

public Task<string> GetById(int id) {

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

Faktisk vil den optimale løsningen i dette tilfellet være å optimalisere hot-banen, nemlig å få en verdi fra ordboken uten unødvendige tildelinger og belastning på GC, mens vi i de sjeldne tilfellene fortsatt trenger å gå til IO for data , vil alt forbli et pluss /minus på den gamle måten:

public ValueTask<string> GetById(int id) {

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

La oss se nærmere på denne kodebiten: hvis det er en verdi i cachen, lager vi en struktur, ellers vil den virkelige oppgaven bli pakket inn i en meningsfull. Den kallende koden bryr seg ikke om hvilken bane denne koden ble utført i: ValueTask, fra et C#-syntakssynspunkt, vil oppføre seg på samme måte som en vanlig oppgave i dette tilfellet.

TaskSchedulers: administrere oppgavestartstrategier

Den neste API-en jeg vil vurdere er klassen Oppgaveplanlegger og dens derivater. Jeg har allerede nevnt ovenfor at TPL har muligheten til å administrere strategier for å distribuere oppgaver på tvers av tråder. Slike strategier er definert i etterkommerne av TaskScheduler-klassen. Nesten hvilken som helst strategi du måtte trenge finner du i biblioteket. Parallelle utvidelser Ekstra, utviklet av Microsoft, men ikke en del av .NET, men levert som en Nuget-pakke. La oss kort se på noen av dem:

  • CurrentThreadTaskScheduler — utfører oppgaver på gjeldende tråd
  • LimitedConcurrencyLevelTask ​​Scheduler — begrenser antall oppgaver som utføres samtidig av parameter N, som er akseptert i konstruktøren
  • Bestilt TaskScheduler — er definert som LimitedConcurrencyLevelTaskScheduler(1), så oppgaver vil bli utført sekvensielt.
  • WorkStealingTaskScheduler - redskaper arbeid-tyveri tilnærming til oppgavefordeling. I hovedsak er det en egen ThreadPool. Løser problemet med at i .NET ThreadPool er en statisk klasse, en for alle applikasjoner, noe som betyr at overbelastning eller feil bruk i en del av programmet kan føre til bivirkninger i en annen. Dessuten er det ekstremt vanskelig å forstå årsaken til slike feil. At. Det kan være behov for å bruke separate WorkStealingTaskSchedulers i deler av programmet der bruken av ThreadPool kan være aggressiv og uforutsigbar.
  • QueuedTaskScheduler — lar deg utføre oppgaver i henhold til prioriterte køregler
  • ThreadPerTaskScheduler — oppretter en egen tråd for hver oppgave som utføres på den. Kan være nyttig for oppgaver som tar uforutsigbart lang tid å fullføre.

Det er en god detaljert artikkel om TaskSchedulers på microsoft-bloggen.

For praktisk feilsøking av alt relatert til Tasks, har Visual Studio et Tasks-vindu. I dette vinduet kan du se gjeldende status for oppgaven og hoppe til kodelinjen som utføres for øyeblikket.

.NET: Verktøy for arbeid med multithreading og asynkroni. Del 1

PLinq og Parallell-klassen

I tillegg til Tasks og alt som er sagt om dem, er det to flere interessante verktøy i .NET: PLinq (Linq2Parallel) og Parallel-klassen. Den første lover parallell utførelse av alle Linq-operasjoner på flere tråder. Antall tråder kan konfigureres ved å bruke WithDegreeOfParallelism-utvidelsesmetoden. Dessverre er det oftest at PLinq i standardmodusen ikke har nok informasjon om det indre av datakilden din til å gi en betydelig hastighetsøkning, på den annen side er kostnadene ved å prøve svært lave: du trenger bare å ringe AsParallel-metoden før kjeden av Linq-metoder og kjøre ytelsestester. Dessuten er det mulig å sende tilleggsinformasjon til PLinq om arten av datakilden din ved å bruke partisjonsmekanismen. Du kan lese mer her и her.

Parallell static-klassen gir metoder for å iterere gjennom en Foreach-samling parallelt, utføre en For-løkke og utføre flere delegater i parallell Invoke. Utførelse av gjeldende tråd vil bli stoppet inntil beregningene er fullført. Antall tråder kan konfigureres ved å sende ParallelOptions som siste argument. Du kan også spesifisere TaskScheduler og CancellationToken ved å bruke alternativer.

Funn

Da jeg begynte å skrive denne artikkelen basert på materialet i rapporten min og informasjonen jeg samlet inn under arbeidet mitt etter den, forventet jeg ikke at det skulle være så mye av det. Nå, når tekstredigereren som jeg skriver denne artikkelen i bebreidende forteller meg at side 15 er borte, vil jeg oppsummere de foreløpige resultatene. Andre triks, APIer, visuelle verktøy og fallgruver vil bli dekket i neste artikkel.

Konklusjoner:

  • Du må kjenne til verktøyene for å jobbe med tråder, asynkroni og parallellitet for å kunne bruke ressursene til moderne PC-er.
  • .NET har mange forskjellige verktøy for disse formålene
  • Ikke alle dukket opp på en gang, så du kan ofte finne eldre, men det er måter å konvertere gamle APIer uten mye innsats.
  • Arbeid med tråder i .NET er representert av Thread- og ThreadPool-klassene
  • Metodene Thread.Abort, Thread.Interrupt og Win32 API TerminateThread er farlige og anbefales ikke for bruk. I stedet er det bedre å bruke CancellationToken-mekanismen
  • Flow er en verdifull ressurs og tilgangen er begrenset. Situasjoner der tråder er opptatt med å vente på hendelser bør unngås. For dette er det praktisk å bruke TaskCompletionSource-klassen
  • De kraftigste og mest avanserte .NET-verktøyene for å jobbe med parallellitet og asynkroni er Tasks.
  • C# async/wait-operatørene implementerer konseptet med ikke-blokkerende ventetid
  • Du kan kontrollere fordelingen av oppgaver på tvers av tråder ved å bruke TaskScheduler-avledede klasser
  • ValueTask-strukturen kan være nyttig for å optimalisere hot-paths og minnetrafikk
  • Visual Studios oppgaver og tråder-vinduer gir mye informasjon nyttig for feilsøking av flertråds eller asynkron kode
  • PLinq er et kult verktøy, men det har kanskje ikke nok informasjon om datakilden din, men dette kan fikses ved hjelp av partisjoneringsmekanismen
  • To be continued ...

Kilde: www.habr.com

Legg til en kommentar