.NET: Verktyg för att arbeta med multithreading och asynkroni. Del 1

Jag publicerar originalartikeln på Habr, vars översättning är publicerad i företaget blogginlägg.

Behovet av att göra något asynkront, utan att vänta på resultatet här och nu, eller att dela upp stort arbete mellan flera enheter som utför det, fanns innan datorernas tillkomst. Med deras tillkomst blev detta behov mycket påtagligt. Nu, 2019, skriver jag den här artikeln på en bärbar dator med en 8-kärnig Intel Core-processor, på vilken mer än hundra processer körs parallellt, och ännu fler trådar. I närheten finns en lite stökig telefon, köpt för ett par år sedan, den har en 8-kärnig processor ombord. Tematiska resurser är fulla av artiklar och videor där deras författare beundrar årets flaggskeppssmartphones som har 16-kärniga processorer. MS Azure tillhandahåller en virtuell maskin med en processor med 20 kärnor och 128 TB RAM för mindre än 2 USD/timme. Tyvärr är det omöjligt att få ut det maximala och utnyttja denna kraft utan att kunna hantera samspelet mellan trådar.

terminologi

Bearbeta - OS-objekt, isolerat adressutrymme, innehåller trådar.
Tråd - ett OS-objekt, den minsta exekveringsenheten, en del av en process, trådar delar minne och andra resurser sinsemellan inom en process.
Göra flera saker samtidigt - OS-egenskap, möjligheten att köra flera processer samtidigt
Flerkärnig - en egenskap hos processorn, förmågan att använda flera kärnor för databehandling
Multiprocessing - en egenskap hos en dator, förmågan att samtidigt arbeta med flera processorer fysiskt
Multithreading — en egenskap hos en process, förmågan att fördela databehandling mellan flera trådar.
Parallellism - utföra flera handlingar fysiskt samtidigt per tidsenhet
Asynkroni — exekvering av en operation utan att vänta på slutförandet av denna bearbetning; resultatet av exekveringen kan bearbetas senare.

metafor

Alla definitioner är inte bra och vissa behöver ytterligare förklaringar, så jag lägger till en metafor om att laga frukost till den formellt introducerade terminologin. Att laga frukost i denna metafor är en process.

