.NET: Værktøjer til at arbejde med multithreading og asynkroni. Del 1

Jeg udgiver den originale artikel om Habr, hvis oversættelse er offentliggjort i virksomheden blogindlæg.

Behovet for 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, eksisterede før computernes fremkomst. Med deres fremkomst blev dette behov meget håndgribeligt. Nu, i 2019, skriver jeg denne artikel på en bærbar computer med en 8-core Intel Core-processor, hvor mere end hundrede processer kører parallelt, og endnu flere tråde. I nærheden er der en lidt lurvet telefon, købt for et par år siden, den har en 8-core processor ombord. Tematiske ressourcer er fulde af artikler og videoer, hvor deres forfattere beundrer dette års flagskibssmartphones, der har 16-core processorer. MS Azure leverer en virtuel maskine med en processor med 20 kerner og 128 TB RAM for mindre end $2/time. Desværre er det umuligt at udtrække det maksimale og udnytte denne kraft uden at være i stand til at styre samspillet mellem tråde.

terminologi

Behandle - OS-objekt, isoleret adresserum, indeholder tråde.
Tråd - et OS-objekt, den mindste udførelsesenhed, en del af en proces, tråde deler hukommelse og andre ressourcer indbyrdes i en proces.
multitasking - OS-egenskab, evnen til at køre flere processer samtidigt
Multi-core - en egenskab hos processoren, evnen til at bruge flere kerner til databehandling
Multibearbejdning - en egenskab ved en computer, evnen til at arbejde med flere processorer samtidigt fysisk
Multithreading — en egenskab ved en proces, evnen til at fordele databehandling mellem flere tråde.
Parallelisme - at udføre flere handlinger fysisk samtidigt pr. tidsenhed
Asynkroni — udførelse af en operation uden at vente på afslutningen af ​​denne behandling; resultatet af udførelsen kan behandles senere.

Metafor

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

