Jag publicerar pÄ Habr originalartikeln vars översÀttning finns i företaget .
Behovet av att göra nÄgot asynkront, utan att vÀnta pÄ resultat hÀr och nu, eller att dela upp ett stort arbete mellan flera enheter som utför det, har funnits Àven innan datorernas tid. Med deras ankomst blev detta behov mycket tydligt. Nu, 2019, medan jag skriver denna artikel pÄ en bÀrbar dator med en 8-kÀrnig Intel Core-processor, vilket samtidigt hanterar hundratals processer, var och en med Ànnu fler trÄdar. Bredvid ligger en nÄgot sliten mobiltelefon jag köpte för ett par Är sedan, som har en 8-kÀrnig processor. Det finns gott om artiklar och videor pÄ specialiserade resurser dÀr författarna beundrar de flaggskepps-smartphones som har 16-kÀrniga processorer i Är. MS Azure erbjuder en virtuella maskin med en 128-kÀrnig processor och 2 TB RAM för mindre Àn 20 $/timme. TyvÀrr Àr det omöjligt att utnyttja den maximala kraften och tygla denna kraft utan att kunna hantera interaktionen mellan trÄdarna.
Terminologi
Process â ett OS-objekt, ett isolerat adressutrymme som innehĂ„ller trĂ„dar.
TrĂ„d â ett OS-objekt, den minsta enheten för utförande, en del av processen, trĂ„dar delar minne och andra resurser sinsemellan inom processen.
Multitasking â en egenskap hos operativsystemet, möjligheten att utföra flera processer samtidigt
FlerkĂ€rnighet â en egenskap hos processorn, möjligheten att anvĂ€nda flera kĂ€rnor för databehandling
Flermaskinvaruarkitektur â en egenskap hos datorn, möjligheten att arbeta med flera processorer fysiskt samtidigt
MultitrĂ„dning â en egenskap hos processen, möjligheten att fördela databehandling mellan flera trĂ„dar.
Parallellitet â utförande av flera Ă„tgĂ€rder fysiskt samtidigt under en viss tidsenhet
Asynkronitet â utförande av en operation utan att vĂ€nta pĂ„ att fullföra denna behandling, resultatet kan bearbetas senare.
Metafor
Inte alla definitioner Àr bra, och nÄgra behöver ytterligare förklaring, sÄ jag lÀgger till en metafor om att laga frukost till den formella terminologin. Laga frukost i denna metafor Àr processen.
Medan jag lagar frukost pÄ morgonen kommer jag (CPU) in i köket (Datorn). Jag har tvÄ hÀnder (KÀrnor). I köket finns flera apparater (IO): ugn, vattenkokare, brödrost, kylskÄp. Jag slÄr pÄ gasen, stÀller en stekpanna pÄ och hÀller i olja, utan att vÀnta pÄ att den ska vÀrmas upp (asynkront, Non-Blocking-IO-Wait), tar jag ut Àggen frÄn kylskÄpet och slÄr dem i en tallrik, varpÄ jag vispar med ena handen (TrÄd#1), och med den andra (TrÄd#2) hÄller jag tallriken (Delad Resurs). Just nu skulle jag ocksÄ vilja sÀtta pÄ vattenkokaren, men jag har inte tillrÀckligt med hÀnder (TrÄdsvÀlt) Under tiden vÀrms stekpannan (Resultatbearbetning) dit jag hÀller det jag har vispat. Jag strÀcker mig till vattenkokaren och sÀtter pÄ den och ser bara pÄ medan vattnet kokar (Blocking-IO-Wait), Àven om jag skulle kunnat diska tallriken dÀr jag vispade omeletten under den tiden.
Jag lagade omelett med endast tvÄ hÀnder, och fler har jag inte, men vid tillagningen av omeletten pÄgick samtidigt tre operationer: vispning av omelett, hÄlla tallriken, vÀrma stekpannan. CPU Àr den snabbaste delen av datorn, IO Àr det som oftast bromsar, och dÀrför Àr en effektiv lösning ofta att sysselsÀtta CPU:n medan data hÀmtas frÄn IO.
För att fortsÀtta metaforen:
- Om jag under tillagningen av omeletten ocksÄ försökte byta klÀder skulle det vara ett exempel pÄ multitasking. En viktig detalj: datorer klarar detta mycket bÀttre Àn mÀnniskor.
- Köket med flera kockar, som i en restaurang â en flerkĂ€rnig dator.
- MĂ„nga restauranger pĂ„ en matmarknad i ett köpcentrum â ett datacenter
Verktyg .NET
I arbetet med trÄdar, liksom i mycket annat, Àr .NET bra. Med varje ny version introducerar den fler och fler nya verktyg för att arbeta med dem, nya abstraktionslager ovanpÄ operativsystemets trÄdar. Vid konstruktion av abstraktioner anvÀnder ramverksutvecklarna en metod som ger möjlighet att, nÀr högre abstraktion anvÀnds, gÄ ner ett eller flera nivÄer. Oftast finns det inget behov av detta, dessutom öppnar det möjligheten för att skjuta sig sjÀlv i foten med en hagelgevÀr, men ibland, i sÀllsynta fall, kan det vara det enda sÀttet att lösa ett problem som inte kan lösas pÄ den aktuella abstraktionsnivÄn.
Med verktygen menar jag bÄde programmeringsgrÀnssnitt (API) som tillhandahÄlls av ramverket och tredjepartsbibliotek samt hela programlösningar som förenklar identifiering av eventuella problem relaterade till flertrÄdig kod.
Starta trÄd
Klassen Thread, den mest grundlÀggande i .NET för att arbeta med trÄdar. Den tar ett av tvÄ delegattyper i konstruktorn:
- ThreadStart â Utan parametrar
- ParametrizedThreadStart â med en parameter av typen object.
Delegaten kommer att köras i den ny skapade trÄden efter att metoden Start har anropats; om en parametriserad delegat skickas till konstruktorn, mÄste ett objekt överföras till metoden Start. Denna mekanism behövs för att överföra lokal information till trÄden. Det Àr vÀrt att notera att skapandet av en trÄd Àr en kostsam operation, och sjÀlva trÄden Àr ett tungt objekt, framför allt eftersom 1 MB minne allokeras för stacken och det krÀver interaktion med operativsystemets API.
new Thread(...).Start(...);
Klassen ThreadPool representerar konceptet av en pool. I .NET Àr trÄdpoolen ett ingenjörskonstverk och utvecklarna pÄ Microsoft har investerat mycket tid och anstrÀngning för att den ska fungera optimalt i olika scenarier.
AllmÀn koncept:
FrÄn det ögonblick applikationen startar skapas flera reservtrÄdar i bakgrunden och möjliggör att anvÀnda dem. Om trÄdarna anvÀnds ofta och i stor mÀngd, sÄ expanderar poolen för att tillfredsstÀlla behovet hos den anropande koden. NÀr det i poolen vid ett givet tillfÀlle inte finns nÄgra lediga trÄdar, antingen vÀntar den pÄ att en trÄd ska ÄtervÀnda, eller sÄ skapas en ny. Detta innebÀr att trÄdpoolen Àr utmÀrkt för korta uppgifter men inte lÀmpad för operationer som fungerar som tjÀnster under hela applikationens livslÀngd.
För att anvÀnda en trÄd frÄn poolen finns det metoden QueueUserWorkItem, som tar en delegat av typen WaitCallback, vilken har samma signatur som ParametrizedThreadStart, och parametern som skickas till den utför samma funktion.
ThreadPool.QueueUserWorkItem(...);
En mindre kÀnd metod i trÄdpoolen, RegisterWaitForSingleObject, anvÀnds för att hantera icke-blockerande IO-operationer. Delegaten som skickas till denna metod kommer att anropas nÀr WaitHandle som skickas till metoden 'slÀpps' (Released).
ThreadPool.RegisterWaitForSingleObject(...)
I .NET finns det en trÄd-timer och den skiljer sig frÄn WinForms/WPF-timrar genom att dess hanterare kommer att anropas i en trÄd som tas frÄn poolen.
System.Threading.Timer
Det finns ocksĂ„ ett ganska exotiskt sĂ€tt att skicka en delegat för körning i en trĂ„d frĂ„n poolen â metoden BeginInvoke.
DelegateInstance.BeginInvoke
Jag vill kort beröra funktionen som mĂ„nga av de ovan nĂ€mnda metoderna handlar om â CreateThread frĂ„n Kernel32.dll Win32 API. Det finns ett sĂ€tt att anropa denna funktion tack vare mekanismen för extern metoder. Jag har bara sett ett sĂ„dant anrop en gĂ„ng i en skrĂ€mmande gammal legacy-kod, och motivet bakom författarens val förblir en gĂ„ta för mig.
Kernel32.dll CreateThread
Granska och debugga trÄdar
De trÄdar som du personligen har skapat, alla tredjeparts-komponenter och .NET-trÄdar kan visas i fönstret TrÄdar i Visual Studio. Detta fönster visar information om trÄdarna endast nÀr applikationen Àr i debug-lÀge och stannad (Break mode). HÀr kan du enkelt se stackarna, namnen och prioriteringarna för varje trÄd, och skifta debug-lÀget till en specifik trÄd. TrÄdklassens egenskap Priority kan stÀllas in för att ange en rekommendation om trÄdens prioritet som OS och CLR ska beakta vid fördelning av processor-tid mellan trÄdarna.

Task Parallel Library
Task Parallel Library (TPL) introducerades i .NET 4.0. Det Ă€r nu standarden och det huvudsakliga verktyget för arbete med asynkronitet. All kod som anvĂ€nder Ă€ldre metodik betraktas som legacy. TPL:s grundlĂ€ggande enhet Ă€r klassen Task frĂ„n namespaces System.Threading.Tasks. Task representerar en abstraktion över en trĂ„d. Med den nya versionen av C# fick vi ett elegant sĂ€tt att arbeta med Tasks â async/await-operatörer. Dessa koncept gjorde det möjligt att skriva asynkron kod som om den vore enkel och synkron, vilket gjorde det möjligt Ă€ven för dem som har begrĂ€nsad förstĂ„else för trĂ„darnas inre funktioner att skriva applikationer som anvĂ€nder dem, utan att applikationerna fryser under lĂ„ngvariga operationer. AnvĂ€ndning av async/await Ă€r ett Ă€mne för en eller flera artiklar, men jag ska försöka sammanfatta essensen i nĂ„gra meningar:
- async Àr en modifikator för metoder som returnerar Task eller void
- och await Àr en operator för icke-blockerande vÀntan pÄ en Task.
à terigen: await-operatören, i allmÀnhet (det finns undantag), kommer att slÀppa det nuvarande utförandeflödet vidare, och nÀr Task Àr klar med sin körning, kommer flödet (det Àr faktiskt mer korrekt att sÀga kontext, men mer om detta senare) att fortsÀtta utförandet av metoden. Inom .NET Àr denna mekanism implementerad pÄ samma sÀtt som yield return, nÀr en skriven metod omvandlas till en hel klass, som Àr en tillstÄndsmaskin och kan köras i separata bitar beroende pÄ dessa tillstÄnd. De som Àr intresserade kan skriva nÄgon enkel kod med anvÀndning av async/await, kompilera den och se samlingen med hjÀlp av JetBrains dotPeek med aktiverad Compiler Generated Code.
LÄt oss titta pÄ alternativ för att starta och anvÀnda Task. I exemplet nedan skapar vi en ny task som inte gör nÄgot anvÀndbart (Thread.Sleep(10000)), men i verkliga livet bör detta vara nÄgot komplext som involverar CPU-arbete.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Koden för ÄtgÀrden kommer att köras i en annan kontext
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// Koden efter await kommer att köras i fÄngad kontext
}
Task skapas med ett antal alternativ:
- LongRunning â en hint om att uppgiften inte kommer att slutföras snabbt, och det kan vara vĂ€rt att övervĂ€ga att inte ta en trĂ„d frĂ„n poolen, utan att skapa en separat för denna Task för att inte skada de andra.
- AttachedToParent â Tasks kan ordnas i hierarkier. Om detta alternativ har anvĂ€nts kan Task befinna sig i ett tillstĂ„nd dĂ€r den sjĂ€lv har slutförts och vĂ€ntar pĂ„ att dottern ska slutföras.
- PreferFairness â innebĂ€r att det vore bra att köra Tasks som skickades för utförande tidigare innan de som skickades senare. Men detta Ă€r bara en rekommendation och resultatet Ă€r inte garanterat.
Det andra parametern i metoden Àr en CancellationToken. För korrekt hantering av avbrytning av operationen efter dess start mÄste den körda koden vara fylld med kontroller för tillstÄndet av CancellationToken. Om det inte finns nÄgra kontroller kan metoden Cancel som anropas pÄ objektet CancellationTokenSource stoppa körningen av Task-en bara före dess start.
Den sista parametern Àr ett objekt av typen TaskScheduler. Denna klass och dess derviat betjÀnar för att hantera strategier för fördelning av Tasks över trÄdar, som standard kommer Task att köras pÄ en slumpmÀssig trÄd frÄn poolen.
Det skapade Task har en await-operator tillÀmpad, vilket innebÀr att koden som skrivs efter det, om sÄdan finns, kommer att köras i samma kontext (detta betyder ofta att den körs pÄ samma trÄd) som koden före await.
Metoden Àr markerad som async void, vilket innebÀr att det Àr tillÄtet att anvÀnda await-operatorn i den, men den som anropar koden kan inte vÀnta pÄ att den ska avslutas. Om ett sÄdant behov finns bör metoden returnera en Task. Metoder markerade som async void förekommer ganska ofta: vanligtvis Àr de hÀndelsehanterare eller andra metoder som fungerar enligt principen kör och glöm (fire and forget). Om det Àr nödvÀndigt att bÄde kunna vÀnta pÄ att körningen ska avslutas och returnera ett resultat, bör Task anvÀndas.
PÄ Task som returnerades av metoden StartNew, precis som pÄ vilken annan som helst, kan metoden ConfigureAwait med parameter false anropas, dÄ fortsÀtter körningen efter await inte i den fÄngade kontexten utan pÄ en godtycklig sÄdan. Detta bör alltid göras nÀr kontexten för koden efter await inte Àr avgörande. Detta Àr ocksÄ en rekommendation frÄn MS nÀr man skriver kod som kommer att levereras i ett paketerat biblioteksformat.
LÄt oss stanna upp lite mer vid hur vi kan vÀnta pÄ att en Task ska avslutas. Nedan Àr ett kodexempel med kommentarer, dÀr vÀntan görs förhÄllandevis bra och nÀr det görs förhÄllandevis dÄligt.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // bra
result = AsyncMethod().Result; // dÄligt
AsyncMethod().Wait(); // dÄligt
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // bra
await Task.WhenAny(tasks); // bra
Task.WaitAll(tasks.ToArray()); // dÄligt
}
I det första exemplet vÀntar vi pÄ att Task ska avslutas utan att blockera den anropande trÄden, vi kommer tillbaka till att bearbeta resultatet först nÀr det Àr klart, tills dess Àr den anropande trÄden fri.
I det andra fallet blockerar vi den anropande trÄden tills resultatet av metoden har berÀknats. Detta Àr dÄligt inte bara för att vi har blockerat en trÄd, en sÄ vÀrdefull resurs i programmet, med simpel sysslolöshet, utan ocksÄ för att om det finns await i metoden vi anropar, och synkroniseringskontexten förvÀntar sig att ÄtergÄ till den anropande trÄden efter await, sÄ fÄr vi en deadlock: den anropande trÄden vÀntar pÄ att resultatet av den asynkrona metoden ska berÀknas medan den asynkrona metoden förgÀves försöker fortsÀtta sin exekvering i den anropande trÄden.
En annan nackdel med detta tillvĂ€gagĂ„ngssĂ€tt Ă€r den komplicerade felhanteringen. Grejen Ă€r att hantera fel i asynkron kod med hjĂ€lp av async/await Ă€r mycket lĂ€tt â de beter sig som om koden vore synkron. Medan om vi anvĂ€nder synkront vĂ€ntande pĂ„ en Task, sĂ„ kapslas det ursprungliga undantaget in i AggregateException, vilket innebĂ€r att vi mĂ„ste undersöka typen av InnerException och sjĂ€lva skriva en kedja av if-satser inom en catch-block eller anvĂ€nda konstruktionen catch when, istĂ€llet för den mer vanliga kedjan av catch-block i C#-vĂ€rlden.
Det tredje och sista exemplet har ocksÄ mÀrkts för att vara dÄligt av samma anledning och innehÄller alla samma problem.
Metoderna WhenAny och WhenAll Àr extremt anvÀndbara för att vÀnta pÄ en grupp av Tasks, de kapslar in en grupp av Tasks i en som kommer att aktiveras antingen vid den första av gruppens Tasks eller nÀr alla har avslutat sin exekvering.
Stoppa trÄdar
Av olika skĂ€l kan det uppstĂ„ ett behov att stoppa en trĂ„d efter att den har startat. Det finns flera sĂ€tt att göra detta. Klassen Thread har tvĂ„ metoder med passande namn â det Ă€r Avbryt och Interrupt. Den första rekommenderas starkt att inte anvĂ€nda, eftersom det efter anropet kan kastas ett undantag nĂ€r som helst under utförandet av valfri instruktion. ThreadAbortedException. Du förvĂ€ntar dig inte att ett sĂ„dant undantag ska uppstĂ„ vid inkrementering av en heltalsvariabel, eller hur? Men med denna metod Ă€r det en helt realitet. Om det Ă€r nödvĂ€ndigt att förbjuda CLR frĂ„n att generera sĂ„dant undantag i ett visst kodavsnitt kan det omges av anrop till Thread.BeginCriticalRegion, Thread.EndCriticalRegion. SĂ„dana Ă„tgĂ€rder omger all kod som skrivits i finally-blocket. Av den anledningen kan det i ramverkets kodbas finnas block med tomma try, men inte tomma finally. Microsoft rekommenderar sĂ„ starkt att anvĂ€nda denna metod att de inte har inkluderat den i .net core.
Metoden Interrupt fungerar mer förutsÀgbart. Den kan avbryta en trÄd med ett undantag ThreadInterruptedException endast i de ögonblick dÄ trÄden befinner sig i vÀntelÀge. Den nÄr detta tillstÄnd genom att hÀnga pÄ ett WaitHandle, lock eller efter att ha anropat Thread.Sleep.
BĂ„da de ovanstĂ„ende alternativen Ă€r dĂ„liga pĂ„ grund av sin oförutsĂ€gbarhet. Lösningen Ă€r att anvĂ€nda strukturen CancellationToken och klassen CancellationTokenSource. PoĂ€ngen Ă€r följande: en instans av klassen CancellationTokenSource skapas och endast den som Ă€ger den kan stoppa operationen genom att kalla metoden Cancel. I sjĂ€lva operationen skickas endast CancellationToken. Ăgare av CancellationToken kan inte sjĂ€lva avbryta operationen, de kan endast kontrollera om operationen har avbrutits. För detta finns en boolesk egenskap IsCancellationRequested och metoden ThrowIfCancelRequested. Den sistnĂ€mnda kommer att generera ett undantag TaskCancelledException om metoden Cancel har anropats pĂ„ den medföljande CancellationToken-exemplaret av CancellationTokenSource. Och det Ă€r just denna metod jag rekommenderar att anvĂ€nda. Det ger bĂ€ttre kontroll över nĂ€r undantagsoperationen kan avbrytas.
Det mest drastiska sĂ€ttet att stoppa en trĂ„d Ă€r att anropa Win32 API-funktionen TerminateThread. CLR:s beteende efter att denna funktion har anropats kan vara oförutsĂ€gbart. PĂ„ MSDN stĂ„r följande om denna funktion: âTerminateThread is a dangerous function that should only be used in the most extreme cases.â
Konvertering av legacy-API till taskbaserat med metoden FromAsync
Om du har turen att arbeta pÄ ett projekt som startades efter att uppgifterna infördes och som slutade att orsaka tyst skrÀck hos de flesta utvecklare, sÄ behöver du inte hantera mÄnga gamla API:er, vare sig tredjeparts eller de som ditt team har skapat tidigare. Som tur Àr har .NET Frameworks utvecklingsteam tagit hand om oss, Àven om syftet möjligen var att ta hand om sig sjÀlva. Hur som helst finns det i .NET en rad verktyg för smÀrtfri konvertering av kod skrivna i gamla tillvÀgagÄngssÀtt för asynkron programmering till nya. En av dem Àr metoden FromAsync i TaskFactory. I koden nedan kapslar jag in gamla asynkrona metoder frÄn klassen WebRequest i en Task med hjÀlp av denna metod.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Detta Àr bara ett exempel och du kommer knappast behöva göra detta med inbyggda typer, men vilket gammalt projekt som helst Àr fullt av metoder som BeginDoSomething som returnerar IAsyncResult och metoder som EndDoSomething som tar emot det.
Konvertering av legacy-API:er till Task Based med hjÀlp av klassen TaskCompletionSource
Ett annat viktigt verktyg att övervÀga Àr klassen TaskCompletionSource. I funktioner, syfte och arbetsprincip kan den pÄminna om metoden RegisterWaitForSingleObject i klassen ThreadPool som jag nÀmnde ovan. Med hjÀlp av denna klass kan du enkelt och bekvÀmt kapsla in gamla asynkrona API:er i Tasks.
Du kanske sÀger att jag redan har pratat om metoden FromAsync i klassen TaskFactory som Àr avsedd för dessa syften. HÀr mÄste vi Äterkalla hela historien om utvecklingen av asynkrona modeller i .NET som Microsoft har erbjudit under de senaste 15 Ären: innan Task-Based Asynchronous Pattern (TAP) fanns det Asynchronous Programming Pattern (APP), vilket handlade om metoderna BeginDoSomething som returnerar IAsyncResult och metoderna EndDoSomething som tar emot det och för legacy frÄn dessa Är passar metoden FromAsync utmÀrkt, men med tiden kom Event Based Asynchronous Pattern (EAP), vilket antog att ett evenemang skulle utlösas nÀr den asynkrona operationen var slutförd.
TaskCompletionSource Àr perfekt för att kapsla in Task:er frÄn legacy-API:er som bygger pÄ en hÀndelsestyrd modell. KÀrnan i dess funktion Àr följande: objektet av denna klass har en offentlig egenskap av typen Task vars tillstÄnd kan styras via metoderna SetResult, SetException och sÄ vidare. Klassen TaskCompletionSource. I de stÀllen dÀr await-operatorn har anvÀnts pÄ denna Task kommer den att fullföljas eller krascha med ett undantag beroende pÄ vilken metod som tillÀmpades pÄ TaskCompletionSource. Om det fortfarande Àr oklart, lÄt oss titta pÄ detta kodexempel dÀr ett gammalt API frÄn EAP-kontrolleras i Task med hjÀlp av TaskCompletionSource: nÀr en hÀndelse intrÀffar kommer Task att ledas till tillstÄndet Completed, och metoden som tillÀmpar await-operatorn pÄ denna Task kommer att Äteruppta sitt utförande och fÄ objektet. result.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
TaskCompletionSource Tips & Tricks
Kapsla gamla API:er Àr inte allt som kan göras med TaskCompletionSource. AnvÀndningen av denna klass öppnar intressanta möjligheter för att designa olika API:er baserade pÄ Task:er, vilket inte blockerar trÄdar. Och en trÄd, som vi minns, Àr en dyr resurs och antalet av dem Àr begrÀnsat (frÀmst av RAM-mÀngden). Detta begrÀnsning Àr lÀtt att nÄ nÀr man utvecklar, till exempel, en tung webbapplikation med komplex affÀrslogik. LÄt oss titta pÄ de möjligheter jag talar om genom att implementera ett sÄdant trick som Long-Polling.
Kort sagt handlar tricket om att fÄ information frÄn ett API om vissa hÀndelser som sker pÄ dess sida, medan API:t av olika skÀl inte kan rapportera om hÀndelsen, utan bara returnera sitt tillstÄnd. Exempel pÄ detta Àr alla API:er som byggdes ovanpÄ HTTP innan WebSocket-tiden eller nÀr teknologin inte kan anvÀndas av nÄgon anledning. Klienten kan frÄga HTTP-servern. HTTP-servern kan inte sjÀlv initiera kommunikation med klienten. En enkel lösning Àr att poll servern med en timer, men det skapar ytterligare belastning pÄ servern och en genomsnittlig fördröjning pÄ TimerInterval / 2. För att kringgÄ detta uppfanns tricket som kallas Long Polling, vilket innebÀr att svaret frÄn servern fördröjs tills timeouten gÄr ut eller en hÀndelse intrÀffar. Om en hÀndelse intrÀffar behandlas den, om inte, skickas förfrÄgan om igen.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Men en sÄdan lösning kommer att prestera katastrofalt sÄ snart antalet klienter som vÀntar pÄ hÀndelsen ökar, eftersom varje sÄdan klient i vÀntan pÄ hÀndelsen tar upp en hel trÄd. Dessutom fÄr vi en extra fördröjning pÄ 1 ms vid hÀndelseutlösning, vilket oftast inte Àr avgörande, men varför göra mjukvaran sÀmre Àn den kan vara? Om vi tar bort Thread.Sleep(1), sÄ belastar vi onödigt ett CPU-kÀrna till 100% i en meningslös loop. Med hjÀlp av TaskCompletionSource kan vi enkelt omarbeta denna kod och lösa alla tidigare nÀmnda problem:
class LongPollingApi {
private Dictionary<int, TaskCompletionSource> tasks;
public async Task AcceptMessageAsync(int userId, int duration) {
var cs = new TaskCompletionSource();
tasks[userId] = cs;
await Task.WhenAny(Task.Delay(duration), cs.Task);
return cs.Task.IsCompleted ? cs.Task.Result : null;
}
public void SendMessage(int userId, Msg m) {
if (tasks.TryGetValue(userId, out var completionSource))
completionSource.SetResult(m);
}
}
Denna kod Àr inte produktionsklar, utan Àr bara för demonstrationsÀndamÄl. För att anvÀnda den i verkliga scenarier mÄste man minst hantera situationen nÀr ett meddelande kom i ett ögonblick nÀr ingen förvÀntade sig det: i det fallet bör metoden AcceptMessageAsync returnera en redan slutförd Task. Om detta fall Àr det vanligaste, kan man Àven övervÀga att anvÀnda ValueTask.
Vid mottagandet av en förfrÄgan om ett meddelande skapar vi och placerar i ordboken TaskCompletionSource, och sedan vÀntar vi pÄ vad som sker först: den angivna tidsintervallet gÄr ut eller ett meddelande tas emot.
ValueTask: varför och hur
Operatörerna async/await, liksom operatorn yield return, genererar en tillstĂ„ndsmaskin frĂ„n metoden, vilket innebĂ€r att ett nytt objekt skapas. Detta Ă€r nĂ€stan alltid oviktigt, men i sĂ€llsynta fall kan det skapa problem. Detta kan hĂ€nda om metoden kallas mycket frekvent, som tiotusentals eller hundratusentals gĂ„nger per sekund. Om en sĂ„dan metod Ă€r skriven sĂ„ att den i de flesta fall returnerar ett resultat utan att passera alla await-metoder, sĂ„ tillhandahĂ„ller .NET ett verktyg för att optimera detta â strukturen ValueTask. För att förstĂ„ detta, lĂ„t oss titta pĂ„ ett exempel pĂ„ dess anvĂ€ndning: det finns en cache som vi ofta gĂ„r till. Vissa vĂ€rden finns dĂ€r och vi returnerar dem bara, om de inte finns, gĂ„r vi till nĂ„got lĂ„ngsamt IO för att hĂ€mta dem. Det senare vill vi göra asynkront, vilket innebĂ€r att hela metoden blir asynkron. SĂ„ledes Ă€r den uppenbara varianten av metoden 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 lÀtt rÀdsla för vad Roslyn kommer att generera nÀr den kompilerar denna kod, kan detta exempel skrivas om pÄ följande sÀtt:
public Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
Det verkligt optimala lösningen i detta fall skulle vara att optimera hot-path, nÀmligen att hÀmta vÀrdet frÄn ordboken utan onödiga allokeringar och belastning pÄ GC, medan i de sÀllsynta fall vi faktiskt behöver gÄ till IO för data, allt förblir plus/minus som tidigare:
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 titta nÀrmare pÄ detta kodfragment: nÀr det finns ett vÀrde i cachen skapar vi en struktur, annars kommer den verkliga uppgiften att vara insvept i en signifikant. Den anropande koden bryr sig inte om vilket sÀtt denna kod har körts: ValueTask kommer ur C#-syntaxens synvinkel att bete sig pÄ samma sÀtt som en vanlig Task i detta fall.
TaskScheduler: hantera strategier för körning av uppgifter
Det nÀsta API som vi skulle vilja granska Àr klassen TaskScheduler och dess derivat. Jag nÀmnde tidigare att det finns möjlighet att hantera strategier för distribution av uppgifter över trÄdar i TPL. SÄdana strategier definieras i underklasser av TaskScheduler. NÀstan vilken strategi som helst som kan behövas kommer att hittas i biblioteket. ParallelExtensionsExtras, utvecklad av Microsoft, men som inte Àr en del av .NET, utan levereras som ett NuGet-paket. LÄt oss korttitta pÄ nÄgra av dem:
- CurrentThreadTaskScheduler â kör uppgifter pĂ„ den aktuella trĂ„den.
- LimitedConcurrencyLevelTaskScheduler â begrĂ€nsar antalet uppgifter som körs samtidigt med parameter N, vilket tas emot i konstruktorn.
- OrderedTaskScheduler â definieras som LimitedConcurrencyLevelTaskScheduler(1), vilket innebĂ€r att uppgifterna kommer att köras sekventiellt.
- WorkStealingTaskScheduler â implementerar metoden för uppgiftsdistribution. Den utgör i grunden en separat ThreadPool. Löser problemet att .NET ThreadPool Ă€r en statisk klass, en för alla applikationer, vilket innebĂ€r att överbelastning eller felaktig anvĂ€ndning i en del av programmet kan leda till bieffekter i en annan del. Dessutom Ă€r det mycket svĂ„rt att förstĂ„ orsaken till sĂ„dana defekter. Det kan dĂ€rför finnas ett behov av att anvĂ€nda separata WorkStealingTaskScheduler i de delar av programmet dĂ€r anvĂ€ndningen av ThreadPool kan vara aggressiv och oförutsĂ€gbar.
- QueuedTaskScheduler â möjliggör att kö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.
Det finns en bra detaljerad om TaskScheduler i Microsofts blogg.
För bekvÀm felsökning av allt relaterat till uppgifter i Visual Studio finns det ett fönster för Uppgifter. I detta fönster kan du se det aktuella tillstÄndet för en uppgift och gÄ till den kodrad som för nÀrvarande körs.