När jag förberedde frukost på morgonen jag (CPU) Jag kommer till köket (dator). jag har 2 händer (Kärnor). Det finns ett antal enheter i köket (IO): ugn, vattenkokare, brödrost, kylskåp. Jag sätter på gasen, sätter en stekpanna på den och häller olja i den utan att vänta på att den ska värmas upp (asynkront, Non-Blocking-IO-Wait), tar jag ut äggen ur kylen och bryter dem till en tallrik och vispa dem sedan med en hand (Tråd #1), och andra (Tråd #2) håller plattan (Shared Resource). Nu skulle jag vilja sätta på vattenkokaren, men jag har inte tillräckligt med händer (Tråd Svält) Under denna tid värms stekpannan upp (Bearbetar resultatet) som jag häller i det jag vispat. Jag sträcker mig efter vattenkokaren och sätter på den och ser dumt hur vattnet kokar i den (Blockering-IO-Vänta), även om han under denna tid kunde ha diskat tallriken där han vispade omeletten.

Jag lagade en omelett med bara två händer, och jag har inte fler, men samtidigt, i ögonblicket för vispning av omeletten, inträffade 2 operationer på en gång: vispning av omeletten, håll i plattan, värmde upp stekpannan. CPU:n är den snabbaste delen av datorn, IO är det som oftast är allt saktar ner, så ofta är en effektiv lösning att sysselsätta CPU:n med något samtidigt som man tar emot data från IO.

Fortsätter metaforen:

  • Om jag i färd med att förbereda en omelett också skulle försöka byta kläder, skulle detta vara ett exempel på multitasking. En viktig nyans: datorer är mycket bättre på detta än människor.
  • Ett kök med flera kockar, till exempel på en restaurang - en flerkärnig dator.
  • Många restauranger i en food court i ett köpcentrum - datacenter

.NET-verktyg

.NET är bra på att arbeta med trådar, som med många andra saker. Med varje ny version introducerar den fler och fler nya verktyg för att arbeta med dem, nya lager av abstraktion över OS-trådar. När man arbetar med konstruktion av abstraktioner använder ramutvecklare ett tillvägagångssätt som ger möjlighet att, när man använder en abstraktion på hög nivå, gå ner en eller flera nivåer under. Oftast är detta inte nödvändigt, i själva verket öppnar det dörren för att skjuta dig själv i foten med ett hagelgevär, men ibland, i sällsynta fall, kan det vara det enda sättet att lösa ett problem som inte är löst på den nuvarande abstraktionsnivån .

Med verktyg menar jag både applikationsprogrammeringsgränssnitt (API) som tillhandahålls av ramverket och tredjepartspaket, såväl som hela mjukvarulösningar som förenklar sökningen efter eventuella problem relaterade till flertrådad kod.

Startar en tråd

Trådklassen är den mest grundläggande klassen i .NET för att arbeta med trådar. Konstruktören accepterar en av två delegater:

  • Trådstart — Inga parametrar
  • ParametrizedThreadStart - med en parameter av typen objekt.

Delegaten kommer att exekveras i den nyskapade tråden efter att ha anropat Startmetoden Om en delegat av typen ParametrizedThreadStart skickades till konstruktorn måste ett objekt skickas till Startmetoden. Denna mekanism behövs för att överföra all lokal information till strömmen. Det är värt att notera att att skapa en tråd är en dyr operation, och själva tråden är ett tungt objekt, åtminstone för att den allokerar 1 MB minne på stacken och kräver interaktion med OS API.

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

ThreadPool-klassen representerar konceptet med en pool. I .NET är trådpoolen ett stycke ingenjörskonst, och utvecklarna på Microsoft har lagt mycket kraft på att se till att den fungerar optimalt i en mängd olika scenarier.

Allmänt koncept:

Från det ögonblick som applikationen startar skapar den flera trådar i reserv i bakgrunden och ger möjlighet att ta dem för användning. Om trådar används ofta och i stort antal utökas poolen för att möta uppringarens behov. När det inte finns några lediga trådar i poolen vid rätt tidpunkt, väntar den antingen på att en av trådarna ska komma tillbaka, eller skapar en ny. Därav följer att trådpoolen är utmärkt för vissa kortsiktiga åtgärder och dåligt lämpad för operationer som körs som tjänster under hela applikationens drift.

För att använda en tråd från poolen finns det en QueueUserWorkItem-metod som accepterar en delegat av typen WaitCallback, som har samma signatur som ParametrizedThreadStart, och parametern som skickas till den utför samma funktion.

ThreadPool.QueueUserWorkItem(...);

Den mindre kända trådpoolmetoden RegisterWaitForSingleObject används för att organisera icke-blockerande IO-operationer. Delegaten som skickas till denna metod kommer att anropas när WaitHandle som skickas till metoden är "Released".

ThreadPool.RegisterWaitForSingleObject(...)

.NET har en trådtimer och den skiljer sig från WinForms/WPF-timers genom att dess hanterare kommer att anropas på en tråd hämtad från poolen.

System.Threading.Timer

Det finns också ett ganska exotiskt sätt att skicka en delegat för exekvering till en tråd från poolen - BeginInvoke-metoden.

DelegateInstance.BeginInvoke

Jag skulle kort vilja uppehålla mig vid funktionen som många av ovanstående metoder kan kallas - CreateThread från Kernel32.dll Win32 API. Det finns ett sätt, tack vare mekanismen för externa metoder, att anropa denna funktion. Jag har bara sett ett sådant samtal en gång i ett fruktansvärt exempel på äldre kod, och motivationen för författaren som gjorde exakt detta är fortfarande ett mysterium för mig.

Kernel32.dll CreateThread

Visa och felsöka trådar

Trådar som skapats av dig, alla tredjepartskomponenter och .NET-poolen kan ses i fönstret Trådar i Visual Studio. Det här fönstret visar bara trådinformation när programmet är under felsökning och i brytläge. Här kan du enkelt se stacknamnen och prioriteterna för varje tråd och byta felsökning till en specifik tråd. Genom att använda egenskapen Priority för Thread-klassen kan du ställa in prioriteten för en tråd, vilket OC och CLR kommer att uppfatta som en rekommendation när processortiden delas mellan trådarna.

.NET: Verktyg för att arbeta med multithreading och asynkroni. Del 1

Parallellt uppgiftsbibliotek

Task Parallel Library (TPL) introducerades i .NET 4.0. Nu är det standarden och huvudverktyget för att arbeta med asynkroni. Varje kod som använder en äldre metod anses vara äldre. Den grundläggande enheten för TPL är Task-klassen från System.Threading.Tasks-namnområdet. En uppgift är en abstraktion över en tråd. Med den nya versionen av C#-språket fick vi ett elegant sätt att arbeta med Tasks - async/await-operatorer. Dessa koncept gjorde det möjligt att skriva asynkron kod som om den vore enkel och synkron, detta gjorde det möjligt även för personer med liten förståelse för trådarnas interna funktion att skriva applikationer som använder dem, applikationer som inte fryser när de utför långa operationer. Att använda async/await är ett ämne för en eller till och med flera artiklar, men jag ska försöka få reda på kärnan av det i några meningar:

  • async är en modifierare av en metod som returnerar Task eller void
  • and await är en icke-blockerande operatör för uppgift som väntar.

Än en gång: await-operatören, i det allmänna fallet (det finns undantag), kommer att släppa den aktuella exekveringstråden ytterligare, och när uppgiften avslutar sin exekvering, och tråden (det skulle faktiskt vara mer korrekt att säga sammanhanget , men mer om det senare) kommer att fortsätta exekvera metoden ytterligare. Inuti .NET är denna mekanism implementerad på samma sätt som avkastningsavkastning, när den skrivna metoden förvandlas till en hel klass, vilket är en tillståndsmaskin och kan exekveras i separata delar beroende på dessa tillstånd. Alla som är intresserade kan skriva vilken enkel kod som helst med asynс/await, kompilera och se sammansättningen med JetBrains dotPeek med kompilatorgenererad kod aktiverad.

Låt oss titta på alternativ för att starta och använda Task. I kodexemplet nedan skapar vi en ny uppgift som inte gör något användbart (Tråd.Sömn(10000 XNUMX)), men i verkligheten borde detta vara en del komplext CPU-intensivt arbete.

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 uppgift skapas med ett antal alternativ:

  • LongRunning är en antydan om att uppgiften inte kommer att slutföras snabbt, vilket innebär att det kan vara värt att överväga att inte ta en tråd från poolen, utan skapa en separat för denna uppgift för att inte skada andra.
  • AttachedToParent - Uppgifter kan ordnas i en hierarki. Om detta alternativ användes kan uppgiften vara i ett tillstånd där den själv har slutförts och väntar på att dess barn ska utföras.
  • PreferFairness - betyder att det skulle vara bättre att utföra uppgifter som skickas för exekvering tidigare före de som skickas senare. Men detta är bara en rekommendation och resultat är inte garanterade.

Den andra parametern som skickas till metoden är CancellationToken. För att korrekt hantera annullering av en operation efter att den har startat, måste koden som exekveras fyllas med kontroller för CancellationToken-tillståndet. Om det inte finns några kontroller, kommer Avbryt-metoden som anropas på CancellationTokenSource-objektet att kunna stoppa exekveringen av uppgiften först innan den startar.

Den sista parametern är ett schemaläggarobjekt av typen TaskScheduler. Den här klassen och dess avkomlingar är designade för att styra strategier för att distribuera uppgifter över trådar; som standard kommer uppgiften att köras på en slumpmässig tråd från poolen.

Vänta-operatorn appliceras på den skapade uppgiften, vilket innebär att koden som skrivs efter den, om det finns en sådan, kommer att exekveras i samma sammanhang (ofta betyder detta på samma tråd) som koden före await.

Metoden är markerad som async void, vilket innebär att den kan använda await-operatören, men anropskoden kommer inte att kunna vänta på exekvering. Om en sådan funktion är nödvändig måste metoden returnera Task. Metoder som är markerade med async void är ganska vanliga: som regel är dessa händelsehanterare eller andra metoder som fungerar enligt principen om eld och glöm. Om du inte bara behöver ge möjligheten att vänta till slutet av exekveringen, utan också returnera resultatet, måste du använda Task.

På uppgiften som StartNew-metoden returnerade, liksom på alla andra, kan du anropa ConfigureAwait-metoden med den falska parametern, sedan kommer exekveringen efter await att fortsätta inte på det fångade sammanhanget, utan på ett godtyckligt. Detta bör alltid göras när exekveringskontexten inte är viktig för koden efter väntan. Detta är också en rekommendation från MS när man skriver kod som ska levereras paketerad i ett bibliotek.

Låt oss dröja lite mer om hur du kan vänta på att en uppgift är klar. Nedan finns ett exempel på kod, med kommentarer om när förväntningen görs villkorligt bra och när det görs villkorligt dåligt.

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örsta exemplet väntar vi på att uppgiften ska slutföras utan att blockera den anropande tråden; vi kommer att återgå till att bearbeta resultatet först när det redan finns där; tills dess lämnas den anropande tråden till sina egna enheter.

I det andra alternativet blockerar vi anropstråden tills resultatet av metoden beräknas. Detta är dåligt inte bara för att vi har ockuperat en tråd, en så värdefull resurs i programmet, med enkel tomgång, utan också för att om koden för metoden som vi anropar innehåller väntar, och synkroniseringskontexten kräver att man återgår till den anropande tråden efter vänta, då kommer vi att få ett dödläge: Den anropande tråden väntar på att resultatet av den asynkrona metoden ska beräknas, den asynkrona metoden försöker förgäves att fortsätta sin exekvering i den anropande tråden.

En annan nackdel med detta tillvägagångssätt är komplicerad felhantering. Faktum är att fel i asynkron kod när man använder async/await är väldigt lätta att hantera - de beter sig på samma sätt som om koden vore synkron. Medan om vi tillämpar synkron väntande exorcism på en uppgift, förvandlas det ursprungliga undantaget till ett AggregateException, dvs. För att hantera undantaget måste du undersöka InnerException-typen och skriva en if-kedja själv i ett catch-block eller använda catchen när konstruktionen, istället för kedjan av catch-block som är mer bekant i C#-världen.

De tredje och sista exemplen är också markerade som dåliga av samma anledning och innehåller alla samma problem.

WhenAny- och WhenAll-metoderna är extremt praktiska för att vänta på en grupp uppgifter; de slår ihop en grupp av uppgifter i en, som kommer att aktiveras antingen när en uppgift från gruppen först utlöses eller när alla har slutfört sin exekvering.

Stoppa trådar

Av olika anledningar kan det vara nödvändigt att stoppa flödet efter att det har startat. Det finns ett antal sätt att göra detta. Klassen Thread har två lämpligt namngivna metoder: missfall и Interrupt. Den första rekommenderas starkt inte för användning, eftersom efter att ha anropat den när som helst, under bearbetningen av en instruktion, kommer ett undantag att kastas ThreadAbortedException. Du förväntar dig inte att ett sådant undantag ska göras när du ökar en heltalsvariabel, eller hur? Och när du använder den här metoden är detta en mycket verklig situation. Om du behöver förhindra att CLR genererar ett sådant undantag i ett visst avsnitt av koden kan du slå in det i samtal Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Varje kod som skrivs i ett slutgiltigt block lindas in i sådana samtal. Av denna anledning kan du i ramkodens djup hitta block med ett tomt försök, men inte ett tomt slutligen. Microsoft avråder från denna metod så mycket att de inte inkluderade den i .net-kärnan.

Avbrottsmetoden fungerar mer förutsägbart. Det kan avbryta tråden med ett undantag Trådavbruten undantag endast under de ögonblick då tråden är i vänteläge. Den går in i detta tillstånd medan den hänger medan den väntar på WaitHandle, lås eller efter att ha anropat Thread.Sleep.

Båda alternativen som beskrivs ovan är dåliga på grund av deras oförutsägbarhet. Lösningen är att använda en struktur CancellationToken och klass CancellationTokenSource. Poängen är denna: en instans av klassen CancellationTokenSource skapas och endast den som äger den kan stoppa operationen genom att anropa metoden Annullera. Endast CancellationToken skickas till själva operationen. CancellationToken-ägare kan inte avbryta operationen själva, utan kan bara kontrollera om operationen har avbrutits. Det finns en boolesk egenskap för detta IsCancellationRequested och metod ThrowIfCancelRequested. Det senare kommer att skapa ett undantag TaskCcelledException om Cancel-metoden anropades på den CancellationToken-instans som parrotades. Och det här är metoden jag rekommenderar att använda. Detta är en förbättring jämfört med tidigare alternativ genom att få full kontroll över vid vilken tidpunkt en undantagsoperation kan avbrytas.

Det mest brutala alternativet för att stoppa en tråd är att anropa Win32 API TerminateThread-funktionen. Beteendet hos CLR efter anrop av denna funktion kan vara oförutsägbart. På MSDN skrivs följande om denna funktion: "TerminateThread är en farlig funktion som endast bör användas i de mest extrema fallen. "

Konvertera äldre API till Task Based med FromAsync-metoden

Om du har turen att arbeta på ett projekt som startades efter att Tasks introducerades och som slutade orsaka tyst skräck för de flesta utvecklare, så kommer du inte behöva ta itu med en massa gamla API:er, både tredjeparts- och de ditt team har torterat tidigare. Som tur var tog .NET Framework-teamet hand om oss, även om målet kanske var att ta hand om oss själva. Hur det än må vara så har .NET ett antal verktyg för att smärtfritt konvertera kod skriven i gamla asynkrona programmeringssätt till den nya. En av dem är FromAsync-metoden i TaskFactory. I kodexemplet nedan lägger jag in de gamla asynkmetoderna för WebRequest-klassen i en uppgift med den här metoden.

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

Detta är bara ett exempel och det är osannolikt att du behöver göra detta med inbyggda typer, men alla gamla projekt kryllar helt enkelt av BeginDoSomething-metoder som returnerar IAsyncResult- och EndDoSomething-metoder som tar emot det.

Konvertera äldre API till Task Based med TaskCompletionSource-klassen

Ett annat viktigt verktyg att tänka på är klassen TaskCompletionSource. När det gäller funktioner, syfte och funktionsprincip kan det påminna en del om metoden RegisterWaitForSingleObject i klassen ThreadPool, som jag skrev om ovan. Med den här klassen kan du enkelt och bekvämt slå in gamla asynkrona API:er i Tasks.

Du kommer att säga att jag redan har pratat om FromAsync-metoden för TaskFactory-klassen avsedd för dessa ändamål. Här måste vi komma ihåg hela historiken för utvecklingen av asynkrona modeller i .net som Microsoft har erbjudit under de senaste 15 åren: innan det Task-Based Asynchronous Pattern (TAP) fanns det Asynchronous Programming Pattern (APP), som handlade om metoder BörjarGör något återvänder IAsyncResult och metoder SlutetDoSomething som accepterar det och för arvet från dessa år är FromAsync-metoden helt perfekt, men med tiden ersattes den av Event Based Asynchronous Pattern (EAP), som antog att en händelse skulle uppstå när den asynkrona operationen slutfördes.

TaskCompletionSource är perfekt för att slå in uppgifter och äldre API:er byggda kring händelsemodellen. Kärnan i dess arbete är följande: ett objekt av denna klass har en publik egenskap av typen Task, vars tillstånd kan kontrolleras genom metoderna SetResult, SetException, etc. i klassen TaskCompletionSource. På platser där await-operatorn applicerades på den här uppgiften kommer den att exekveras eller misslyckas med ett undantag beroende på metoden som tillämpas på TaskCompletionSource. Om det fortfarande inte är klart, låt oss titta på det här kodexemplet, där något gammalt EAP API är insvept i en uppgift med hjälp av en TaskCompletionSource: när händelsen utlöses, kommer uppgiften att överföras till läget Slutfört och metoden som tillämpade await-operatorn till denna uppgift kommer att återuppta sin exekvering efter att ha tagit emot objektet resultera.

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 och tricks

Att slå in gamla API:er är inte allt som kan göras med TaskCompletionSource. Att använda den här klassen öppnar upp en intressant möjlighet att designa olika API:er på uppgifter som inte upptar trådar. Och strömmen, som vi minns, är en dyr resurs och deras antal är begränsat (främst av mängden RAM). Denna begränsning kan enkelt uppnås genom att till exempel utveckla en laddad webbapplikation med komplex affärslogik. Låt oss överväga möjligheterna som jag pratar om när vi implementerar ett sådant trick som Long-Polling.

Kort sagt, kärnan i tricket är detta: du behöver få information från API:et om vissa händelser som inträffar på dess sida, medan API:et av någon anledning inte kan rapportera händelsen, utan bara kan returnera tillståndet. Ett exempel på dessa är alla API:er som byggdes ovanpå HTTP före WebSockets tider eller när det av någon anledning var omöjligt att använda denna teknik. Klienten kan fråga HTTP-servern. HTTP-servern kan inte själv initiera kommunikation med klienten. En enkel lösning är att polla servern med hjälp av en timer, men detta skapar ytterligare belastning på servern och en extra fördröjning i genomsnitt TimerInterval / 2. För att komma runt detta uppfanns ett trick som heter Long Polling, vilket innebär att fördröja svaret från server tills Timeout löper ut eller en händelse inträffar. Om händelsen har inträffat behandlas den, om inte skickas begäran igen.

while(!eventOccures && !timeoutExceeded)  {

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

Men en sådan lösning kommer att visa sig vara fruktansvärd så fort antalet kunder som väntar på evenemanget ökar, eftersom... Varje sådan klient upptar en hel tråd som väntar på en händelse. Ja, och vi får ytterligare 1 ms fördröjning när händelsen utlöses, oftast är detta inte signifikant, men varför göra programvaran sämre än den kan vara? Om vi ​​tar bort Thread.Sleep(1), kommer vi förgäves att ladda en processorkärna 100% ledig, roterande i en värdelös cykel. Med TaskCompletionSource kan du enkelt göra om den här koden och lösa alla problem som identifierats ovan:

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

Den här koden är inte produktionsklar, utan bara en demo. För att använda det i verkliga fall behöver du också, som ett minimum, hantera situationen när ett meddelande kommer vid en tidpunkt då ingen förväntar sig det: i det här fallet bör AsseptMessageAsync-metoden returnera en redan slutförd uppgift. Om detta är det vanligaste fallet kan du tänka på att använda ValueTask.

När vi får en begäran om ett meddelande skapar vi och placerar en TaskCompletionSource i ordboken och väntar sedan på vad som händer först: det angivna tidsintervallet löper ut eller ett meddelande tas emot.

ValueTask: varför och hur

Async/await-operatorerna, liksom avkastningsåtergångsoperatören, genererar en tillståndsmaskin från metoden, och detta är skapandet av ett nytt objekt, vilket nästan alltid inte är viktigt, men i sällsynta fall kan det skapa problem. Det här fallet kan vara en metod som kallas riktigt ofta, vi pratar om tiotals och hundratusentals samtal per sekund. Om en sådan metod är skriven på ett sådant sätt att den i de flesta fall returnerar ett resultat som går förbi alla await-metoder, så tillhandahåller .NET ett verktyg för att optimera detta - ValueTask-strukturen. För att göra det tydligt, låt oss titta på ett exempel på dess användning: det finns en cache som vi går till väldigt ofta. Det finns några värden i det och sedan returnerar vi dem helt enkelt; om inte, så går vi till någon långsam IO för att få dem. Jag vill göra det senare asynkront, vilket innebär att hela metoden visar sig vara asynkron. Det uppenbara sättet att skriva metoden är alltså följande:

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

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

På grund av önskan att optimera lite, och en liten rädsla för vad Roslyn kommer att generera när du kompilerar den här koden, kan du skriva om det här exemplet enligt följande:

public Task<string> GetById(int id) {

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

Den optimala lösningen i det här fallet skulle faktiskt vara att optimera hot-path, nämligen att erhålla ett värde från ordboken utan några onödiga tilldelningar och belastning på GC, medan vi i de sällsynta fallen fortfarande behöver gå till IO för data , allt kommer att förbli ett plus /minus på det gamla sättet:

public ValueTask<string> GetById(int id) {

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

Låt oss ta en närmare titt på denna kodbit: om det finns ett värde i cachen skapar vi en struktur, annars kommer den verkliga uppgiften att lindas in i en meningsfull. Den anropande koden bryr sig inte om vilken sökväg den här koden kördes i: ValueTask, ur C#-syntaxsynpunkt, kommer att bete sig på samma sätt som en vanlig uppgift i det här fallet.

TaskSchedulers: hantera strategier för uppgiftsstart

Nästa API som jag skulle vilja överväga är klassen Schemaläggaren och dess derivat. Jag nämnde redan ovan att TPL har förmågan att hantera strategier för att distribuera uppgifter över trådar. Sådana strategier definieras i ättlingarna till klassen TaskScheduler. Nästan alla strategier du kan behöva finns i biblioteket. ParallelExtensionsExtras, utvecklad av Microsoft, men inte en del av .NET, utan levereras som ett Nuget-paket. Låt oss kort titta på några av dem:

  • CurrentThreadTaskScheduler — utför uppgifter i den aktuella tråden
  • LimitedConcurrencyLevelTaskScheduler — begränsar antalet uppgifter som exekveras samtidigt av parameter N, som accepteras i konstruktorn
  • OrderedTaskScheduler — definieras som LimitedConcurrencyLevelTaskScheduler(1), så uppgifter kommer att utföras sekventiellt.
  • WorkStealingTaskScheduler - redskap arbetsstöld förhållningssätt till uppgiftsfördelning. I huvudsak är det en separat ThreadPool. Löser problemet att i .NET ThreadPool är en statisk klass, en för alla applikationer, vilket innebär att dess överbelastning eller felaktig användning i en del av programmet kan leda till biverkningar i en annan. Dessutom är det extremt svårt att förstå orsaken till sådana defekter. Den där. Det kan finnas ett behov av att använda separata WorkStealingTaskSchedulers i delar av programmet där användningen av ThreadPool kan vara aggressiv och oförutsägbar.
  • QueuedTaskScheduler — låter dig utföra uppgifter enligt prioritetsköregler
  • ThreadPerTaskScheduler — skapar en separat tråd för varje uppgift som körs på den. Kan vara användbart för uppgifter som tar oförutsägbart lång tid att slutföra.

Det finns en bra detaljerad artikel om TaskSchedulers på Microsofts blogg.

För bekväm felsökning av allt relaterat till Tasks, har Visual Studio ett Tasks-fönster. I det här fönstret kan du se det aktuella tillståndet för uppgiften och hoppa till den kodrad som för närvarande körs.

.NET: Verktyg för att arbeta med multithreading och asynkroni. Del 1

PLinq och klassen Parallell

Förutom Tasks och allt som sägs om dem, finns det ytterligare två intressanta verktyg i .NET: PLinq (Linq2Parallel) och klassen Parallel. Den första lovar parallell exekvering av alla Linq-operationer på flera trådar. Antalet trådar kan konfigureras med förlängningsmetoden WithDegreeOfParallelism. Tyvärr har oftast PLinq i sitt standardläge inte tillräckligt med information om interna i din datakälla för att ge en betydande hastighetsökning, å andra sidan är kostnaden för att försöka mycket låg: du behöver bara anropa AsParallel-metoden innan kedjan av Linq-metoder och köra prestandatester. Dessutom är det möjligt att skicka ytterligare information till PLinq om din datakällas karaktär med hjälp av partitionsmekanismen. Du kan läsa mer här и här.

Den Parallella statiska klassen tillhandahåller metoder för att iterera genom en Foreach-samling parallellt, exekvera en For-loop och exekvera flera delegater i parallell Invoke. Exekveringen av den aktuella tråden kommer att stoppas tills beräkningarna är klara. Antalet trådar kan konfigureras genom att skicka ParallelOptions som sista argument. Du kan också ange TaskScheduler och CancellationToken med hjälp av alternativ.

Resultat

När jag började skriva den här artikeln baserat på materialet i min rapport och den information som jag samlade in under mitt arbete efter den, trodde jag inte att det skulle bli så mycket av det. Nu, när textredigeraren som jag skriver den här artikeln i förebrående säger till mig att sidan 15 har försvunnit, kommer jag att sammanfatta interimsresultaten. Andra knep, API:er, visuella verktyg och fallgropar kommer att behandlas i nästa artikel.

Slutsatser:

  • Du behöver känna till verktygen för att arbeta med trådar, asynkroni och parallellism för att kunna använda resurserna i moderna datorer.
  • .NET har många olika verktyg för dessa ändamål
  • Inte alla dök upp på en gång, så du kan ofta hitta äldre, men det finns sätt att konvertera gamla API:er utan större ansträngning.
  • Att arbeta med trådar i .NET representeras av klasserna Thread och ThreadPool
  • Metoderna Thread.Abort, Thread.Interrupt och Win32 API TerminateThread är farliga och rekommenderas inte för användning. Istället är det bättre att använda CancellationToken-mekanismen
  • Flöde är en värdefull resurs och dess tillgång är begränsad. Situationer där trådar är upptagna i väntan på händelser bör undvikas. För detta är det bekvämt att använda klassen TaskCompletionSource
  • De mest kraftfulla och avancerade .NET-verktygen för att arbeta med parallellism och asynkroni är Tasks.
  • C# async/await-operatörerna implementerar konceptet med icke-blockerande väntan
  • Du kan styra fördelningen av uppgifter över trådar med TaskScheduler-härledda klasser
  • ValueTask-strukturen kan vara användbar för att optimera hot-paths och minnestrafik
  • Visual Studios Tasks and Threads-fönster ger mycket användbar information för att felsöka flertrådad eller asynkron kod
  • Plinq är ett coolt verktyg, men det kanske inte har tillräckligt med information om din datakälla, men detta kan fixas med hjälp av partitioneringsmekanismen
  • Fortsättning ...

Källa: will.com

Lägg en kommentar