Mens jeg tilberedte morgenmad om morgenen,CPU) jeg kommer i køkkenet (Computer). jeg har 2 hænder (kerner). Der er en række enheder i køkkenet (IO): ovn, kedel, brødrister, køleskab. Jeg tænder for gassen, sætter en stegepande på den og hælder olie i den uden at vente på, at den bliver varm (asynkront, Non-Blocking-IO-Wait), tager jeg æggene ud af køleskabet og brækker dem i en tallerken, og pisk dem derefter med den ene hånd (Tråd #1), og andet (Tråd #2) holder pladen (Shared Resource). Nu vil jeg gerne tænde for kedlen, men jeg har ikke hænder nok (Tråd sult) I løbet af denne tid varmer stegepanden op (Bearbejder resultatet) hvori jeg hælder det jeg har pisket. Jeg rækker ud efter kedlen og tænder for den og ser dumt vandet koge i den (Blokering-IO-Vent), selvom han i løbet af denne tid kunne have vasket tallerkenen, hvor han piskede omeletten.

Jeg tilberedte en æggekage med kun 2 hænder, og jeg har ikke flere, men samtidig, i det øjeblik, hvor æggekagen blev pisket, foregik der 3 operationer på én gang: Piskning af æggekagen, hold på pladen, opvarmning af stegepanden CPU'en er den hurtigste del af computeren, IO er det, der oftest er, alt bremser, så ofte er en effektiv løsning at besætte CPU'en med noget, mens du modtager data fra IO.

Fortsætter metaforen:

  • Hvis jeg i processen med at forberede en omelet også ville prøve at skifte tøj, ville dette være et eksempel på multitasking. En vigtig nuance: computere er meget bedre til dette end mennesker.
  • Et køkken med flere kokke, for eksempel i en restaurant - en multi-core computer.
  • Mange restauranter i en food court i et indkøbscenter - datacenter

.NET Værktøjer

.NET er god til at arbejde med tråde, som med mange andre ting. Med hver ny version introducerer den flere og flere nye værktøjer til at arbejde med dem, nye lag af abstraktion over OS-tråde. Når man arbejder med konstruktion af abstraktioner, anvender rammeudviklere en tilgang, der giver mulighed for, når man bruger en abstraktion på højt niveau, at gå ned et eller flere niveauer under. Oftest er dette ikke nødvendigt, faktisk åbner det døren til at skyde dig selv i foden med et haglgevær, men nogle gange, i sjældne tilfælde, kan det være den eneste måde at løse et problem, der ikke er løst på det nuværende abstraktionsniveau .

Med værktøjer mener jeg både applikationsprogrammeringsgrænseflader (API'er), der leveres af rammeværket og tredjepartspakker, såvel som hele softwareløsninger, der forenkler søgningen efter eventuelle problemer relateret til flertrådskode.

Starter en tråd

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

  • Trådstart — Ingen parametre
  • ParametrizedThreadStart - med én parameter af typen objekt.

Delegaten vil blive eksekveret i den nyoprettede tråd efter at have kaldt Start-metoden Hvis en delegat af typen ParametrizedThreadStart blev videregivet til konstruktøren, skal et objekt videregives til Start-metoden. Denne mekanisme er nødvendig for at overføre lokal information til strømmen. 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 den allokerer 1 MB hukommelse på stakken og kræver interaktion med OS API.

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

ThreadPool-klassen repræsenterer konceptet med en pool. I .NET er trådpuljen et stykke ingeniørkunst, og udviklerne hos Microsoft har lagt mange kræfter i at sikre, at den fungerer optimalt i en lang række scenarier.

Generelt koncept:

Fra det øjeblik applikationen starter, opretter den flere tråde i reserve i baggrunden og giver mulighed for at tage dem til brug. Hvis tråde bruges ofte og i stort antal, udvides puljen for at imødekomme den, der ringer op. Når der ikke er ledige tråde i poolen på det rigtige tidspunkt, vil den enten vente på, at en af ​​trådene vender tilbage, eller oprette en ny. Det følger heraf, at trådpuljen er fantastisk til nogle kortsigtede handlinger og dårligt egnet til operationer, der kører som tjenester gennem hele applikationens drift.

For at bruge en tråd fra puljen er der en QueueUserWorkItem-metode, der accepterer en delegeret af typen WaitCallback, som har samme signatur som ParametrizedThreadStart, og parameteren, der sendes til den, udfører den samme funktion.

ThreadPool.QueueUserWorkItem(...);

Den mindre kendte trådpuljemetode RegisterWaitForSingleObject bruges til at organisere ikke-blokerende IO-operationer. Den delegerede, der er overført til denne metode, vil blive kaldt, når det WaitHandle, der sendes til metoden, er "Frigivet".

ThreadPool.RegisterWaitForSingleObject(...)

.NET har en tråd-timer, og den adskiller sig fra WinForms/WPF-timere ved, at dens handler vil blive kaldt på en tråd taget fra poolen.

System.Threading.Timer

Der er også en ret eksotisk måde at sende en delegeret til eksekvering til en tråd fra puljen - BeginInvoke-metoden.

DelegateInstance.BeginInvoke

Jeg vil gerne kort dvæle ved den funktion, som mange af ovenstående metoder kan kaldes til - CreateThread fra Kernel32.dll Win32 API. Der er en måde, takket være mekanismen for eksterne metoder, at kalde denne funktion. Jeg har kun set et sådant opkald én gang i et forfærdeligt eksempel på arv kode, og motivationen for forfatteren, der gjorde præcis dette, er stadig et mysterium for mig.

Kernel32.dll CreateThread

Visning og fejlretning af tråde

Tråde oprettet af dig, alle tredjepartskomponenter og .NET-puljen kan ses i vinduet Threads i Visual Studio. Dette vindue viser kun trådinformation, når applikationen er under fejlretning og i pausetilstand. Her kan du bekvemt se staknavnene og prioriteterne for hver tråd og skifte fejlfinding til en bestemt tråd. Ved at bruge egenskaben Priority i Thread-klassen kan du indstille prioriteten for en tråd, som OC og CLR vil opfatte som en anbefaling, når processortiden skal divideres mellem trådene.

.NET: Værktøjer til at arbejde med multithreading og asynkroni. Del 1

Opgave parallelbibliotek

Task Parallel Library (TPL) blev introduceret i .NET 4.0. Nu er det standarden og hovedværktøjet til at arbejde med asynkroni. Enhver kode, der bruger en ældre tilgang, betragtes som arv. Den grundlæggende enhed i TPL er Task-klassen fra System.Threading.Tasks-navneområdet. En opgave er en abstraktion over en tråd. Med den nye version af C#-sproget fik vi en elegant måde at arbejde med Tasks på - async/wait-operatorer. Disse begreber gjorde det muligt at skrive asynkron kode, som om det var simpelt og synkront, dette gjorde det muligt selv for folk med ringe forståelse for den interne funktion af tråde at skrive applikationer, der bruger dem, applikationer, der ikke fryser, når de udfører lange operationer. Brug af async/await er et emne for en eller endda flere artikler, men jeg vil prøve at få essensen af ​​det i et par sætninger:

  • async er en modifikator af en metode, der returnerer Task eller void
  • og await er en ikke-blokerende opgave venter operatør.

Endnu en gang: Vent-operatøren vil i det generelle tilfælde (der er undtagelser) frigive den aktuelle udførelsestråd yderligere, og når opgaven afslutter sin udførelse, og tråden (faktisk ville det være mere korrekt at sige konteksten , men mere om det senere) vil fortsætte med at udføre metoden yderligere. Inde i .NET er denne mekanisme implementeret på samme måde som yield return, når den skrevne metode bliver til en hel klasse, som er en tilstandsmaskine og kan udføres i separate stykker afhængig af disse tilstande. Enhver, der er interesseret, kan skrive enhver simpel kode ved hjælp af asynс/await, kompilere og se samlingen ved hjælp af JetBrains dotPeek med Compiler Generated Code aktiveret.

Lad os se på mulighederne for at starte og bruge Task. I kodeeksemplet nedenfor opretter vi en ny opgave, der ikke gør noget nyttigt (Tråd.Søvn(10000)), men i det virkelige liv burde dette være noget komplekst CPU-intensivt arbejde.

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 opgave oprettes med en række muligheder:

  • LongRunning er et hint om, at opgaven ikke bliver løst hurtigt, hvilket betyder, at det kan være værd at overveje ikke at tage en tråd fra puljen, men oprette en separat til denne opgave for ikke at skade andre.
  • AttachedToParent - Opgaver kan arrangeres i et hierarki. Hvis denne mulighed blev brugt, kan opgaven være i en tilstand, hvor den selv er fuldført og venter på udførelsen af ​​sine børn.
  • PreferFairness - betyder, at det ville være bedre at udføre Opgaver sendt til udførelse tidligere før dem, der sendes senere. Men dette er kun en anbefaling, og resultater er ikke garanteret.

Den anden parameter, der sendes til metoden, er CancellationToken. For korrekt at håndtere annullering af en operation, efter den er startet, skal koden, der udføres, udfyldes med checks for CancellationToken-tilstanden. Hvis der ikke er nogen kontrol, vil Annulleringsmetoden, der kaldes på CancellationTokenSource-objektet, kun være i stand til at stoppe udførelsen af ​​opgaven, før den starter.

Den sidste parameter er et planlægningsobjekt af typen TaskScheduler. Denne klasse og dens efterkommere er designet til at styre strategier til fordeling af opgaver på tværs af tråde; som standard vil opgaven blive udført på en tilfældig tråd fra puljen.

Vent-operatoren anvendes på den oprettede opgave, hvilket betyder, at koden skrevet efter den, hvis der er en, vil blive udført i samme kontekst (ofte betyder dette på samme tråd) som koden før afvent.

Metoden er markeret som asynkron void, hvilket betyder, at den kan bruge vent-operatøren, men den kaldende kode vil ikke kunne vente på udførelse. Hvis en sådan funktion er nødvendig, skal metoden returnere Task. Metoder, der er markeret med async void, er ret almindelige: som regel er disse hændelseshandlere eller andre metoder, der fungerer efter fire and forget-princippet. Hvis du ikke kun skal give mulighed for at vente til slutningen af ​​udførelsen, men også returnere resultatet, så skal du bruge Task.

På den opgave, som StartNew-metoden returnerede, såvel som på enhver anden, kan du kalde ConfigureAwait-metoden med den falske parameter, hvorefter udførelse efter await fortsætter ikke på den fangede kontekst, men på en vilkårlig. Dette bør altid gøres, når udførelseskonteksten ikke er vigtig for koden efter afvent. Dette er også en anbefaling fra MS ved skrivning af kode, der skal leveres pakket i et bibliotek.

Lad os dvæle lidt mere ved, hvordan du kan vente på færdiggørelsen af ​​en opgave. Nedenfor er et eksempel på kode, med kommentarer til, hvornår forventningen er udført betinget godt, og hvornår det er udført betinget dårligt.

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 eksempel venter vi på, at opgaven er fuldført uden at blokere den kaldende tråd; vi vil kun vende tilbage til at behandle resultatet, når det allerede er der; indtil da er den kaldende tråd overladt til sine egne enheder.

I den anden mulighed blokerer vi den kaldende tråd, indtil resultatet af metoden er beregnet. Dette er dårligt, ikke kun fordi vi har optaget en tråd, en så værdifuld ressource i programmet, med simpel tomgang, men også fordi, hvis koden til den metode, som vi kalder indeholder, venter, og synkroniseringskonteksten kræver at vende tilbage til den kaldende tråd efter vent, så får vi et dødvande: Den kaldende tråd venter mens resultatet af den asynkrone metode beregnes, den asynkrone metode forsøger forgæves at fortsætte sin eksekvering i den kaldende tråd.

En anden ulempe ved denne fremgangsmåde er kompliceret fejlhåndtering. Faktum er, at fejl i asynkron kode ved brug af async/await er meget nemme at håndtere - de opfører sig på samme måde, som hvis koden var synkron. Mens hvis vi anvender synkron venteeksorcisme på en opgave, bliver den oprindelige undtagelse til en AggregateException, dvs. For at håndtere undtagelsen bliver du nødt til at undersøge InnerException-typen og skrive en if-kæde dig selv inde i en catch-blok eller bruge catchen, når du konstruerer, i stedet for kæden af ​​catch-blokke, der er mere kendt i C#-verdenen.

Det tredje og sidste eksempel er også markeret dårligt af samme grund og indeholder alle de samme problemer.

WhenAny- og WhenAll-metoderne er ekstremt praktiske til at vente på en gruppe opgaver; de samler en gruppe opgaver i én, som udløses, enten når en opgave fra gruppen først udløses, eller når de alle har fuldført deres udførelse.

Stoppe tråde

Af forskellige årsager kan det være nødvendigt at stoppe flowet efter det er startet. Der er en række måder at gøre dette på. Thread-klassen har to passende navngivne metoder: abort и Afbryde. Den første anbefales stærkt ikke til brug, fordi efter at have kaldt det på et hvilket som helst tilfældigt tidspunkt, under behandlingen af ​​enhver instruktion, vil en undtagelse blive kastet ThreadAbortedException. Du forventer ikke, at en sådan undtagelse vil blive kastet, når du øger en heltalsvariabel, vel? Og når du bruger denne metode, er dette en meget reel situation. Hvis du har brug for at forhindre CLR i at generere en sådan undtagelse i et bestemt kodeafsnit, kan du pakke det ind i opkald Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Enhver kode skrevet i en endelig blok er pakket ind i sådanne opkald. Af denne grund kan du i dybden af ​​rammekoden finde blokke med et tomt forsøg, men ikke et tomt til sidst. Microsoft fraråder denne metode så meget, at de ikke inkluderede den i .net-kernen.

Interrupt-metoden fungerer mere forudsigeligt. Det kan afbryde tråden med en undtagelse Undtagelse af trådafbrudt kun i de øjeblikke, hvor tråden er i ventetilstand. Den går ind i denne tilstand, mens den hænger, mens den venter på WaitHandle, lås eller efter at have ringet til Thread.Sleep.

Begge muligheder beskrevet ovenfor er dårlige på grund af deres uforudsigelighed. Løsningen er at bruge en struktur Annulleringstoken og klasse CancellationTokenSource. Pointen er dette: en forekomst af klassen CancellationTokenSource oprettes, og kun den, der ejer den, kan stoppe operationen ved at kalde metoden Ophæve. Kun CancellationToken overføres til selve operationen. CancellationToken-ejere kan ikke selv annullere operationen, men kan kun kontrollere, om operationen er blevet annulleret. Der er en boolesk egenskab til dette Er AnnulleringAnmodet og metode ThrowIfCancelRequested. Sidstnævnte vil give en undtagelse TaskCancelledException hvis Annuller-metoden blev kaldt på den CancellationToken-instans, der blev parroteret. Og det er den metode, jeg anbefaler at bruge. Dette er en forbedring i forhold til de tidligere muligheder ved at få fuld kontrol over, hvornår en undtagelsesoperation kan afbrydes.

Den mest brutale mulighed for at stoppe en tråd er at kalde Win32 API TerminateThread-funktionen. CLR'ens opførsel efter at have kaldt denne funktion kan være uforudsigelig. På MSDN er følgende skrevet om denne funktion: "TerminateThread er en farlig funktion, som kun bør bruges i de mest ekstreme tilfælde. “

Konvertering af ældre API til opgavebaseret ved hjælp af FromAsync-metoden

Hvis du er så heldig at arbejde på et projekt, der blev startet efter at Tasks blev introduceret og holdt op med at forårsage stille rædsel for de fleste udviklere, så behøver du ikke at håndtere en masse gamle API'er, både tredjeparts og dem dit team har tortureret i fortiden. Heldigvis tog .NET Framework-teamet sig af os, selvom målet måske var at passe på os selv. Hvorom alting er, så har .NET en række værktøjer til smertefrit at konvertere kode skrevet i gamle asynkrone programmeringstilgange til den nye. En af dem er FromAsync-metoden fra TaskFactory. I kodeeksemplet nedenfor pakker jeg de gamle async-metoder fra WebRequest-klassen ind i en opgave ved hjælp af denne metode.

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

Dette er blot et eksempel, og det er usandsynligt, at du skal gøre dette med indbyggede typer, men ethvert gammelt projekt vrimler simpelthen med BeginDoSomething-metoder, der returnerer IAsyncResult og EndDoSomething-metoder, der modtager det.

Konverter ældre API til Task Based ved hjælp af TaskCompletionSource-klassen

Et andet vigtigt værktøj at overveje er klassen TaskCompletionSource. Med hensyn til funktioner, formål og funktionsprincip kan det minde lidt om RegisterWaitForSingleObject-metoden i ThreadPool-klassen, som jeg skrev om ovenfor. Ved at bruge denne klasse kan du nemt og bekvemt pakke gamle asynkrone API'er ind i Tasks.

Du vil sige, at jeg allerede har talt om FromAsync-metoden i TaskFactory-klassen beregnet til disse formål. Her bliver vi nødt til at huske hele historien om udviklingen af ​​asynkrone modeller i .net, som Microsoft har tilbudt gennem de sidste 15 år: før det opgavebaserede asynkrone mønster (TAP) var der det asynkrone programmeringsmønster (APP), som handlede om metoder BegyndGør noget vender tilbage IAsyncResult og metoder EndeDoSomething, der accepterer det, og for arven fra disse år er FromAsync-metoden bare perfekt, men med tiden blev den erstattet af det begivenhedsbaserede asynkrone mønster (EAP), som antog, at en hændelse ville blive rejst, når den asynkrone operation var fuldført.

TaskCompletionSource er perfekt til at indpakke opgaver og ældre API'er bygget op omkring begivenhedsmodellen. Essensen af ​​dets arbejde er som følger: et objekt i denne klasse har en offentlig egenskab af typen Task, hvis tilstand kan styres gennem metoderne SetResult, SetException osv. i TaskCompletionSource-klassen. På steder, hvor vent-operatoren blev anvendt på denne opgave, vil den blive udført eller mislykkes med en undtagelse, afhængigt af metoden anvendt på TaskCompletionSource. Hvis det stadig ikke er klart, lad os se på dette kodeeksempel, hvor nogle gamle EAP API er pakket ind i en opgave ved hjælp af en TaskCompletionSource: når hændelsen udløses, vil opgaven blive placeret i fuldført tilstand, og den metode, der anvendte await-operatoren til denne opgave vil genoptage sin udførelse efter at have modtaget 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 tricks

Indpakning af gamle API'er er ikke alt, der kan gøres ved hjælp af TaskCompletionSource. Brug af denne klasse åbner op for en interessant mulighed for at designe forskellige API'er på opgaver, der ikke optager tråde. Og streamen, som vi husker, er en dyr ressource, og deres antal er begrænset (hovedsageligt af mængden af ​​RAM). Denne begrænsning kan nemt opnås ved at udvikle for eksempel en indlæst webapplikation med kompleks forretningslogik. Lad os overveje de muligheder, jeg taler om, når vi implementerer et sådant trick som Long-Polling.

Kort sagt er essensen af ​​tricket dette: du skal modtage information fra API'et om nogle hændelser, der sker på dens side, mens API'et af en eller anden grund ikke kan rapportere hændelsen, men kun kan returnere tilstanden. Et eksempel på disse er alle API'er bygget oven på HTTP før WebSockets tid, eller hvor det af en eller anden grund var umuligt at bruge denne teknologi. Klienten kan spørge HTTP-serveren. HTTP-serveren kan ikke selv starte kommunikation med klienten. En simpel løsning er at polle serveren ved hjælp af en timer, men dette skaber en ekstra belastning på serveren og en ekstra forsinkelse i gennemsnit TimerInterval / 2. For at komme uden om dette blev et trick kaldet Long Polling opfundet, som går ud på at forsinke svaret fra kl. serveren, indtil Timeout udløber, eller der opstår en hændelse. Hvis hændelsen er indtruffet, behandles den, hvis ikke, sendes anmodningen igen.

while(!eventOccures && !timeoutExceeded)  {

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

Men sådan en løsning vil vise sig at være forfærdelig, så snart antallet af kunder, der venter på begivenheden stiger, fordi... Hver sådan klient optager en hel tråd, der venter på en begivenhed. Ja, og vi får yderligere 1 ms forsinkelse, når hændelsen udløses, oftest er dette ikke væsentligt, men hvorfor gøre softwaren værre, end den kan være? Hvis vi fjerner Thread.Sleep(1), så vil vi forgæves indlæse en processorkerne 100% inaktiv, roterende i en ubrugelig cyklus. Ved at bruge TaskCompletionSource kan du nemt lave denne kode om og løse alle de problemer, der er identificeret 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 kode er ikke produktionsklar, men kun en demo. For at bruge det i rigtige tilfælde skal du som minimum også håndtere situationen, når en besked ankommer på et tidspunkt, hvor ingen forventer det: i dette tilfælde skulle AsseptMessageAsync-metoden returnere en allerede afsluttet opgave. Hvis dette er det mest almindelige tilfælde, så kan du overveje at bruge ValueTask.

Når vi modtager en anmodning om en besked, opretter og placerer vi en TaskCompletionSource i ordbogen, og venter derefter på, hvad der sker først: det angivne tidsinterval udløber, eller en besked modtages.

ValueTask: hvorfor og hvordan

Async/await-operatørerne genererer ligesom yield return-operatoren en tilstandsmaskine ud fra metoden, og dette er oprettelsen af ​​et nyt objekt, hvilket næsten altid ikke er vigtigt, men i sjældne tilfælde kan det skabe et problem. Denne sag kan være en metode, der kaldes rigtig ofte, vi taler om titusinder og hundredtusindvis af opkald i sekundet. Hvis en sådan metode er skrevet på en sådan måde, at den i de fleste tilfælde returnerer et resultat uden om alle await-metoder, så giver .NET et værktøj til at optimere dette - ValueTask-strukturen. For at gøre det klart, lad os se på et eksempel på dets brug: der er en cache, som vi går til meget ofte. Der er nogle værdier i det, og så returnerer vi dem simpelthen; hvis ikke, så går vi til en langsom IO for at få dem. Jeg vil gøre sidstnævnte asynkront, hvilket betyder, at hele metoden viser sig at være asynkron. Den oplagte måde at skrive metoden på er således som følger:

public async Task<string> 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 lille frygt for, hvad Roslyn vil generere, når denne kode kompileres, kan du omskrive dette eksempel som følger:

public Task<string> GetById(int id) {

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

Faktisk ville den optimale løsning i dette tilfælde være at optimere hot-stien, nemlig at få en værdi fra ordbogen uden unødvendige allokeringer og belastning på GC'en, mens vi i de sjældne tilfælde stadig skal gå til IO for data , vil alt forblive et plus /minus på den gamle måde:

public ValueTask<string> GetById(int id) {

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

Lad os se nærmere på dette stykke kode: Hvis der er en værdi i cachen, opretter vi en struktur, ellers vil den virkelige opgave blive pakket ind i en meningsfuld. Den kaldende kode er ligeglad med, hvilken sti denne kode blev udført i: ValueTask, fra et C#-syntakssynspunkt, vil opføre sig på samme måde som en almindelig opgave i dette tilfælde.

TaskSchedulers: styring af opgavelanceringsstrategier

Den næste API, som jeg gerne vil overveje, er klassen Opgaveplanlægger og dets derivater. Jeg har allerede nævnt ovenfor, at TPL har evnen til at administrere strategier til fordeling af opgaver på tværs af tråde. Sådanne strategier er defineret i efterkommerne af TaskScheduler-klassen. Næsten enhver strategi, du måtte have brug for, kan findes i biblioteket. ParallelExtensionsExtras, udviklet af Microsoft, men ikke en del af .NET, men leveret som en Nuget-pakke. Lad os kort se på nogle af dem:

  • CurrentThreadTaskScheduler — udfører opgaver på den aktuelle tråd
  • LimitedConcurrencyLevelTask ​​Scheduler — begrænser antallet af opgaver, der udføres samtidigt af parameter N, som accepteres i konstruktøren
  • BestiltTaskScheduler — er defineret som LimitedConcurrencyLevelTaskScheduler(1), så opgaver vil blive udført sekventielt.
  • WorkStealingTaskScheduler - redskaber arbejde stjæle tilgang til opgavefordeling. Grundlæggende er det en separat ThreadPool. Løser det problem, at i .NET ThreadPool er en statisk klasse, en for alle applikationer, hvilket betyder, at dens overbelastning eller forkert brug i en del af programmet kan føre til bivirkninger i en anden. Desuden er det ekstremt svært at forstå årsagen til sådanne defekter. At. Der kan være behov for at bruge separate WorkStealingTaskSchedulers i dele af programmet, hvor brugen af ​​ThreadPool kan være aggressiv og uforudsigelig.
  • QueuedTaskScheduler — giver dig mulighed for at udføre opgaver i henhold til prioritetskøregler
  • ThreadPerTaskScheduler — opretter en separat tråd for hver opgave, der udføres på den. Kan være nyttig til opgaver, der tager uforudsigeligt lang tid at udføre.

Der er en god detaljeret artiklen om TaskSchedulers på microsoft-bloggen.

For bekvem fejlfinding af alt relateret til Opgaver har Visual Studio et Opgavevindue. I dette vindue kan du se den aktuelle tilstand for opgaven og hoppe til den kodelinje, der udføres i øjeblikket.

.NET: Værktøjer til at arbejde med multithreading og asynkroni. Del 1

PLinq og Parallel-klassen

Ud over Tasks og alt, der er sagt om dem, er der to mere interessante værktøjer i .NET: PLinq (Linq2Parallel) og Parallel-klassen. Den første lover parallel udførelse af alle Linq-operationer på flere tråde. Antallet af tråde kan konfigureres ved hjælp af WithDegreeOfParallelism-udvidelsesmetoden. Desværre har PLinq oftest i sin standardtilstand ikke nok information om det interne i din datakilde til at give en betydelig hastighedsforøgelse, på den anden side er omkostningerne ved at prøve meget lave: du skal bare kalde AsParallel-metoden før kæden af ​​Linq metoder og køre præstationstests. Desuden er det muligt at videregive yderligere information til PLinq om arten af ​​din datakilde ved hjælp af partitionsmekanismen. Du kan læse mere her и her.

Den parallelle statiske klasse giver metoder til at iterere gennem en Foreach-samling parallelt, udføre en For-løkke og udføre flere delegerede i parallel Invoke. Udførelse af den aktuelle tråd vil blive stoppet, indtil beregningerne er afsluttet. Antallet af tråde kan konfigureres ved at sende ParallelOptions som det sidste argument. Du kan også angive TaskScheduler og CancellationToken ved hjælp af valgmuligheder.

Fund

Da jeg begyndte at skrive denne artikel baseret på materialet i min rapport og den information, jeg indsamlede under mit arbejde efter den, havde jeg ikke forventet, at der ville være så meget af det. Nu, når teksteditoren, som jeg skriver denne artikel i, bebrejdende fortæller mig, at side 15 er forsvundet, vil jeg opsummere de foreløbige resultater. Andre tricks, API'er, visuelle værktøjer og faldgruber vil blive dækket i den næste artikel.

Konklusioner:

  • Du skal kende værktøjerne til at arbejde med tråde, asynkroni og parallelitet for at kunne bruge ressourcerne på moderne pc'er.
  • .NET har mange forskellige værktøjer til disse formål
  • Ikke alle af dem dukkede op på én gang, så du kan ofte finde ældre, men der er måder at konvertere gamle API'er uden stor indsats.
  • Arbejde med tråde i .NET er repræsenteret af klasserne Thread og ThreadPool
  • Thread.Abort, Thread.Interrupt og Win32 API TerminateThread-metoderne er farlige og anbefales ikke til brug. I stedet er det bedre at bruge CancellationToken-mekanismen
  • Flow er en værdifuld ressource, og dens udbud er begrænset. Situationer, hvor tråde er travlt optaget af at vente på begivenheder, bør undgås. Til dette er det praktisk at bruge klassen TaskCompletionSource
  • De mest kraftfulde og avancerede .NET-værktøjer til at arbejde med parallelitet og asynkroni er Tasks.
  • C# async/await-operatørerne implementerer konceptet med ikke-blokerende ventetid
  • Du kan kontrollere fordelingen af ​​opgaver på tværs af tråde ved hjælp af TaskScheduler-afledte klasser
  • ValueTask-strukturen kan være nyttig til at optimere hot-paths og hukommelsestrafik
  • Visual Studios Tasks and Threads-vinduer giver en masse nyttig information til fejlretning af multi-threaded eller asynkron kode
  • PLinq er et fedt værktøj, men det har muligvis ikke nok information om din datakilde, men dette kan rettes ved hjælp af partitioneringsmekanismen
  • Fortsættes ...

Kilde: www.habr.com

Tilføj en kommentar