PLinq och klassen Parallel
Förutom Tasks och allt som har sagts om .NET finns det ytterligare tvÄ intressanta verktyg: 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 metoden WithDegreeOfParallelism. TyvÀrr rÀcker PLinq i standardlÀge oftast inte med information om innehÄllet i din datakÀlla för att ge betydande hastighetsvinster, Ä andra sidan Àr kostnaden för att försöka mycket lÄg: du behöver bara kalla metoden AsParallel före kedjan av Linq-metoder och utföra prestanda tester. Dessutom finns det möjlighet att skicka ytterligare information om din datakÀlla till PLinq via mekanismen Partitions. Mer information kan lÀsas och .
Den statiska klassen Parallel tillhandahÄller metoder för parallell genomgÄng av en samling med Foreach, utförande av en For-slinga och körning av flera delegater i parallell Invoke. Den aktuella trÄden kommer att stoppas tills berÀkningarna Àr klara. Antalet trÄdar kan konfigureras genom att skicka ParallelOptions som sista argument. Med hjÀlp av alternativ kan du ocksÄ ange TaskScheduler och CancellationToken.
Slutsatser
NÀr jag började skriva denna artikel baserat pÄ material frÄn min presentation och information som jag samlat under min tid efterÄt, förvÀntade jag mig inte att det skulle bli sÄ mycket. Nu nÀr textredigeraren dÀr jag skriver denna artikel med ett klanderande meddelande sÀger att jag Àr pÄ sida 15, sammanfattar jag de föregÄende punkterna. Andra tricks, API:er, visuella verktyg och fallgropar kommer att diskuteras i nÀsta artikel.
Slutsatser:
- Det Àr viktigt att kÀnna till verktygen för att arbeta med trÄdar, asynkronitet och parallellism för att kunna anvÀnda resurserna pÄ moderna datorer.
- .NET har mÄnga olika verktyg för dessa ÀndamÄl
- Inte alla dök upp pÄ en gÄng, sÄ det Àr ofta möjligt att trÀffa pÄ legacy, men det finns sÀtt att konvertera gamla API:er utan större anstrÀngning.
- Arbete med trÄdar i .NET representeras av klasserna Thread och ThreadPool
- Metoderna Thread.Abort, Thread.Interrupt och Win32 API-funktionen TerminateThread Àr farliga och rekommenderas inte att anvÀnda. IstÀllet Àr det bÀttre att anvÀnda mekanismen med CancellationTokens.
- Flöden Àr en vÀrdefull resurs och deras antal Àr begrÀnsat. Det Àr viktigt att undvika situationer dÀr flödena Àr upptagna med att vÀnta pÄ hÀndelser. För detta ÀndamÄl Àr det praktiskt att anvÀnda klassen TaskCompletionSource.
- Det mest kraftfulla och avancerade verktyget i .NET för att hantera parallellism och asynkronitet Àr Task.
- C# async/await-operatörerna implementerar konceptet för icke-blockerande vÀntan.
- Distributionen av Tasks över trÄdar kan hanteras med hjÀlp av klasser som hÀrstammar frÄn TaskScheduler.
- Strukturen ValueTask kan vara anvÀndbar för att optimera hot-paths och minnesflöde.
- Fönstren Tasks och Threads i Visual Studio ger mycket anvÀndbar information för att felsöka trÄd- eller asynkron kod.
- PLinq Àr ett fantastiskt verktyg, men det kan sakna tillrÀcklig information om din datakÀlla; detta kan dock ÄtgÀrdas med hjÀlp av partitionering.
- FortsĂ€ttning följerâŠ
KĂ€lla: habr.com
