.NET: Mjete për të punuar me multithreading dhe asinkroni. Pjesa 1

Po botoj artikullin origjinal në Habr, përkthimi i të cilit është postuar në korporatë blog.

Nevoja për të bërë diçka në mënyrë asinkrone, pa pritur rezultatin këtu dhe tani, ose për të ndarë një punë të madhe midis disa njësive që e kryenin atë, ekzistonte përpara ardhjes së kompjuterëve. Me ardhjen e tyre, kjo nevojë u bë shumë e prekshme. Tani, në vitin 2019, po shkruaj këtë artikull në një laptop me një procesor Intel Core 8-bërthamë, në të cilin më shumë se njëqind procese po funksionojnë paralelisht, dhe madje edhe më shumë fije. Aty pranë është një telefon paksa i rrënuar, i blerë nja dy vjet më parë, ka një procesor 8 bërthamash në bord. Burimet tematike janë plot me artikuj dhe video ku autorët e tyre admirojnë smartfonët kryesorë të këtij viti që kanë procesorë 16 bërthamash. MS Azure ofron një makinë virtuale me një procesor 20 bërthamash dhe 128 TB RAM për më pak se 2 dollarë në orë. Fatkeqësisht, është e pamundur të nxirret maksimumi dhe të shfrytëzohet kjo fuqi pa qenë në gjendje të menaxhosh ndërveprimin e fijeve.

terminologji

Procesi - Objekti OS, hapësira e izoluar e adresave, përmban fije.
Fije - një objekt OS, njësia më e vogël e ekzekutimit, pjesë e një procesi, thread-et ndajnë memorien dhe burimet e tjera ndërmjet tyre brenda një procesi.
multitasking - Vetia e OS, aftësia për të ekzekutuar disa procese në të njëjtën kohë
Multi-core - një pronë e procesorit, aftësia për të përdorur disa bërthama për përpunimin e të dhënave
Multiprocessing - një veti e një kompjuteri, aftësia për të punuar njëkohësisht me disa procesorë fizikisht
Multithreading — vetia e një procesi, aftësia për të shpërndarë përpunimin e të dhënave midis disa fijeve.
Paralelizmi - kryerja e disa veprimeve fizikisht njëkohësisht për njësi të kohës
Asinkronia — ekzekutimi i një operacioni pa pritur përfundimin e këtij përpunimi; rezultati i ekzekutimit mund të përpunohet më vonë.

metaforë

Jo të gjitha përkufizimet janë të mira dhe disa kanë nevojë për shpjegime shtesë, kështu që unë do të shtoj një metaforë për gatimin e mëngjesit në terminologjinë e prezantuar zyrtarisht. Gatimi i mëngjesit në këtë metaforë është një proces.

