.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