Po publikoj në Habr origjinalin e artikullit, përkthimi i të cilit është vendosur në korporatën tonë .
Nevoja për të bërë diçka asinkronisht, pa pritur rezultatin këtu dhe tani, ose për të ndarë një punë të madhe midis një njësi të shumta që e kryejnë atë, ka ekzistuar edhe para se të shfaqeshin kompjuterët. Me shfaqjen e tyre, kjo nevojë u bë shumë e ndjeshme. Tani, në vitin 2019, duke shkruar këtë artikull në një laptop me procesor Intel Core 8-bërthamësh, mbi të cilin po funksionojnë njëqind procese, dhe akoma më shumë rrjedha në mënyrë paralele. Pranë, ndodhet një telefon pak i përdorur, blerë para dy vitesh, me një procesor 8-bërthamësh. Në burime të specializuara ka plot artikuj dhe video, ku autorët admirojnë smartphone-t e vitit të fundit që kanë procesorë 16-bërthamësh. MS Azure ofron një makinë virtuale me procesor 128-bërthamësh dhe 2 TB RAM për më pak se 20 $/orë. Fatkeqësisht, është e pamundur të nxirret maksimumi dhe të kontrollohet kjo fuqia pa ditur si të menaxhohet ndërveprimi i rrjedhave.
Terminologjia
Procesi (Process) — objekt i OS-së, hapësirë adresimi të izoluar, përmban rrjedha.
Rrjedha (Thread) — objekt i OS-së, njësia më e vogël e ekzekutimit, pjesë e procesit, rrjedhat ndajnë memorjen dhe burime të tjera midis tyre brenda procesit.
Multitasking — sjellja e OS-së, mundësia për të ekzekutuar disa procese në të njëjtën kohë
Multicore — sjellja e procesorit, mundësia për të përdorur disa bërthama për përpunimin e të dhënave
Multiprocessing — sjellja e kompjuterit, mundësia për të punuar fizikisht me disa procesorë në të njëjtën kohë
Multithreading — sjellja e procesit, mundësia për të shpërndarë përpunimin e të dhënave midis shumë rrjedhave.
Parallelizmi — ekzekutimi i disa veprimeve fizikisht në të njëjtën kohë në një njësi të caktuar të kohës
Asinkronizmi — ekzekutimi i një operacioni pa pritur për përfundimin e kësaj përpunimi, ndërsa rezultati mund të përpunohet më vonë.
Metafora
Jo të gjitha përkufizimet janë të mira dhe disa kërkojnë shpjegim të mëtejshëm, prandaj për terminologjinë formalisht të hyrë, do të shtoj një metaforë për përgatitjen e mëngjesit. Përgatitja e mëngjesit në këtë metaforë është proces.
Duke përgatitur mëngjesin në mëngjes, unë (CPU) shkoj në kuzhinë (Kompjuter). Kam kam du duar (Bërthamat). Në kuzhinë ka një sërë pajisjesh (IO): furra, kazan, tostier, frigorifer. Aktivizoj gazin, vendos një tigan mbi të dhe hedh aty vajin, pa pritur që të ngrohet (asinkron, Non-Blocking-IO-Wait), nxjerr vezët nga frigoriferi dhe i thyej ato në një pjatë, pastaj i çfar bëj me një dorë (Thread#1), kurse dorën tjetër (Thread#2) mbaj pjatën (Burim i Ndashshëm). Tani do ishte mirë të aktivizoja kazan, por më mungojnë duar (Thread Starvation) Ndërkohë, tigani ngrohet (Përpunimi i Rezultatit) në të cilin hedh ato që kam bërë. Arrij të kap kazan dhe ta aktivizoj dhe thjesht shikoj si e zier uji në të (Blocking-IO-Wait), megjithëse do mund të kisha larë pjatën ku thyeva omletën gjatë kësaj kohe.
Kam përgatitur omletën duke përdorur vetëm 2 duar, por nuk kam më shumë, por gjatë momentit të rrahjes së omletës ndodhnin 3 operacione njëherësh: rrahja e omletës, mbajtja e pjatës, ngrohja e tiganeve. CPU është pjesa më e shpejtë e kompjuterit, IO është ajo që ngadalëson më shpesh, prandaj shpesh një zgjidhje efektive është të angazhojmë CPU derisa të marrim të dhënat nga IO.
Duke vazhduar metaforën:
- Nëse gjatë procesit të gatimit të omletës, do të përpiqesha gjithashtu të shqea rrobat, do të ishte një shembull i multitasking. Nisb e rëndësishme: kompjuterët kanë shumë më tepër përparësi në këtë sesa njerëzit.
- Kuzhina me shumë kuzhinierë, për shembull në një restorant — një kompjuter me shumë bërthama.
- Më shumë restorante në një food court në qendrën tregtare — një qendër të dhënash
Mjetet .NET
Në punën me rrjedha, si në shumë gjëra të tjera, .NET është shumë mirë. Me çdo version të ri, ai paraqet gjithnjë e më shumë mjete të reja për punën me to, nivele të reja abstraksioni mbi rrjedhat e OS. Në ndërtimin e abstraksioneve, zhvilluesit e kornizës përdorin një qasje që lë hapësirë të zhyten në një ose disa nivele më poshtë kur përdorim një abstraksion të nivelit të lartë. Shumë shpesh në këtë nuk ka nevojë, madje kjo hap mundësinë për të gjuajtur veten në këmbë me një armë gjuetie, por ndonjëherë, në raste të rralla, mund të jetë mënyra e vetme për të zgjidhur një problem që nuk zgjidhet në nivelin aktual të abstraksionit.
Me mjete kam parasysh si ndërfaqet e programit (API) që ofrohen nga korniza dhe paketat e jashtme, ashtu edhe zgjidhje të plota programore që thjeshtojnë gjetjen e ndonjë problemi të lidhur me kodin shumëprind.
Fillimi i rrjedhës
Klasa Thread, më e thjeshtë në .NET për të punuar me rrjedha. Në konstruktori merr një nga dy delegatët:
- ThreadStart — Pa parametra
- ParametrizedThreadStart — me një parametër të tipit object.
Delegati do të ekzekutohet në një rrjedhë të sapo krijuar pas thirrjes së metodës Start, nëse në konstruktori është kaluar një delegat i tipit ParametrizedThreadStart, atëherë në metodën Start është e nevojshme të kaloni një objekt. Ky mekanizëm është i nevojshëm për kalimin e çdo informacioni lokal në rrjedhë. Duhet të theksohet se krijimi i një rrjedhe është një operacion i kushtueshëm, dhe vetë rrjedha është një objekt i rëndë, duke qenë se ndodhin ndarjen e 1MB memorie në grumbull, dhe kërkon ndërveprim me API-në e OS.
new Thread(...).Start(...);
Klasa ThreadPool përfaqëson konceptin e një pool. Në .NET, pool-i i rrjedhave është një arritje ingjinierike dhe zhvilluesit nga Microsoft kanë investuar shumë përpjekje që ai të funksionojë në mënyrën më optimale në skenarë të ndryshëm.
Koncepci i përgjithshëm:
Që nga startimi, aplikacioni në sfond krijon disa rrjedha rezerve dhe ofron mundësinë për t'i përdorur ato. Nëse rrjedhat përdoren shpesh dhe në numër të madh, atëherë pool-i zgjerohet për të plotësuar kërkesën e kodit që thërret. Kur në pool në momentin e duhur nuk ka rrjedha të lira, ai ose do të presë kthimin e një prej rrjedhave, ose do të krijojë një të re. Nga kjo ndodh që pool-i i rrjedhave është ideal për veprime të shkurtra dhe i papërshtatshëm për operacione që punojnë si shërbime gjatë gjithë kohës së funksionimit të aplikacioneve.
Për të përdorur një rrjedhë nga pool-i, ekziston metoda QueueUserWorkItem, e cila merr një delegat të tipit WaitCallback, që për sa i përket shenjës përputhet me ParametrizedThreadStart, dhe parametri që i dërgohet atij kryen të njëjtin funksion.
ThreadPool.QueueUserWorkItem(...);
Një metodë e panjohur më pak e pool-it të rrjedhave, RegisterWaitForSingleObject shërben për organizimin e operacioneve IO jo bllokuese. Delegati që i kalon në këtë metodë do të thirret kur WaitHandle i kaluar në metodë të "lirohet".
ThreadPool.RegisterWaitForSingleObject(...)
Në .NET ka një timer të rrjedhës dhe ndryshon nga timerat WinForms/WPF në atë që handlers-i i tij do të thirret në një rrjedhë të marrë nga pool-i.
System.Threading.Timer
Po ashtu ka një mënyrë mjaft ekzotike për të dërguar një delegat për ekzekutim në një rrjedhë nga pool-i — metoda BeginInvoke.
DelegateInstance.BeginInvoke
Dua të ndalem shpejt në funksionin që lidhet me shumë nga metodat e lartpërmendura — CreateThread nga Kernel32.dll Win32 API. Ekziston një mënyrë, falë mekanizmit të metodave extern, për të thirrur këtë funksion. E kam parë këtë thirrje vetëm një herë në një shembull të tmerrshëm të kodit legacy, dhe motivi i autorit që e bëri këtë vazhdon të mbetet një mister për mua.
Kernel32.dll CreateThread
Shikimi dhe debuggimi i thread-eve
Thread-et e krijuara nga ju personalisht, nga të gjitha komponentët e palëve të treta dhe nga pool-i .NET mund të shihen në dritaren Threads në Visual Studio. Kjo dritare do të shfaqë informacionin për thread-et vetëm kur aplikacioni është në procesin e debugimit dhe në mënyrën e ndaljes (Break mode). Këtu mund të shihni lehtësisht emrat dhe prioritete e secilit thread, madje edhe të kaloni debuggimin në një thread specifik. Prona Priority e klasës Thread mund të caktojë prioritetin e thread-it, që OC dhe CLR do ta interpretojnë si një rekomandim gjatë ndarjes së kohës së procesorit midis thread-eve.

Task Parallel Library
Task Parallel Library (TPL) u shfaq në .NET 4.0. Tani është standardi dhe mjeti kryesor për të punuar me asinkroninë. Çdo kod që përdor qasje më të vjetra konsiderohet legacy. Njësia kryesore e TPL është klasa Task nga hapësira e emrave System.Threading.Tasks. Task përfaqëson një abstraksion mbi thread-in. Me versionin e ri të gjuhës C#, kemi marrë një mënyrë elegant për të punuar me Task-e — operatorët async/await. Këto koncepte lejuan të shkruajmë kod asinkron si nëse do të ishte i thjeshtë dhe sinkron, dhe i dhanë mundësinë madhe njerëzve që nuk kuptojnë mirë mekanizmat e brendshëm të thread-eve të krijojnë aplikacione që nuk ngecin kur kryejnë operacione të gjata. Përdorimi i async/await është një temë për një ose madje disa artikuj, por do të përpiqem të përmbledh thelbin në disa fjali:
- async është një modifikator i metodës që kthehet një Task ose void
- dhe await është operatori i pritjes jo-bllokuese për një Task.
Një herë tjetër: operatori await, në përgjithësi (ka përjashtime), do të lirojë të rrjedhën e tanishme të ekzekutimit përpara, dhe kur Task të përfundojë ekzekutimin e tij, dhe e rrjedha (në të vërtetë është më e saktë të thuhet konteksti, por për këtë do të flasim më vonë) do të jetë e lirë do të vazhdojë ekzekutimin e metodës përpara. Brenda .NET, ky mekanizëm është implementuar ashtu siç është yield return, kur metoda e shkruar shndërrohet në një klasë të tërë, e cila është një makinë gjendjesh dhe mund të ekzekutohet në copëza të ndara në varësi të këtyre gjendjeve. Ata që janë të interesuar mund të shkruajnë ndonjë kod të thjeshtë duke përdorur async/await, ta kompilojnë dhe ta shikojnë ndërtimin me ndihmën e JetBrains dotPeek me Kodin e Gjeneruar nga Kompileri të aktivizuar.
Le të shqyrtojmë variantet e nisjes dhe përdorimit të Task. Në shembullin e kodit më poshtë, ne krijojmë një task të ri, i cili nuk bën asgjë të dobishme (Thread.Sleep(10000)), por në jetën reale, kjo duhet të jetë një punë komplekse që angazhon CPU.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
/// Kodi i veprimit do të ekzekutohet në kontekst tjetër
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
/// Kodi pas await do të ekzekutohet në kontekstin e kapur
}
Task krijohet me një sërë opsionesh:
- LongRunning — një sugjerim që tregon se task nuk do të përfundojë shpejt, dhe ndoshta, mund të mendohet të mos merret një rrjedhë nga pool, por të krijohet një e veçantë për këtë Task për të mos dëmtuar të tjerat.
- AttachedToParent — Task mund të ndërtohen në hierarki. Nëse është përdorur kjo opsion, atëherë Task mund të jetë në një gjendje, kur ai vetë është ekzekutuar dhe pret për ekzekutimin e fëmijëve.
- PreferFairness — do të thotë se do të ishte mirë të ekzekutoheshin Task të dërguar për ekzekutim më herët para atyre që janë dërguar më vonë. Por kjo është vetëm një rekomandim dhe rezultati nuk është i garantuar.
Parametri i dytë në metodë është dërguar CancellationToken. Për të siguruar që të drejtat për anulimin e operacioneve pas nisjes të merren në konsideratë, kodi që ekzekutohet duhet të jetë i mbushur me kontroll të gjendjes së CancellationToken. Nëse nuk ka kontrolle, atëherë metoda Cancel e thirrur në objektin CancellationTokenSource do të jetë në gjendje të ndalojë ekzekutimin e Task vetëm deri në momentin e nisjes.
Parametri i fundit i kaluar është objekti scheduler i tipit TaskScheduler. Klasat e këtij lloji dhe trashëgimtarët e tij janë krijuar për të menaxhuar strategjitë e shpërndarjes së Task-ëve nëpër threese. Me default, Task do të ekzekutohet në një thread të rastësishëm nga pool-i.
Operatori await është aplikuar në Task-in e krijuar, kështu që kodi i shkruar pas tij, nëse ka, do të ekzekutohet në të njëjtin kontekst (shpesh do të thotë në të njëjtin thread) si kodi para await-it.
Metoda është e shënuar si async void, që do të thotë se është e lejuar të përdoret operatori await, por kodi i thirrjes nuk do të mund të presë për përfundimin e saj. Nëse kjo mundësi nevojitet, metoda duhet të kthejë një Task. Metodat e shënuara me async void zakonisht janë menaxherë ngjarjesh ose metoda të tjera që funksionojnë sipas parimit ekzekuto dhe harro (fire and forget). Nëse është e nevojshme të mundësohet jo vetëm pritja e përfundimit, por edhe kthimi i rezultatit, atëherë duhet të përdoret Task.
Në Task-in që kthehet nga metoda StartNew, ashtu si dhe në çdo tjetër, mund të thërrasësh metodën ConfigureAwait me parametrin false, atëherë ekzekutimi pas await-it do të vazhdojë jo në kontekstin e kapur, por në një të rastësishëm. Kjo duhet të bëhet gjithmonë kur konteksti i ekzekutimit për kodin pas await-it nuk është thelbësor. Gjithashtu, kjo është një rekomandim nga MS kur shkruhet kod, që do të ofrohet në formën e një biblioteke.
Le të ndalojmë një moment për të parë se si mund të presim për përfundimin e ekzekutimit të Task-it. Më poshtë është një shembull kodi, me komente, kur pritja është bërë mjaft mirë dhe kur është bërë mjaft keq.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // e mirë
result = AsyncMethod().Result; // e keqe
AsyncMethod().Wait(); // e keqe
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // e mirë
await Task.WhenAny(tasks); // e mirë
Task.WaitAll(tasks.ToArray()); // e keqe
}
Në shembullin e parë, ne presim për përfundimin e Task-it pa bllokuar thread-in e thirrjes; do t'i kthehemi përpunimit të rezultatit vetëm kur ai të jetë i disponueshëm, derisa thirrësi të jetë në dispozicion të vet.
Në variantin e dytë, ne bllokojmë rrjedhën e thirrjes derisa të llogaritet rezultati i metodës. Kjo është e keqe jo vetëm sepse kemi zënë një rrjedhë, një burim të çmuar të programit, me një aktivitet të thjeshtë ndihmës, por gjithashtu sepse nëse në kodin e metodës që thërrasim ka një await, dhe konteksti i sinkronizimit parashikon kthimin në rrjedhën e thirrjes pas await, atëherë do të kemi një deadlock: rrjedha e thirrjes pret derisa të llogaritet rezultati i metodës asinkrone, ndërsa metoda asinkrone përpiqet kot të vazhdojë ekzekutimin e saj në rrjedhën e thirrjes.
Një tjetër disavantazh i këtij qasja është përpunimi i komplikuar i gabimeve. Çështja është se gabimet në kodin asinkron kur përdoret async/await janë shumë të lehta për t'u përpunuar — ato sillet si nëse kodi do të ishte sinkron. Ndërsa nëse aplikojmë një pritje sinkrone në një Task, përjashtimi origjinal mbështillet në një AggregateException, kështu që për përpunimin e përjashtimit do të duhet të hetojmë tipin e InnerException dhe vetë të shkruajmë një zinxhir if brenda një bloku catch, ose të përdorim strukturën catch when, në vend të një zinxhiri më të njohur të blloqeve catch në botën C#.
Shembujt e tretë dhe të fundit gjithashtu shënohen si të këqij për të njëjtën arsye dhe përmbajnë të gjitha ato të njëjtat probleme.
Metodat WhenAny dhe WhenAll janë jashtëzakonisht të dobishme në pritjen e një grupi Task’ash, ato mbështjellin grupin e Task’ave në një të vetëm, i cili do të funksionojë ose nga aktivizimi i parë të një Task-i nga grupi, ose kur të përfundojë ekzekutimi i të gjithëve.
Ndërprerja e rrjedhave
Për arsye të ndryshme, mund të ketë nevojë për ndalimin e një rrjedhe pas nisjes së saj. Për këtë ekzistojnë disa mënyra. Klasa Thread ka dy metoda me emra të përshtatshëm — këto janë Abort dhe Interrupt. E para është shumë e pakontrolluar për t'u përdorur, sepse pas thirrjes së saj, në çdo moment të rastësishëm, gjatë përpunimit të çfarëdo instrukcioni, do të hedhet një përjashtim ThreadAbortedException. A nuk e prisni që një përjashtim i tillë të ndodhte gjatë inkrementit të ndonjë variabli të plotë, apo jo? Dhe me përdorimin e kësaj metode, kjo është një situatë shumë reale. Në rast nevoje për të ndaluar CLR-në që të gjenerojë një përjashtim të tillë në një pjesë të caktuar të kodit, mund ta mbështjellim atë në thirrjet Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Çdo kod i shkruar në bllokun finally pakësohet me këto thirrje. Për këtë arsye, në thellësi të kodit të framerkës, mund të gjeni blloqe me try bosh, por jo me finally bosh. Microsoft është kaq shumë kundër përdorimit të këtij metodi, saqë nuk e përfshiu atë në .net core.
Metoda Interrupt funksionon më parashikueshmërisht. Ajo mund të ndërpresë një rrjedhë me një përjashtim. ThreadInterruptedException vetëm në ato momente kur rrjedha është në gjendje pritjeje. Një gjendje e tillë arrihet kur pezullohet në pritje WaitHandle, lock, ose pas thirrjes së Thread.Sleep.
Të dy variantet e përshkruara më sipër, janë të këqija për shkak të paparashikueshmërisë së tyre. Zgjidhja është përdorimi i strukturës CancellationToken dhe klasës CancellationTokenSource. Essenca është si më poshtë: krijohet një instancë e klasës CancellationTokenSource dhe vetëm ai që e zotëron atë, mund të ndalë operacionin duke thirrur metodën Cancel. Vetë operacioni merr vetëm CancellationToken. Ndërsa zotëruesit e CancellationToken nuk mund ta ndalin vetë operacionin, por mund vetëm të kontrollojnë nëse operacioni është ndërprerë. Për këtë ka një atribut boolean IsCancellationRequested dhe metodën ThrowIfCancelRequested. E fundit do të gjenerojë një përjashtim TaskCancelledException nëse metoda Cancel është thirrur në instancën e CancellationTokenSource. Dhe ky është metodën që unë rekomandoj të përdoret. Kjo është më mirë se variantet e mëparshme duke përfituar kontroll të plotë mbi momentet se kur operacioni mund të ndërpritet përmes një përjashtimi.
Më e dhunshmja nga të gjitha mënyrat për të ndaluar një rrjedhë është thirrja e funksionit Win32 API TerminateThread. Sjellja e CLR pas thirrjes së këtij funksioni mund të jetë e paparashikueshme. Në MSDN për këtë funksion thuhet: “TerminateThread është një funksion i rrezikshëm që duhet të përdoret vetëm në rastet më ekstreme.”
Transformimi i legacy-API në Task Based duke përdorur metodën FromAsync
Nëse keni pasur fatin të punoni në një projekt që filloi pas hyrjes së Task-ëve dhe ndalimit të shkaktimit të frikës së qetë për shumicën e zhvilluesve, atëherë nuk do t'ju duhet të merrni me shumë API të vjetra, si ato të palëve të treta ashtu edhe ato të krijuara nga ekipi juaj në të kaluarën. Fatmirësisht, ekipi i zhvillimit të .NET Framework kujdeset për ne, ndonëse ndoshta qëllimi ishte të kujdeseshin për vetveten. Si dhe të jetë, në .NET ka disa mjete për të konvertuar pa dhimbje kodin e shkruar me qasje të vjetra të programimit asinkron në të reja. Një prej tyre është metoda FromAsync e TaskFactory. Në shembullin e kodit më poshtë, unë po mbështjell metodet e vjetra asinkrone të klasës WebRequest në Task duke përdorur këtë metodë.
object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
wr.BeginGetResponse,
wr.EndGetResponse
);
Ky është vetëm një shembull dhe nuk do t'ju duhet të bëni diçka të tillë me tipet e integruara, por çdo projekt i vjetër është plot me metoda BeginDoSomething që kthejnë IAsyncResult dhe metoda EndDoSomething që i pranojnë ato.
Konvertimi i legacy-API në Task Based me ndihmën e klasës TaskCompletionSource
Një tjetër mjet i rëndësishëm për t'u shqyrtuar është klasa TaskCompletionSource. Në funksionet, qëllimin dhe parimin e punës, ajo ndoshta iu ngjan metodës RegisterWaitForSingleObject të klasës ThreadPool që kam përmendur më lart. Me këtë klasë mund të mbështjellni lehtësisht dhe në mënyrë të përshtatshme API të vjetra asinkrone në Task-e.
Mund të thoni se kam folur tashmë për metodën FromAsync të klasës TaskFactory të destinuar për këto qëllime. Këtu duhet të kujtojmë gjithë historinë e zhvillimit të modeleve asinkrone në .NET që Microsofti e ofroi gjatë 15 viteve të fundit: para Task-Based Asynchronous Pattern (TAP) ekzistonte Asynchronous Programming Pattern (APP), i cili merrej me metodat BeginDoSomething që kthejnë IAsyncResult dhe metodat EndDoSomething që i pranojnë ato dhe për legacy e asaj kohe, metoda FromAsync është pikërisht për këtë, por me kalimin e kohës, në vend të saj erdhi Event Based Asynchronous Pattern (EAP), e cila parashikonte se pas përfundimit të operacionit asinkron do të thirrej një ngjarje.
TaskCompletionSource është pikërisht çka i nevojitet për të mbështetur Task-et e një API-je të vjetër të ndërtuar rreth modelit ngjarës. Thelbi i funksionimit të tij është ky: objekti i kësaj klase ka një pronë publike të tipit Task, e cila mund të menaxhohet përmes metodave SetResult, SetException, etj. Në vendet ku është përdorur operatori await për këtë Task, ai do të ekzekutohet ose do të dështojë me një përjashtim, në varësi të metodës së aplikuar ndaj TaskCompletionSource. Nëse ende nuk është e qartë, le të shikojmë këtë shembull kodi, ku një API e vjetër nga koha e EAP përfundon në një Task duke përdorur TaskCompletionSource: kur ndodh ngjarja, Task do të kalojë në gjendjen Completed dhe metoda që aplikoi operatorin await për këtë Task do të rinisë ekzekutimin e saj duke marrë objektin. rezultati.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
Këshilla dhe Trukë për TaskCompletionSource
Mbështjellja e API-ve të vjetra nuk është e gjithë çka mund të bëjmë me TaskCompletionSource. Përdorimi i kësaj klase hap mundësi interesante për dizajnimin e API-ve të ndryshme, që janë me Task-e dhe nuk zënë procese. Dhe siç e mbajmë mend, proceset janë një burim i shtrenjtë dhe numri i tyre është i kufizuar (në thelb nga sasia e RAM-it). Ky limit është lehtësisht i arritshëm duke zhvilluar, për shembull, një aplikacion web të ngarkuar me logjikë të komplikuar biznesi. Le të shqyrtojmë ato mundësi që po flas për implementimin e një truke si Long-Polling.
Nëse të shkurtojmë, thelbi i trukut është ky: ju duhet të merrni informacion nga API mbi disa ngjarje që ndodhin anash, ndërsa API për arsye të caktuara nuk mund të njoftojë për ngjarjen, por mund vetëm të kthejë gjendjen. Një shembull i tillë është të gjitha API-të të ndërtuara mbi HTTP deri në kohët e WebSocket-it ose në rastin kur për ndonjë arsye nuk mund të përdoret kjo teknologji. Klienti mund të pyesë serverin HTTP. Serveri HTTP nuk mund të nxisë vetë komunikimin me klientin. Një zgjidhje e thjeshtë është të pyesësh serverin me një timer, por kjo krijon një ngarkesë shtesë mbi serverin dhe një vonesë shtesë në mesatare TimerInterval / 2. Për të anashkaluar këtë, u shpik një truk i quajtur Long Polling, i cili parashikon një vonesë në përgjigjen nga serveri deri në përfundimin e Timeout ose deri në ndodhin e një ngjarjeje. Nëse ka ndodhur një ngjarje, ajo përpunohet, nëse jo, kërkesa dërgohet sërish.
while(!eventOccurs && !timeoutExceeded) {
CheckTimeout();
CheckEvent();
Thread.Sleep(1);
}
Por kjo zgjidhje do të tregojë veten jashtëzakonisht keq, sapo numri i klientëve që presin një ngjarje të rritet, pasi çdo klient i tillë në pritje të ngjarjes zënë një tërë thread. Dhe ne gjithashtu marrim një vonesë shtesë prej 1ms në ndodhin e ngjarjes, shpesh kjo nuk është thelbësore, por pse ta bëjmë softuerin më të keq sesa mund të jetë? Nëse heqim Thread.Sleep(1), atëherë do të ngarkojmë një nüthë të procesorit në 100% kot duke rrotulluar në një cikël të panevojshëm. Me ndihmën e TaskCompletionSource mund të riformatojmë lehtësisht këtë kod dhe të zgjidhim të gjitha problemet e përmendura më sipër:
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);
}
}
Ky kod nuk është production-ready, por është vetëm demonstrues. Për përdorim në raste reale, duhet gjithashtu, minimum njëherë, të trajtohet situata kur mesazhi ka ardhur në momentin kur askush nuk e pret atë: në këtë rast, metoda AcceptMessageAsync duhet të kthejë tashmë një Task të përfunduar. Nëse ky rast është gjithashtu më i zakonshmi, atëherë mund të mendojmë edhe për përdorimin e ValueTask.
Kur kur marrë një kërkesë për mesazh, ne krijojmë dhe vendosim në fjalorin TaskCompletionSource, dhe pastaj presim të ndodhi ajo që ndodh më parë: të skadojë intervali i caktuar ose të merret një mesazh.
ValueTask: përse dhe si
Operatorët async/await si dhe operatori yield return gjenerate nga metoda një makinë gjendjesh, dhe kjo është krijimi i një objekti të ri, që pothuajse gjithmonë nuk ka rëndësi, por në raste të rralla mund të krijojë probleme. Ky rast mund të jetë një metodë që thirret vërtet shpesh, flasim për dhjetëra dhe qindra mijëra thirrje në sekondë. Nëse një metodë e tillë është shkruar në mënyrë që në shumicën e rasteve ajo kthen rezultatin duke shmangur të gjitha metodet await, atëherë .NET ofron një mjet për ta optimizuar atë — struktura ValueTask. Për ta bërë të kuptueshme le të shqyrtojmë një shembull të përdorimit të saj: ka një cache në të cilin hyjmë shumë shpesh. Disa vlera atje janë dhe thjesht i kthejmë, nëse jo, atëherë shkojmë për to në ndonjë IO të ngadaltë. E fundit duam ta bëjmë asinkrone, dhe kështu tërë metoda del asinkrone. Kështu, varianti i qartë i shkrimit të metodës është si më poshtë:
public async Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return val;
return await RequestById(id);
}
Për shkak të dëshirës për të optimizuar pak, dhe një frikë të lehtë mbi atë që do të gjeneronte Roslyn kur kompilonte këtë kod, mund të rishkruajmë këtë shembull si më poshtë:
public Task<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
Në të vërtetë, zgjidhja më e optimizuar në këtë rast do të jetë optimizimi i hot-path, pra marrja e vlerës nga fjalori pa alokime të panevojshme dhe ngarkesa në GC, ndërkohë që në ato raste të rralla kur na nevojitet të shkojmë në IO për të dhëna gjithçka do të mbetet plus/minus si më parë:
public ValueTask<string> GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask<string>(val);
return new ValueTask<string>(RequestById(id));
}
Le të shqyrtojmë më në detaje këtë fragment kodi: kur ka një vlerë në cache, ne krijojmë një strukturë, përndryshe, task-i real do të jetë i mbështjellë në një të vlefshëm. Kodi që e thërret nuk e ka për rëndësi se me cilin rrugë është ekzekutuar ky kod: ValueTask nga këndvështrimi i sintaksës C# do të sillet njëlloj si një Task i zakonshëm në këtë rast.
TaskScheduler’ët: menaxhimi i strategjive të startit të Task’ave
API-i tjetër që do të doja të shqyrtoja është klasa TaskScheduler dhe derivatet e tij. E përmenda më lart se në TPL ka mundësinë të menaxhosh strategjitë e shpërndarjes së Task-ëve nëpër rrjedha. Këto strategji përcaktohen në trashëgimtarët e klasës TaskScheduler. Praktikisht çdo strategji që mund të nevojitet do të gjendet në bibliotekë. ParallelExtensionsExtras, e zhvilluar nga microsoft, por që nuk është pjesë e .NET, e cila ofrohet në formën e një pakete Nuget. Le të shohim shkurtimisht disa prej tyre:
- CurrentThreadTaskScheduler — ekzekuton Task-ët në rrjedhën aktuale
- LimitedConcurrencyLevelTaskScheduler — kufizon numrin e Task-ëve që ekzekutohen njëkohësisht me parametrin N, që pranohet në konstruktor
- OrderedTaskScheduler — përcaktohet si LimitedConcurrencyLevelTaskScheduler(1), kështu që detyrat do të ekzekutohen njëra pas tjetrës.
- WorkStealingTaskScheduler — implementon qasen për shpërndarjen e detyrave. Në thelb është një ThreadPool i veçantë. Zgjidh problemin që në .NET ThreadPool është një klasë statike, një për të gjitha aplikacionet, që do të thotë se ngarkesa e tij ose përdorimi i papërshtatshëm në një pjesë të programit mund të çojë në efekte anësore në pjesë të tjera. Më tej, të kuptosh shkakun e këtyre defekteve është jashtëzakonisht e vështirë. Prandaj, mund të ekzistojë nevoja për të përdorur WorkStealingTaskScheduler të veçantë në ato pjesë të programit ku përdorimi i ThreadPool mund të jetë agresiv dhe i paparashikueshëm.
- QueuedTaskScheduler — lejon ekzekutimin e detyrave sipas rregullave të radhës me prioritetet
- ThreadPerTaskScheduler — krijon një rrjedhë të veçantë për çdo Task që ekzekutohet. Mund të jetë e dobishme për detyrat që ekzekutohen për një kohë të paparashikueshme gjatë.
Ka një artikull të mirë të detajuar për TaskScheduler-ët në blogun e microsoft.
Për debugging të lehtë të gjithçkaje që lidhet me Task-ët në Visual Studio ka dritaren Tasks. Në këtë dritare mund të shikoni gjendjen aktuale të detyrës dhe të kaloni në rreshtin e kodit që po ekzekutohet në atë moment.