Gjatë përgatitjes së mëngjesit në mëngjes unë (CPU) Unë vij në kuzhinë (kompjuter). kam 2 duar (cores). Ka një numër pajisjesh në kuzhinë (IO): furrë, kazan, thotë dolli, frigorifer. Ndez gazin, i vë një tigan dhe i hedh vaj pa pritur që të nxehet (në mënyrë asinkrone, Non-Blocking-IO-Wait), i nxjerr vezet nga frigoriferi dhe i thyej ne nje pjate dhe me pas i rrahim me nje dore (Tema #1), dhe e dyta (Tema #2) duke mbajtur pjatën (Shared Resource). Tani dua të ndez kazanin, por nuk kam duar të mjaftueshme (Thread Starvation) Gjatë kësaj kohe nxehet tigani (Përpunimi i rezultatit) në të cilin hedh atë që kam rrahur. Unë zgjatem drejt kazanit dhe e ndez dhe shikoj marrëzi ujin duke vluar në të (Bllokimi-IO-Prit), edhe pse gjatë kësaj kohe ai mund të kishte larë pjatën ku ka rrahur omëletën.

Kam gatuar një omëletë duke përdorur vetëm 2 duar dhe nuk kam më shumë, por në të njëjtën kohë në momentin e rrahjes së omëletës janë bërë 3 operacione njëherësh: rrahja e omëletës, mbajtja e pjatës, ngrohja e tiganit. CPU është pjesa më e shpejtë e kompjuterit, IO është ajo që më shpesh çdo gjë ngadalësohet, kështu që shpesh një zgjidhje efektive është të zëni CPU-në me diçka gjatë marrjes së të dhënave nga IO.

Vazhdimi i metaforës:

  • Nëse në procesin e përgatitjes së një omëlete, do të përpiqesha të ndërroja edhe rrobat, ky do të ishte një shembull i multitasking. Një nuancë e rëndësishme: kompjuterët janë shumë më të mirë në këtë se sa njerëzit.
  • Një kuzhinë me disa kuzhinierë, për shembull në një restorant - një kompjuter me shumë bërthama.
  • Shumë restorante në një gjykatë ushqimore në një qendër tregtare - qendër të dhënash

.NET Tools

.NET është i mirë në punën me threads, si me shumë gjëra të tjera. Me çdo version të ri, ai prezanton gjithnjë e më shumë mjete të reja për të punuar me to, shtresa të reja abstraksioni mbi fijet e OS. Kur punojnë me ndërtimin e abstraksioneve, zhvilluesit e kornizës përdorin një qasje që lë mundësinë, kur përdoret një abstraksion i nivelit të lartë, të zbresë një ose më shumë nivele më poshtë. Më shpesh kjo nuk është e nevojshme, në fakt ajo hap derën për të qëlluar veten në këmbë me armë gjahu, 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, nënkuptoj si ndërfaqet e programimit të aplikacioneve (API) të ofruara nga kuadri dhe paketat e palëve të treta, si dhe zgjidhje të tëra softuerësh që thjeshtojnë kërkimin për çdo problem që lidhet me kodin me shumë fije.

Fillimi i një filli

Klasa Thread është klasa më themelore në .NET për të punuar me thread-at. Konstruktori pranon një nga dy delegatët:

  • ThreadStart - Nuk ka parametra
  • ParametrizedThreadStart - me një parametër të objektit të tipit.

Delegati do të ekzekutohet në thread-in e sapokrijuar pas thirrjes së metodës Start.Nëse një delegat i tipit ParametrizedThreadStart i është kaluar konstruktorit, atëherë një objekt duhet t'i kalohet metodës Start. Ky mekanizëm është i nevojshëm për të transferuar çdo informacion lokal në transmetim. Vlen të përmendet se krijimi i një thread është një operacion i shtrenjtë dhe vetë filli është një objekt i rëndë, të paktën sepse shpërndan 1MB memorie në pirg dhe kërkon ndërveprim me API të OS.

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

Klasa ThreadPool përfaqëson konceptin e një grupi. Në.

Koncepti i përgjithshëm:

Që nga momenti i fillimit të aplikacionit, ai krijon disa fije në rezervë në sfond dhe ofron mundësinë për t'i marrë ato për përdorim. Nëse fijet përdoren shpesh dhe në numër të madh, grupi zgjerohet për të përmbushur nevojat e telefonuesit. Kur nuk ka fije të lira në pishinë në kohën e duhur, ai ose do të presë që njëra prej tyre të kthehet, ose do të krijojë një të re. Nga kjo rrjedh se grupi i temave është i shkëlqyeshëm për disa veprime afatshkurtra dhe i papërshtatshëm për operacionet që funksionojnë si shërbime gjatë gjithë funksionimit të aplikacionit.

Për të përdorur një thread nga grupi, ekziston një metodë QueueUserWorkItem që pranon një delegat të llojit WaitCallback, i cili ka të njëjtin nënshkrim si ParametrizedThreadStart dhe parametri që i kalon atij kryen të njëjtin funksion.

ThreadPool.QueueUserWorkItem(...);

Metoda më pak e njohur e grupit thread RegisterWaitForSingleObject përdoret për të organizuar operacione jo-bllokuese të IO. Delegati i kaluar në këtë metodë do të thirret kur WaitHandle e kaluar në metodë është "Released".

ThreadPool.RegisterWaitForSingleObject(...)

.NET ka një kohëmatës thread dhe ndryshon nga kohëmatësit WinForms/WPF në atë që mbajtësi i tij do të thirret në një thread të marrë nga grupi.

System.Threading.Timer

Ekziston gjithashtu një mënyrë mjaft ekzotike për të dërguar një delegat për ekzekutim në një fije nga grupi - metoda BeginInvoke.

DelegateInstance.BeginInvoke

Do të doja të ndalem shkurtimisht në funksionin në të cilin mund të quhen shumë nga metodat e mësipërme - CreateThread nga Kernel32.dll Win32 API. Ekziston një mënyrë, falë mekanizmit të metodave të jashtme, për ta thirrur këtë funksion. Një thirrje të tillë e kam parë vetëm një herë në një shembull të tmerrshëm të kodit të trashëgimisë, dhe motivimi i autorit që e bëri pikërisht këtë mbetet ende një mister për mua.

Kernel32.dll CreateThread

Shikimi dhe korrigjimi i temave

Temat e krijuara nga ju, të gjithë komponentët e palëve të treta dhe grupi .NET mund të shikohen në dritaren Threads të Visual Studio. Kjo dritare do të shfaqë informacionin e lidhjes vetëm kur aplikacioni është nën korrigjimin e gabimeve dhe në modalitetin Break. Këtu mund të shikoni me lehtësi emrat e stivës dhe prioritetet e çdo thread, dhe të kaloni korrigjimin e gabimeve në një fill specifik. Duke përdorur vetinë Priority të klasës Thread, mund të vendosni prioritetin e një thread, të cilin OC dhe CLR do ta perceptojnë si një rekomandim kur ndajnë kohën e procesorit midis thread-ve.

.NET: Mjete për të punuar me multithreading dhe asinkroni. Pjesa 1

Biblioteka paralele e detyrave

Task Parallel Library (TPL) u prezantua në .NET 4.0. Tani është standardi dhe mjeti kryesor për të punuar me asinkroninë. Çdo kod që përdor një qasje më të vjetër konsiderohet si trashëgimi. Njësia bazë e TPL është klasa Task nga hapësira e emrave System.Threading.Tasks. Një detyrë është një abstraksion mbi një fije. Me versionin e ri të gjuhës C#, ne kemi një mënyrë elegante për të punuar me Tasks - operatorët async/prit. Këto koncepte bënë të mundur shkrimin e kodit asinkron sikur të ishte i thjeshtë dhe sinkron, kjo bëri të mundur që edhe njerëzit që kishin pak njohuri për funksionimin e brendshëm të thread-eve të shkruanin aplikacione që i përdorin ato, aplikacione që nuk ngrijnë kur kryejnë operacione të gjata. Përdorimi i asinkronizimit/pritjes është një temë për një apo edhe disa artikuj, por do të përpiqem ta kuptoj thelbin e tij me disa fjali:

  • async është një modifikues i një metode që kthen Task ose void
  • dhe await është një operator i pritjes së detyrave që nuk bllokon.

Edhe një herë: operatori i pritjes, në rastin e përgjithshëm (ka përjashtime), do të lëshojë më tej fillin aktual të ekzekutimit, dhe kur Detyra të përfundojë ekzekutimin e saj, dhe fillin (në fakt, do të ishte më e saktë të thuhej konteksti , por më shumë për këtë më vonë) do të vazhdojë të ekzekutojë metodën më tej. Brenda .NET, ky mekanizëm zbatohet në të njëjtën mënyrë si kthimi i yield-it, kur metoda e shkruar kthehet në një klasë të tërë, e cila është një makinë gjendje dhe mund të ekzekutohet në pjesë të veçanta në varësi të këtyre gjendjeve. Kushdo i interesuar mund të shkruajë çdo kod të thjeshtë duke përdorur asynс/wait, të përpilojë dhe të shikojë montimin duke përdorur JetBrains dotPeek me kodin e gjeneruar nga përpiluesi i aktivizuar.

Le të shohim opsionet për nisjen dhe përdorimin e Task. Në shembullin e kodit më poshtë, ne krijojmë një detyrë të re që nuk bën asgjë të dobishme (Thread.Sleep (10000)), por në jetën reale kjo duhet të jetë një punë komplekse me intensitet të CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

Krijohet një detyrë me një numër opsionesh:

  • LongRunning është një aluzion se detyra nuk do të përfundojë shpejt, që do të thotë se mund të ia vlen të merret parasysh të mos merrni një fije nga pishina, por të krijoni një të veçantë për këtë Detyrë, në mënyrë që të mos dëmtoni të tjerët.
  • AttachedToParent - Detyrat mund të organizohen në një hierarki. Nëse ky opsion është përdorur, atëherë Detyra mund të jetë në një gjendje ku ajo vetë ka përfunduar dhe është duke pritur për ekzekutimin e fëmijëve të saj.
  • PreferFairness - do të thotë se do të ishte më mirë të ekzekutoheshin Detyrat e dërguara për ekzekutim më herët përpara atyre të dërguara më vonë. Por ky është vetëm një rekomandim dhe rezultatet nuk janë të garantuara.

Parametri i dytë i kaluar në metodë është CancellationToken. Për të trajtuar saktë anulimin e një operacioni pasi të ketë filluar, kodi që ekzekutohet duhet të plotësohet me kontrolle për gjendjen CancellationToken. Nëse nuk ka kontrolle, atëherë metoda Cancel e thirrur në objektin CancellationTokenSource do të mund të ndalojë ekzekutimin e Detyrës vetëm përpara se të fillojë.

Parametri i fundit është një objekt planifikues i tipit TaskScheduler. Kjo klasë dhe pasardhësit e saj janë krijuar për të kontrolluar strategjitë për shpërndarjen e Detyrave nëpër thread; si parazgjedhje, Detyra do të ekzekutohet në një fije të rastësishme nga grupi.

Operatori i pritjes aplikohet në detyrën e krijuar, që do të thotë se kodi i shkruar pas tij, nëse ka një të tillë, do të ekzekutohet në të njëjtin kontekst (shpesh kjo do të thotë në të njëjtën fillesë) si kodi para pritjes.

Metoda është shënuar si e pavlefshme asinkronike, që do të thotë se mund të përdorë operatorin e pritjes, por kodi i thirrjes nuk do të jetë në gjendje të presë për ekzekutimin. Nëse një veçori e tillë është e nevojshme, atëherë metoda duhet të kthejë Task. Metodat e shënuara si zbrazëti asinkronike janë mjaft të zakonshme: si rregull, këto janë trajtues të ngjarjeve ose metoda të tjera që funksionojnë në parimin e zjarrit dhe harresës. Nëse ju duhet jo vetëm të jepni mundësinë për të pritur deri në fund të ekzekutimit, por edhe të ktheni rezultatin, atëherë duhet të përdorni Task.

Në Detyrën që ktheu metoda StartNew, si dhe në çdo tjetër, mund të telefononi metodën ConfigureAwait me parametrin false, më pas ekzekutimi pas pritjes do të vazhdojë jo në kontekstin e kapur, por në një arbitrar. Kjo duhet bërë gjithmonë kur konteksti i ekzekutimit nuk është i rëndësishëm për kodin pas pritjes. Ky është gjithashtu një rekomandim nga MS kur shkruani kodin që do të dorëzohet i paketuar në një bibliotekë.

Le të ndalemi pak më shumë se si mund të presësh për përfundimin e një Detyre. Më poshtë është një shembull i kodit, me komente se kur pritshmëria është bërë me kusht mirë dhe kur është bërë me kusht keq.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

Në shembullin e parë, presim që Detyra të përfundojë pa bllokuar thread-in thirrës; ne do të kthehemi në përpunimin e rezultatit vetëm kur ai është tashmë atje; deri atëherë, filli i thirrjes lihet në pajisjet e veta.

Në opsionin e dytë, ne bllokojmë thread-in e thirrjes derisa të llogaritet rezultati i metodës. Kjo është e keqe jo vetëm sepse ne kemi zënë një thread, një burim kaq të vlefshëm të programit, me papunësi të thjeshtë, por edhe sepse nëse kodi i metodës që ne thërrasim përmban pritje, dhe konteksti i sinkronizimit kërkon kthimin në fillin thirrës pas prit, atëherë do të marrim një bllokim: Fillimi thirrës pret që të llogaritet rezultati i metodës asinkrone, metoda asinkrone përpiqet më kot të vazhdojë ekzekutimin e saj në thread-in thirrës.

Një tjetër disavantazh i kësaj qasjeje është trajtimi i komplikuar i gabimeve. Fakti është se gabimet në kodin asinkron kur përdorni async/prit janë shumë të lehta për t'u trajtuar - ato sillen njësoj sikur kodi të ishte sinkron. Ndërsa nëse zbatojmë ekzorcizmin sinkron të pritjes në një Detyrë, përjashtimi origjinal kthehet në një Përjashtim Aggregate, d.m.th. Për të trajtuar përjashtimin, do t'ju duhet të ekzaminoni llojin InnerException dhe të shkruani vetë një zinxhir if brenda një blloku të kapjes ose të përdorni kapjen kur ndërtohet, në vend të zinxhirit të blloqeve të kapjes që është më i njohur në botën C#.

Shembujt e tretë dhe të fundit gjithashtu janë shënuar keq për të njëjtën arsye dhe përmbajnë të njëjtat probleme.

Metodat WhenAny dhe WhenAll janë jashtëzakonisht të përshtatshme për pritjen e një grupi detyrash; ato mbështjellin një grup detyrash në një, i cili do të aktivizohet ose kur të aktivizohet fillimisht një Detyrë nga grupi, ose kur të gjitha të kenë përfunduar ekzekutimin e tyre.

Ndalimi i fijeve

Për arsye të ndryshme, mund të jetë e nevojshme të ndaloni rrjedhën pasi të ketë filluar. Ka një sërë mënyrash për ta bërë këtë. Klasa Thread ka dy metoda të emërtuara siç duhet: Prish и Ndërpres. E para nuk rekomandohet shumë për përdorim, sepse pas thirrjes së tij në çdo moment të rastësishëm, gjatë përpunimit të çdo udhëzimi, do të bëhet një përjashtim ThreadAbortedException. Ju nuk prisni që një përjashtim i tillë të hidhet kur rritet ndonjë ndryshore numër i plotë, apo jo? Dhe kur përdorni këtë metodë, kjo është një situatë shumë reale. Nëse keni nevojë të parandaloni që CLR të gjenerojë një përjashtim të tillë në një pjesë të caktuar të kodit, mund ta mbështillni atë në thirrje Tema.FillimiCriticalRegion, Thread.Rajoni EndCritical. Çdo kod i shkruar në një bllok përfundimtar është i mbështjellë me thirrje të tilla. Për këtë arsye, në thellësi të kodit të kornizës mund të gjeni blloqe me një provë boshe, por jo në fund të zbrazët. Microsoft e dekurajon këtë metodë aq shumë sa nuk e përfshiu atë në bërthamën .net.

Metoda Interrupt funksionon më e parashikueshme. Mund të ndërpresë fillin me një përjashtim ThreadInterruptedException vetëm gjatë atyre momenteve kur filli është në gjendje pritjeje. Ai hyn në këtë gjendje ndërsa është i varur ndërsa pret WaitHandle, kyçje ose pasi telefonon Thread.Sleep.

Të dy opsionet e përshkruara më sipër janë të këqija për shkak të paparashikueshmërisë së tyre. Zgjidhja është përdorimi i një strukture CancellationToken dhe klasës CancellationTokenSource. Çështja është kjo: krijohet një shembull i klasës CancellationTokenSource dhe vetëm ai që e zotëron mund të ndalojë operacionin duke thirrur metodën anuloj. Vetëm CancellationToken i kalohet vetë operacionit. Pronarët e CancellationToken nuk mund ta anulojnë vetë operacionin, por mund të kontrollojnë vetëm nëse operacioni është anuluar. Ekziston një pronë Boolean për këtë Është Kërkuar Anulimi dhe metodë Kërkohet Hedhja Nëse Anulohet. Ky i fundit do të bëjë një përjashtim TaskCancelledException nëse metoda Cancel thirrej në instancën CancellationToken duke u papagalluar. Dhe kjo është metoda që unë rekomandoj të përdorni. Ky është një përmirësim në krahasim me opsionet e mëparshme duke fituar kontroll të plotë se në cilën pikë mund të ndërpritet një operacion përjashtimi.

Opsioni më brutal për ndalimin e një thread është thirrja e funksionit Win32 API TerminateThread. Sjellja e CLR pas thirrjes së këtij funksioni mund të jetë e paparashikueshme. Në MSDN shkruhet në lidhje me këtë funksion: “TerminateThread është një funksion i rrezikshëm që duhet të përdoret vetëm në rastet më ekstreme. "

Konvertimi i API-së së vjetër në Task Bazuar duke përdorur metodën FromAsync

Nëse keni fatin të punoni në një projekt që filloi pasi Tasks u prezantua dhe pushoi së shkaktuari tmerr të qetë për shumicën e zhvilluesve, atëherë nuk do të duhet të merreni me shumë API të vjetra, si ato të palëve të treta, ashtu edhe ato të ekipit tuaj ka torturuar në të kaluarën. Për fat të mirë, ekipi i .NET Framework u kujdes për ne, megjithëse ndoshta qëllimi ishte të kujdeseshim për veten tonë. Sido që të jetë, .NET ka një sërë mjetesh për konvertimin pa dhimbje të kodit të shkruar në qasjet e vjetra të programimit asinkron në atë të ri. Një prej tyre është metoda FromAsync e TaskFactory. Në shembullin e kodit më poshtë, unë i mbështjell metodat e vjetra asinkronike të klasës WebRequest në një Detyrë duke përdorur këtë metodë.

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

Ky është vetëm një shembull dhe nuk ka gjasa ta bëni këtë me llojet e integruara, por çdo projekt i vjetër është thjesht i mbushur me metoda BeginDoSomething që kthejnë metodat IAsyncResult dhe EndDoSomething që e marrin atë.

Konvertoni API-në e vjetër në Task Bazuar duke përdorur klasën TaskCompletionSource

Një mjet tjetër i rëndësishëm për t'u marrë parasysh është klasa Burimi i Përfundimit të Detyrave. Për sa i përket funksioneve, qëllimit dhe parimit të funksionimit, mund të kujtojë disi metodën RegisterWaitForSingleObject të klasës ThreadPool, për të cilën shkrova më lart. Duke përdorur këtë klasë, ju mund të mbështillni lehtësisht dhe me lehtësi API-të e vjetra asinkrone në Tasks.

Ju do të thoni që unë kam folur tashmë për metodën FromAsync të klasës TaskFactory të destinuar për këto qëllime. Këtu do të duhet të kujtojmë të gjithë historinë e zhvillimit të modeleve asinkrone në .net që Microsoft ka ofruar gjatë 15 viteve të fundit: përpara Modelit Asinkron të Bazuar në Detyra (TAP), ekzistonte Modeli i Programimit Asinkron (APP), i cili kishte të bënte me metodat FillojBëj Diçka që kthehet IAsyncRezultati dhe metodat fundBëj Diçka që e pranon atë dhe për trashëgiminë e këtyre viteve, metoda FromAsync është thjesht e përsosur, por me kalimin e kohës, ajo u zëvendësua nga Modeli Asinkron i Bazuar në Ngjarje (DHE AP), i cili supozoi se një ngjarje do të ngrihej kur të përfundonte operacioni asinkron.

TaskCompletionSource është i përsosur për mbështjelljen e detyrave dhe API-ve të vjetra të ndërtuara rreth modelit të ngjarjes. Thelbi i punës së tij është si vijon: një objekt i kësaj klase ka një pronë publike të tipit Task, gjendja e së cilës mund të kontrollohet përmes metodave SetResult, SetException, etj. të klasës TaskCompletionSource. Në vendet ku operatori i pritjes është aplikuar në këtë Detyrë, ai do të ekzekutohet ose do të dështojë me një përjashtim në varësi të metodës së aplikuar në TaskCompletionSource. Nëse ende nuk është e qartë, le të shohim këtë shembull kodi, ku disa API të vjetra EAP janë mbështjellë në një Task duke përdorur një TaskCompletionSource: kur ngjarja ndizet, Detyra do të transferohet në gjendjen e përfunduar dhe metodën që aplikoi operatorin e pritjes për këtë Detyrë do të rifillojë ekzekutimin e saj pasi të ketë marrë objektin rezultat.

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

Këshilla dhe truket e TaskCompletionBurimi

Mbështjellja e API-ve të vjetra nuk është gjithçka që mund të bëhet duke përdorur TaskCompletionSource. Përdorimi i kësaj klase hap një mundësi interesante për të krijuar API të ndryshme në Tasks që nuk zënë thread. Dhe rryma, siç kujtojmë, është një burim i shtrenjtë dhe numri i tyre është i kufizuar (kryesisht nga sasia e RAM-it). Ky kufizim mund të arrihet lehtësisht duke zhvilluar, për shembull, një aplikacion ueb të ngarkuar me logjikë komplekse biznesi. Le të shqyrtojmë mundësitë për të cilat po flas kur zbatojmë një truk të tillë si Sondazhi i gjatë.

Me pak fjalë, thelbi i mashtrimit është ky: ju duhet të merrni informacion nga API për disa ngjarje që ndodhin në anën e tij, ndërsa API, për disa arsye, nuk mund të raportojë ngjarjen, por mund të kthejë vetëm gjendjen. Një shembull i tyre janë të gjitha API-të e ndërtuara në krye të HTTP para kohës së WebSocket ose kur ishte e pamundur për ndonjë arsye të përdorej kjo teknologji. Klienti mund të kërkojë serverin HTTP. Serveri HTTP nuk mund të fillojë vetë komunikimin me klientin. Një zgjidhje e thjeshtë është të anketoni serverin duke përdorur një kohëmatës, por kjo krijon një ngarkesë shtesë në server dhe një vonesë shtesë në mesataren TimerInterval / 2. Për të shmangur këtë, u shpik një truk i quajtur Long Polling, i cili përfshin vonimin e përgjigjes nga serveri derisa të skadojë afati ose do të ndodhë një ngjarje. Nëse ngjarja ka ndodhur, atëherë ajo përpunohet, nëse jo, atëherë kërkesa dërgohet përsëri.

while(!eventOccures && !timeoutExceeded)  {

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

Por një zgjidhje e tillë do të jetë e tmerrshme sapo të rritet numri i klientëve që presin eventin, sepse... Çdo klient i tillë zë një fije të tërë duke pritur për një ngjarje. Po, dhe ne marrim një vonesë shtesë prej 1 ms kur aktivizohet ngjarja, më shpesh kjo nuk është e rëndësishme, por pse ta bëjmë softuerin më keq sesa mund të jetë? Nëse heqim Thread.Sleep(1), atëherë më kot do të ngarkojmë një bërthamë procesori 100% boshe, duke u rrotulluar në një cikël të padobishëm. Duke përdorur TaskCompletionSource, mund ta rikrijoni lehtësisht këtë kod dhe të zgjidhni të gjitha problemet e identifikuara më sipër:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

Ky kod nuk është gati për prodhim, por thjesht një demo. Për ta përdorur atë në raste reale, ju duhet gjithashtu, të paktën, të trajtoni situatën kur një mesazh arrin në një kohë kur askush nuk e pret atë: në këtë rast, metoda AsseptMessageAsync duhet të kthejë një Detyrë tashmë të përfunduar. Nëse ky është rasti më i zakonshëm, atëherë mund të mendoni për përdorimin e ValueTask.

Kur marrim një kërkesë për një mesazh, ne krijojmë dhe vendosim një TaskCompletionSource në fjalor dhe më pas presim se çfarë ndodh së pari: skadon intervali kohor i specifikuar ose merret një mesazh.

ValueTask: pse dhe si

Operatorët async/wait, si operatori i kthimit të yield-it, gjenerojnë një makinë gjendjeje nga metoda dhe ky është krijimi i një objekti të ri, i cili pothuajse gjithmonë nuk është i rëndësishëm, por në raste të rralla mund të krijojë një problem. Ky rast mund të jetë një metodë që thirret vërtet shpesh, po flasim për dhjetëra e qindra mijëra thirrje në sekondë. Nëse një metodë e tillë shkruhet në atë mënyrë që në shumicën e rasteve të kthejë një rezultat duke anashkaluar të gjitha metodat e pritjes, atëherë .NET ofron një mjet për të optimizuar këtë - strukturën ValueTask. Për ta bërë të qartë, le të shohim një shembull të përdorimit të tij: ka një cache që ne shkojmë shumë shpesh. Ka disa vlera në të dhe më pas ne thjesht i kthejmë ato; nëse jo, atëherë shkojmë në një IO të ngadaltë për t'i marrë ato. Unë dua ta bëj këtë të fundit në mënyrë asinkrone, që do të thotë se e gjithë metoda rezulton të jetë asinkrone. Kështu, mënyra e qartë për të shkruar metodën ë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ë se çfarë do të gjenerojë Roslyn gjatë përpilimit të këtij kodi, mund ta rishkruani 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 optimale në këtë rast do të ishte optimizimi i shtegut të nxehtë, domethënë, marrja e një vlere nga fjalori pa ndonjë ndarje dhe ngarkesë të panevojshme në GC, ndërsa në ato raste të rralla kur ende duhet të shkojmë në IO për të dhëna , gjithçka do të mbetet një plus / minus mënyra e vjetër:

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ë hedhim një vështrim më të afërt në këtë pjesë të kodit: nëse ka një vlerë në cache, ne krijojmë një strukturë, përndryshe detyra e vërtetë do të mbështillet me një kuptimplotë. Kodi thirrës nuk i intereson se në cilën rrugë është ekzekutuar ky kod: ValueTask, nga pikëpamja sintaksore C#, do të sillet njësoj si një Detyrë e rregullt në këtë rast.

Task Schedulers: menaxhimi i strategjive të nisjes së detyrave

API tjetër që do të doja të merrja në konsideratë është klasa Programuesi i detyrave dhe derivatet e tij. E përmenda tashmë më lart se TPL ka aftësinë për të menaxhuar strategjitë për shpërndarjen e detyrave nëpër tema. Strategji të tilla janë të përcaktuara në pasardhësit e klasës TaskScheduler. Pothuajse çdo strategji që ju nevojitet mund të gjendet në bibliotekë. Paralele ExtensionsExtras, i zhvilluar nga Microsoft, por jo pjesë e .NET, por e ofruar si një paketë Nuget. Le të shohim shkurtimisht disa prej tyre:

  • CurrentThreadTaskScheduler — ekzekuton Tasks në thread-in aktual
  • LimitedConcurrencyLevelTaskScheduler — kufizon numrin e detyrave të ekzekutuara njëkohësisht nga parametri N, i cili pranohet në konstruktor
  • OrderedTask Scheduler — përkufizohet si LimitedConcurrencyLevelTaskScheduler(1), kështu që detyrat do të ekzekutohen në mënyrë sekuenciale.
  • WorkStealingTaskScheduler - zbaton punë-vjedhje qasje ndaj shpërndarjes së 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 mbingarkesa ose përdorimi i gabuar i saj në një pjesë të programit mund të çojë në efekte anësore në një tjetër. Për më tepër, është jashtëzakonisht e vështirë të kuptohet shkaku i defekteve të tilla. Se. Mund të ketë nevojë për të përdorur WorkStealingTaskScheulers të veçantë në pjesë të programit ku përdorimi i ThreadPool mund të jetë agresiv dhe i paparashikueshëm.
  • Programuesi në radhë i detyrave — ju lejon të kryeni detyra sipas rregullave të radhës prioritare
  • ThreadPerTaskScheduler — krijon një thread të veçantë për çdo Detyrë që ekzekutohet në të. Mund të jetë i dobishëm për detyrat që kërkojnë një kohë të paparashikueshme për t'u përfunduar.

Ka një detaj të mirë artikull rreth TaskScheulers në blogun e Microsoft.

Për korrigjimin e përshtatshëm të çdo gjëje që lidhet me Tasks, Visual Studio ka një dritare Tasks. Në këtë dritare mund të shihni gjendjen aktuale të detyrës dhe të kaloni në linjën e kodit që po ekzekutohet aktualisht.

.NET: Mjete për të punuar me multithreading dhe asinkroni. Pjesa 1

PLinq dhe klasa Paralele

Përveç detyrave dhe gjithçkaje që thuhet për to, në .NET ka edhe dy vegla më interesante: PLinq (Linq2Parallel) dhe klasa Parallel. E para premton ekzekutimin paralel të të gjitha operacioneve të Linq në fije të shumta. Numri i thread-eve mund të konfigurohet duke përdorur metodën e zgjerimit WithDegreeOfParallelism. Fatkeqësisht, më shpesh PLinq në modalitetin e tij të paracaktuar nuk ka informacion të mjaftueshëm në lidhje me brendësinë e burimit tuaj të të dhënave për të siguruar një rritje të konsiderueshme të shpejtësisë, nga ana tjetër, kostoja e përpjekjes është shumë e ulët: thjesht duhet të telefononi metodën AsParallel përpara zinxhiri i metodave Linq dhe ekzekutimi i testeve të performancës. Për më tepër, është e mundur t'i kaloni informacione shtesë PLinq në lidhje me natyrën e burimit tuaj të të dhënave duke përdorur mekanizmin e ndarjeve. Mund të lexoni më shumë këtu и këtu.

Klasa statike Parallel ofron metoda për përsëritjen paralelisht përmes një koleksioni Foreach, ekzekutimin e një cikli For dhe ekzekutimin e shumë delegatëve në Invoke paralele. Ekzekutimi i fillit aktual do të ndalet derisa të përfundojnë llogaritjet. Numri i thread-eve mund të konfigurohet duke kaluar ParallelOptions si argumentin e fundit. Ju gjithashtu mund të specifikoni TaskScheduler dhe CancellationToken duke përdorur opsionet.

Gjetjet

Kur fillova të shkruaj këtë artikull bazuar në materialet e raportit tim dhe informacionin që mblodha gjatë punës sime pas tij, nuk e prisja që do të kishte kaq shumë. Tani, kur redaktori i tekstit në të cilin po shkruaj këtë artikull me qortim më thotë se faqja 15 ka shkuar, unë do të përmbledh rezultatet e ndërmjetme. Truket e tjera, API-të, mjetet vizuale dhe kurthet do të trajtohen në artikullin vijues.

Konkluzione:

  • Ju duhet të dini mjetet për të punuar me fijet, asinkroninë dhe paralelizmin në mënyrë që të përdorni burimet e kompjuterëve modernë.
  • .NET ka shumë mjete të ndryshme për këto qëllime
  • Jo të gjithë u shfaqën menjëherë, kështu që shpesh mund të gjeni ato të trashëguara, megjithatë, ka mënyra për të kthyer API-të e vjetra pa shumë përpjekje.
  • Puna me threads në .NET përfaqësohet nga klasat Thread dhe ThreadPool
  • Metodat Thread.Abort, Thread.Interrupt dhe Win32 API TerminateThread janë të rrezikshme dhe nuk rekomandohen për përdorim. Në vend të kësaj, është më mirë të përdorni mekanizmin CancellationToken
  • Rrjedha është një burim i vlefshëm dhe furnizimi i tij është i kufizuar. Situatat ku temat janë të zënë duke pritur për ngjarje duhet të shmangen. Për këtë është i përshtatshëm për të përdorur klasën TaskCompletionSource
  • Mjetet më të fuqishme dhe më të avancuara .NET për të punuar me paralelizëm dhe asinkroni janë Tasks.
  • Operatorët c# async/wait zbatojnë konceptin e pritjes pa bllokim
  • Ju mund të kontrolloni shpërndarjen e detyrave nëpër tema duke përdorur klasa të rrjedhura nga TaskScheduler
  • Struktura ValueTask mund të jetë e dobishme në optimizimin e shtigjeve të nxehta dhe trafikut të kujtesës
  • Dritaret Tasks dhe Threads të Visual Studio ofrojnë shumë informacione të dobishme për korrigjimin e kodit me shumë fije ose asinkron
  • PLinq është një mjet i mrekullueshëm, por mund të mos ketë informacion të mjaftueshëm për burimin tuaj të të dhënave, por kjo mund të rregullohet duke përdorur mekanizmin e ndarjes
  • Vazhdon…

Burimi: www.habr.com

Shto një koment