.NET: Mjetet për punën me shumëfishtësi dhe asinkronizimin. Pjesa 1

Po publikoj në Habr origjinalin e artikullit, përkthimi i të cilit është vendosur në korporatën tonë blog.

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.

.NET: Mjetet për punën me shumëfishtësi dhe asinkronizimin. Pjesa 1

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 work-stealing 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 artikulli 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.

.NET: Mjetet për punën me shumëfishtësi dhe asinkronizimin. Pjesa 1

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 kĂ«tu dhe kĂ«tu.

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

Blini hosting tĂ« besueshĂ«m pĂ«r faqe interneti me mbrojtje nga DDoS, serverĂ« VPS VDS đŸ”„ Blini hosting tĂ« besueshĂ«m pĂ«r faqe interneti me mbrojtje nga DDoS, serverĂ« VPS VDS | ProHoster