PLinq dhe klasa Parallel
Përveç Task’ave dhe gjithçkaje të thënë për to në .NET, ka edhe dy mjete interesante, PLinq (Linq2Parallel) dhe klasa Parallel. E para premton ekzekutimin paralel të të gjitha operacioneve Linq në disa procese. Numri i proceseve mund të konfigurohet me metodën-zhvilluar WithDegreeOfParallelism. Fatkeqësisht, shpesh herë PLinq në modalitetin e parazgjedhur nuk ka informacion të mjaftueshëm për thelbin e burimit tuaj të të dhënave për të siguruar një përfitim të ndjeshëm të shpejtësisë, ndërsa kostoja e përpjekjes është shumë e ulët: gjithçka që duhet të bëni është të thërrisni metodën AsParallel para zinxhirit të metodave Linq dhe të bëni teste performancën. Më shumë se kaq, ekziston mundësia për t'i kaluar PLinq informacione të tjera mbi natyrën e burimit tuaj të të dhënave përmes mekanizmit të Particioneve. Më shumë informacion mund të lexoni dhe .
Klasa statike Parallel ofron metoda për përshkrimin paralel të koleksioneve me Foreach, ekzekutimin e ciklit For dhe ekzekutimin e disa delegateve në paralel me Invoke. Ekzekutimi i procesit aktual do të ndalet deri sa të përfundojnë llogaritjet. Numri i proceseve mund të konfigurohet duke kaluar ParallelOptions si argumentin e fundit. Me anë të opsioneve gjithashtu mund të specifikoni TaskScheduler dhe CancellationToken.
Përfundimet
Kur fillova të shkruaj këtë artikull nga materialet e prezantimit tim dhe informacionin që kam mbledhur gjatë punës pas tij, nuk prisja që do të rezultonte kaq shumë. Tani, kur redaktori teksti ku po shkruaj këtë artikull më thotë me keqardhje se është faqja e 15-të, do të jap përfundimet e ndërmjetme. Trukët e tjera, API-të, mjetet vizuale dhe pengesat do të shqyrtohen në artikullin e ardhshëm.
Përfundimet:
- Duhet të dini mjete për të punuar me procese, asinkronizimin dhe paralelizmin për të përdorur burimet e kompjuterëve modernë.
- Në .NET ka shumë mjete të ndryshme për këto qëllime
- Jo të gjitha ato u shfaqën njëherësh, kështu që shpesh mund të takoni legacy, megjithatë ka mënyra për të transformuar API-të e vjetra pa shumë mundim.
- Puna me procese në .NET paraqitet me klasat Thread dhe ThreadPool
- Metodat Thread.Abort, Thread.Interrupt, funksioni Win32 API TerminateThread janë të rrezikshme dhe nuk rekomandohen për t'u përdorur. Në vend të tyre është më mirë të përdorni mekanizmin e CancellationToken’ave
- Stream is a valuable resource, and their number is limited. It is essential to avoid situations where streams are busy waiting for events. To manage this effectively, it is convenient to use the TaskCompletionSource class.
- The most powerful and advanced .NET tool for working with parallelism and asynchrony is Tasks.
- The C# async/await operators implement the concept of non-blocking waiting.
- You can manage the distribution of Tasks across threads using classes derived from TaskScheduler.
- The ValueTask structure can be useful in optimizing hot paths and memory traffic.
- The Tasks and Threads windows in Visual Studio provide a lot of helpful information for debugging multithreaded or asynchronous code.
- PLinq is a cool tool, but it may not have enough information about your data source; however, this can be addressed using the partitioning mechanism.
- To be continued...
Burimi: habr.com
