.NET: Alati za rad s višenitnošću i asinkronijom. 1. dio

Izvorni članak objavljujem na Habru čiji je prijevod objavljen u korporativnom blog post.

Potreba da se nešto radi asinkrono, bez čekanja na rezultat ovdje i sada, ili da se veliki posao podijeli na nekoliko jedinica koje ga obavljaju, postojala je i prije pojave računala. Njihovom pojavom ta je potreba postala vrlo opipljiva. Sada, u 2019., ovaj članak tipkam na prijenosnom računalu s 8-jezgrenim Intel Core procesorom, na kojem paralelno radi više od stotinu procesa i još više niti. U blizini je malo otrcan telefon, kupljen prije par godina, ima procesor od 8 jezgri. Tematski resursi prepuni su članaka i videa u kojima se njihovi autori dive ovogodišnjim vodećim pametnim telefonima sa 16-jezgrenim procesorima. MS Azure nudi virtualni stroj s procesorom od 20 jezgri i 128 TB RAM-a za manje od 2 USD/sat. Nažalost, nemoguće je izvući maksimum i iskoristiti ovu snagu bez mogućnosti upravljanja interakcijom niti.

terminologija

Postupak - OS objekt, izolirani adresni prostor, sadrži niti.
Nit - OS objekt, najmanja jedinica izvršenja, dio procesa, niti dijele memoriju i druge resurse među sobom unutar procesa.
Višezadaćnost - OS svojstvo, mogućnost pokretanja nekoliko procesa istovremeno
Višejezgreni - svojstvo procesora, mogućnost korištenja nekoliko jezgri za obradu podataka
Višeprocesiranje - svojstvo računala, sposobnost fizičkog istovremenog rada s nekoliko procesora
Višenitnost — svojstvo procesa, sposobnost raspodjele obrade podataka među nekoliko niti.
Paralelizam - izvođenje više radnji fizički istovremeno u jedinici vremena
Asinhronija — izvršenje operacije bez čekanja na završetak ove obrade; rezultat izvršenja može se kasnije obraditi.

metafora

Nisu sve definicije dobre i neke trebaju dodatno objašnjenje, pa ću formalno uvedenoj terminologiji dodati metaforu o kuhanju doručka. Kuhanje doručka u ovoj metafori je proces.

Dok sam ujutro pripremao doručak (CPU) dolazim u kuhinju (Računalo). Imam 2 ruke (Jezgra). U kuhinji se nalazi niz uređaja (IO): pećnica, kuhalo za vodu, toster, hladnjak. Pustim plin, stavim tavu i ulijem ulje ne čekajući da se zagrije (asinkrono, Non-Blocking-IO-Wait), izvadim jaja iz hladnjaka i razbijem ih u tanjur, pa ih umutim jednom rukom (Nit #1), i drugo (Nit #2) držeći tanjur (dijeljeni resurs). Sada bih htio uključiti kuhalo za vodu, ali nemam dovoljno ruku (Izgladnjivanje niti) Za to vrijeme se zagrije tava (Obrada rezultata) u koju sipam umućeno. Dohvatim kuhalo za vodu i uključim ga i glupo gledam kako voda kuha u njemu (Blokiranje-IO-Čekaj), iako je za to vrijeme mogao oprati tanjur na kojem je mutio omlet.

Kuhao sam omlet koristeći samo 2 ruke, a nemam više, ali u isto vrijeme, u trenutku mućenja omleta, odvijale su se 3 radnje odjednom: mućenje omleta, držanje tanjura, zagrijavanje tave .CPU je najbrži dio računala, IO je ono što najčešće sve usporava, pa je često učinkovito rješenje okupirati CPU nečim dok prima podatke iz IO-a.

Nastavljajući metaforu:

  • Kad bih se u procesu pripreme omleta pokušao i presvući, to bi bio primjer multitaskinga. Važna nijansa: računala su u tome puno bolja od ljudi.
  • Kuhinja s nekoliko kuhara, na primjer u restoranu - višejezgreno računalo.
  • Mnogi restorani u food courtu u trgovačkom centru - podatkovnom centru

.NET alati

.NET je dobar u radu s nitima, kao i s mnogim drugim stvarima. Sa svakom novom verzijom uvodi sve više i više novih alata za rad s njima, nove slojeve apstrakcije preko OS niti. Kada rade s konstrukcijom apstrakcija, programeri okvira koriste pristup koji ostavlja mogućnost, kada se koristi apstrakcija visoke razine, da se spusti jednu ili više razina niže. Najčešće to nije potrebno, dapače otvara vrata pucanju u stopalo iz sačmarice, ali ponekad, u rijetkim slučajevima, to može biti jedini način da se riješi problem koji nije riješen na trenutnoj razini apstrakcije .

Pod alatima mislim na sučelja za programiranje aplikacija (API) koja pružaju okvir i paketi trećih strana, kao i na cijela softverska rješenja koja pojednostavljuju traženje bilo kakvih problema povezanih s kodom s više niti.

Pokretanje niti

Klasa Thread najosnovnija je klasa u .NET-u za rad s nitima. Konstruktor prihvaća jednog od dva delegata:

  • ThreadStart — Nema parametara
  • ParametrizedThreadStart - s jednim parametrom tipa object.

Delegat će se izvršiti u novostvorenoj niti nakon pozivanja metode Start. Ako je delegat tipa ParametrizedThreadStart proslijeđen konstruktoru, tada se objekt mora proslijediti metodi Start. Ovaj mehanizam je potreban za prijenos svih lokalnih informacija u tok. Vrijedno je napomenuti da je stvaranje niti skupa operacija, a sama nit je težak objekt, barem zato što alocira 1 MB memorije na stogu i zahtijeva interakciju s OS API-jem.

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

Klasa ThreadPool predstavlja koncept skupa. U .NET-u skup niti dio je inženjeringa, a programeri u Microsoftu uložili su puno truda kako bi osigurali da radi optimalno u raznim scenarijima.

Opći koncept:

Od trenutka kada se aplikacija pokrene, ona stvara nekoliko niti u rezervi u pozadini i pruža mogućnost njihovog preuzimanja za korištenje. Ako se niti koriste često iu velikom broju, skup se proširuje kako bi zadovoljio potrebe pozivatelja. Kada u skupu nema slobodnih niti u pravom trenutku, čekat će da se jedna od niti vrati ili će stvoriti novu. Iz toga slijedi da je skup niti izvrstan za neke kratkoročne radnje i slabo prikladan za operacije koje se izvode kao usluge tijekom cijelog rada aplikacije.

Za korištenje niti iz skupa postoji metoda QueueUserWorkItem koja prihvaća delegata tipa WaitCallback, koji ima isti potpis kao ParametrizedThreadStart, a parametar koji mu je proslijeđen obavlja istu funkciju.

ThreadPool.QueueUserWorkItem(...);

Manje poznata metoda skupa niti RegisterWaitForSingleObject koristi se za organiziranje neblokirajućih IO operacija. Delegat proslijeđen ovoj metodi bit će pozvan kada WaitHandle proslijeđen metodi bude “Otpušten”.

ThreadPool.RegisterWaitForSingleObject(...)

.NET ima mjerač vremena niti i razlikuje se od mjerača vremena WinForms/WPF po tome što će se njegov rukovatelj pozivati ​​na niti preuzetoj iz skupa.

System.Threading.Timer

Postoji i prilično egzotičan način slanja delegata na izvršenje u drevu iz skupa - metoda BeginInvoke.

DelegateInstance.BeginInvoke

Želio bih se ukratko zadržati na funkciji na koju se mogu pozvati mnoge od gore navedenih metoda - CreateThread iz Kernel32.dll Win32 API-ja. Postoji način, zahvaljujući mehanizmu eksternih metoda, da se ova funkcija pozove. Takav sam poziv vidio samo jednom u užasnom primjeru naslijeđenog koda, a motivacija autora koji je upravo to napravio još uvijek mi ostaje misterij.

Kernel32.dll CreateThread

Pregledavanje i otklanjanje pogrešaka niti

Niti koje ste izradili, sve komponente trećih strana i .NET skup mogu se vidjeti u prozoru Threads programa Visual Studio. Ovaj će prozor prikazati informacije o nitima samo kada je aplikacija u fazi otklanjanja pogrešaka iu načinu prekida. Ovdje možete praktično vidjeti nazive hrpa i prioritete svake niti i prebaciti otklanjanje pogrešaka na određenu nit. Koristeći svojstvo Priority klase Thread, možete postaviti prioritet niti, što će OC i CLR percipirati kao preporuku prilikom dijeljenja procesorskog vremena između niti.

.NET: Alati za rad s višenitnošću i asinkronijom. 1. dio

Paralelna biblioteka zadataka

Task Parallel Library (TPL) uveden je u .NET 4.0. Sada je to standard i glavni alat za rad s asinkronom. Svaki kôd koji koristi stariji pristup smatra se naslijeđenim. Osnovna jedinica TPL-a je klasa Task iz imenskog prostora System.Threading.Tasks. Zadatak je apstrakcija nad niti. S novom verzijom jezika C# dobili smo elegantan način rada s Tasksom - async/await operatore. Ovi su koncepti omogućili pisanje asinkronog koda kao da je jednostavan i sinkroni, što je omogućilo čak i ljudima s malo razumijevanja internog rada niti da pišu aplikacije koje ih koriste, aplikacije koje se ne smrzavaju pri izvođenju dugih operacija. Korištenje async/await tema je za jedan ili čak nekoliko članaka, ali pokušat ću iznijeti bit toga u nekoliko rečenica:

  • async je modifikator metode koja vraća Task ili void
  • a await je neblokirajući operator Task waiting.

Još jednom: operator čekanja, u općem slučaju (postoje iznimke), pustit će trenutnu nit izvršenja dalje, a kada Zadatak završi svoje izvršenje, i nit (zapravo, ispravnije bi bilo reći kontekst , ali više o tome kasnije) će nastaviti s izvršavanjem metode dalje. Unutar .NET-a, ovaj mehanizam je implementiran na isti način kao i yield return, kada se napisana metoda pretvara u cijelu klasu, koja je stroj stanja i može se izvršiti u zasebnim dijelovima, ovisno o tim stanjima. Svatko tko je zainteresiran može napisati bilo koji jednostavan kod koristeći asyñn/await, prevesti i pregledati sklop koristeći JetBrains dotPeek s omogućenim kodom koji generira kompilator.

Pogledajmo opcije za pokretanje i korištenje Taska. U donjem primjeru koda stvaramo novi zadatak koji ne radi ništa korisno (Thread.Sleep(10000)), ali u stvarnom životu ovo bi trebao biti složen CPU-intenzivan posao.

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
}

Zadatak se stvara s nekoliko opcija:

  • LongRunning je nagovještaj da zadatak neće biti brzo dovršen, što znači da možda vrijedi razmisliti o tome da ne uzimate nit iz skupa, već kreirate zasebnu nit za ovaj zadatak kako ne biste naštetili drugima.
  • AttachedToParent - Zadaci se mogu poredati u hijerarhiju. Ako je korištena ova opcija, tada je zadatak možda u stanju u kojem je sam dovršen i čeka na izvršenje svojih potomaka.
  • PreferFairness - znači da bi bilo bolje izvršiti zadatke poslane na izvršenje ranije prije onih koji su poslani kasnije. Ali ovo je samo preporuka i rezultati nisu zajamčeni.

Drugi parametar proslijeđen metodi je CancellationToken. Za ispravno rukovanje otkazivanjem operacije nakon što je započela, kôd koji se izvršava mora biti ispunjen provjerama stanja CancellationToken. Ako nema provjera, tada će metoda Cancel pozvana na objektu CancellationTokenSource moći zaustaviti izvršenje zadatka samo prije nego što započne.

Posljednji parametar je objekt planera tipa TaskScheduler. Ova klasa i njezini potomci dizajnirani su za kontrolu strategija za distribuciju zadataka kroz niti; prema zadanim postavkama, zadatak će se izvršiti na nasumičnim nitima iz skupa.

Operator čekanja primjenjuje se na kreirani zadatak, što znači da će se kod napisan nakon njega, ako postoji, izvršiti u istom kontekstu (često to znači na istoj niti) kao kod prije čekanja.

Metoda je označena kao async void, što znači da može koristiti operator čekanja, ali pozivni kod neće moći čekati izvršenje. Ako je takva značajka neophodna, tada metoda mora vratiti zadatak. Metode označene kao async void prilično su česte: u pravilu su to rukovatelji događajima ili druge metode koje rade na principu pali i zaboravljaj. Ako trebate ne samo dati priliku čekati do kraja izvršenja, već i vratiti rezultat, tada morate koristiti Task.

Na zadatku koji je metoda StartNew vratila, kao i na bilo kojem drugom, možete pozvati metodu ConfigureAwait s lažnim parametrom, tada će se izvršavanje nakon čekanja nastaviti ne na snimljenom kontekstu, već na proizvoljnom. Ovo uvijek treba učiniti kada kontekst izvršenja nije važan za kôd nakon čekanja. Ovo je također preporuka MS-a pri pisanju koda koji će biti isporučen pakiran u biblioteci.

Zadržimo se još malo na tome kako možete čekati završetak zadatka. Ispod je primjer koda, s komentarima o tome kada je očekivanje izvedeno uvjetno dobro, a kada je uvjetno loše.

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
}

U prvom primjeru čekamo da se Zadatak završi bez blokiranja pozivajuće niti; vratit ćemo se na obradu rezultata tek kada je već tu; do tada je pozivna nit prepuštena sama sebi.

U drugoj opciji blokiramo pozivajuću nit dok se ne izračuna rezultat metode. To je loše ne samo zato što smo nit, tako vrijedan resurs programa, zauzeli jednostavnim mirovanjem, već i zato što ako kod metode koju pozivamo sadrži await, a kontekst sinkronizacije zahtijeva vraćanje na nit poziva nakon čekaj, tada ćemo dobiti zastoj: Pozivajuća nit čeka dok se izračunava rezultat asinkrone metode, asinkrona metoda uzalud pokušava nastaviti svoje izvršenje u pozivajućoj niti.

Još jedan nedostatak ovog pristupa je komplicirano rukovanje greškama. Činjenica je da je pogreške u asinkronom kodu kada se koristi async/await vrlo lako riješiti - ponašaju se isto kao da je kod sinkroni. Dok ako primijenimo egzorcizam sinkronog čekanja na zadatak, izvorna iznimka pretvara se u AggregateException, tj. Da biste riješili iznimku, morat ćete ispitati tip InnerException i sami napisati if lanac unutar jednog catch bloka ili koristiti catch when konstrukciju, umjesto lanca catch blokova koji je poznatiji u C# svijetu.

Treći i posljednji primjer također su označeni kao loši iz istog razloga i sadrže sve iste probleme.

Metode WhenAny i WhenAll izuzetno su prikladne za čekanje grupe zadataka; one omotaju grupu zadataka u jedan, koji će se pokrenuti ili kada se zadatak iz grupe prvi put pokrene ili kada svi dovrše svoje izvršenje.

Zaustavljanje niti

Iz različitih razloga može biti potrebno zaustaviti protok nakon što je započeo. Postoji nekoliko načina za to. Klasa Thread ima dvije odgovarajuće imenovane metode: prekid и Prekid. Prvi se izrazito ne preporučuje za korištenje, jer nakon pozivanja u bilo kojem slučajnom trenutku, tijekom obrade bilo koje instrukcije, bit će bačena iznimka ThreadAbortedException. Ne očekujete da će se takva iznimka pojaviti prilikom povećanja bilo koje cjelobrojne varijable, zar ne? A kada se koristi ova metoda, ovo je vrlo stvarna situacija. Ako trebate spriječiti CLR da generira takvu iznimku u određenom odjeljku koda, možete to zamotati u pozive Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Svaki kod napisan u finally bloku je omotan u takve pozive. Iz tog razloga, u dubini okvirnog koda možete pronaći blokove s praznim pokušajem, ali ne i praznim konačno. Microsoft toliko obeshrabruje ovu metodu da je nisu uključili u .net core.

Metoda prekida radi predvidljivije. Može prekinuti nit s iznimkom ThreadInterruptedException samo u onim trenucima kada je nit u stanju čekanja. Ulazi u ovo stanje dok visi dok čeka na WaitHandle, zaključavanje ili nakon poziva Thread.Sleep.

Obje gore opisane opcije su loše zbog svoje nepredvidljivosti. Rješenje je korištenje strukture CancellationToken i klasa CancellationTokenSource. Poanta je sljedeća: stvorena je instanca klase CancellationTokenSource i samo onaj tko je posjeduje može zaustaviti operaciju pozivom metode Otkazati. Samo se CancellationToken prosljeđuje samoj operaciji. Vlasnici CancellationTokena ne mogu sami otkazati operaciju, već mogu samo provjeriti je li operacija otkazana. Za to postoji Boolean svojstvo Zatraženo je otkazivanje i metoda ThrowIfCancelRequested. Potonji će izbaciti iznimku TaskCancelledException ako je metoda Cancel pozvana na instanci CancellationToken koja se ponavlja. I ovo je metoda koju preporučujem. Ovo je poboljšanje u odnosu na prethodne opcije stjecanjem potpune kontrole nad time u kojem trenutku se operacija izuzetka može prekinuti.

Najbrutalnija opcija za zaustavljanje niti je pozivanje funkcije Win32 API TerminateThread. Ponašanje CLR-a nakon poziva ove funkcije može biti nepredvidivo. Na MSDN-u o ovoj funkciji piše sljedeće: “TerminateThread je opasna funkcija koja bi se trebala koristiti samo u najekstremnijim slučajevima. “

Pretvaranje naslijeđenog API-ja u temeljen na zadatku pomoću metode FromAsync

Ako ste dovoljno sretni da radite na projektu koji je započet nakon uvođenja Tasksa i prestao izazivati ​​tihi užas kod većine programera, tada se nećete morati nositi s puno starih API-ja, kako onih trećih strana, tako i onih vašeg tima je mučio u prošlosti. Srećom, ekipa .NET Frameworka se pobrinula za nas, iako je možda cilj bio da se pobrinemo za sebe. Bilo kako bilo, .NET ima niz alata za bezbolno pretvaranje koda napisanog u starim pristupima asinkronog programiranja u novi. Jedna od njih je FromAsync metoda TaskFactory. U donjem primjeru koda omatam stare asinkrone metode klase WebRequest u zadatak pomoću ove metode.

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

Ovo je samo primjer i malo je vjerojatno da ćete to morati učiniti s ugrađenim tipovima, ali svaki stari projekt jednostavno vrvi BeginDoSomething metodama koje vraćaju IAsyncResult i EndDoSomething metodama koje ga primaju.

Pretvorite naslijeđeni API u Task Based pomoću klase TaskCompletionSource

Drugi važan alat koji treba uzeti u obzir je klasa TaskCompletionSource. Što se tiče funkcija, namjene i principa rada, može donekle podsjećati na metodu RegisterWaitForSingleObject klase ThreadPool, o kojoj sam gore pisao. Koristeći ovu klasu, možete jednostavno i praktično omotati stare asinkrone API-je u Zadatke.

Reći ćete da sam već govorio o metodi FromAsync klase TaskFactory namijenjenoj za ove svrhe. Ovdje ćemo se morati prisjetiti cijele povijesti razvoja asinkronih modela u .net-u koje je Microsoft ponudio u proteklih 15 godina: prije Task-Based Asynchronous Pattern (TAP), postojao je Asynchronous Programming Pattern (APP), koji bilo o metodama PočetiUčini nešto što se vraća IAsyncResult i metode KrajDoSomething koji to prihvaća i za naslijeđe ovih godina FromAsync metoda je jednostavno savršena, ali s vremenom ju je zamijenio asinkroni uzorak temeljen na događajima (EAP), koji pretpostavlja da će se događaj pokrenuti kada asinkrona operacija završi.

TaskCompletionSource savršen je za omatanje zadataka i naslijeđenih API-ja izgrađenih oko modela događaja. Suština njegovog rada je sljedeća: objekt ove klase ima javno svojstvo tipa Zadatak, čije se stanje može kontrolirati pomoću metoda SetResult, SetException itd. klase TaskCompletionSource. Na mjestima gdje je operator čekanja primijenjen na ovaj zadatak, bit će izvršen ili neće uspjeti uz iznimku ovisno o metodi primijenjenoj na TaskCompletionSource. Ako još uvijek nije jasno, pogledajmo ovaj primjer koda, gdje je neki stari EAP API umotan u zadatak pomoću TaskCompletionSource: kada se događaj pokrene, zadatak će biti prebačen u stanje Dovršeno, a metoda koja je primijenila operator čekanja na ovaj će zadatak nastaviti s izvršenjem nakon što primi objekt rezultirati.

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

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

    result completionSource.Task;
}

TaskCompletionSource Savjeti i trikovi

Omotavanje starih API-ja nije sve što se može učiniti pomoću TaskCompletionSource. Korištenje ove klase otvara zanimljivu mogućnost dizajniranja različitih API-ja na Zadacima koji ne zauzimaju niti. A stream je, kao što se sjećamo, skup resurs i njihov je broj ograničen (uglavnom količinom RAM-a). Ovo ograničenje može se lako postići razvojem, na primjer, učitane web aplikacije sa složenom poslovnom logikom. Razmotrimo mogućnosti o kojima govorim pri implementaciji takvog trika kao što je Long-Polling.

Ukratko, bit trika je sljedeća: od API-ja trebate primiti informaciju o nekim događajima koji se događaju na njegovoj strani, dok API iz nekog razloga ne može prijaviti događaj, već može samo vratiti stanje. Primjer za to su svi API-ji izgrađeni povrh HTTP-a prije vremena WebSocketa ili kada je iz nekog razloga bilo nemoguće koristiti ovu tehnologiju. Klijent može pitati HTTP poslužitelj. HTTP poslužitelj ne može sam započeti komunikaciju s klijentom. Jednostavno rješenje je anketiranje poslužitelja pomoću mjerača vremena, ali to stvara dodatno opterećenje poslužitelja i dodatno kašnjenje u prosjeku TimerInterval / 2. Kako bi se to zaobišlo, izmišljen je trik pod nazivom Dugo prozivanje, koji uključuje odgodu odgovora od poslužitelj dok ne istekne Timeout ili se dogodi događaj. Ako se događaj dogodio, onda se obrađuje, ako nije, onda se zahtjev ponovno šalje.

while(!eventOccures && !timeoutExceeded)  {

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

Ali takvo će se rješenje pokazati užasnim čim se poveća broj klijenata koji čekaju događaj, jer... Svaki takav klijent zauzima cijelu nit čekajući događaj. Da, i dobivamo dodatnu odgodu od 1ms kada se događaj pokrene, najčešće to nije značajno, ali zašto softver činiti gorim nego što može biti? Ako uklonimo Thread.Sleep(1), onda ćemo uzalud opteretiti jednu jezgru procesora 100% u stanju mirovanja, rotirajući se u beskorisnom ciklusu. Koristeći TaskCompletionSource možete jednostavno preraditi ovaj kod i riješiti sve gore navedene probleme:

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);
    }
}

Ovaj kod nije spreman za proizvodnju, već samo demo. Da biste je koristili u stvarnim slučajevima, također trebate, u najmanju ruku, riješiti situaciju kada poruka stigne u vrijeme kada je nitko ne očekuje: u ovom slučaju, metoda AsseptMessageAsync trebala bi vratiti već dovršeni zadatak. Ako je ovo najčešći slučaj, možete razmisliti o korištenju ValueTaska.

Kada primimo zahtjev za porukom, kreiramo i smjestimo TaskCompletionSource u rječnik, a zatim čekamo što se prvo dogodi: istekne navedeni vremenski interval ili se primi poruka.

ValueTask: zašto i kako

Operatori async/await, poput operatora yield return, generiraju stanje stroja iz metode, a to je stvaranje novog objekta, što gotovo uvijek nije važno, ali u rijetkim slučajevima može stvoriti problem. Ovaj slučaj može biti metoda koja se doista često poziva, govorimo o desecima i stotinama tisuća poziva u sekundi. Ako je takva metoda napisana na takav način da u većini slučajeva vraća rezultat zaobilazeći sve metode čekanja, tada .NET pruža alat za optimizaciju - strukturu ValueTask. Kako bi bilo jasnije, pogledajmo primjer njegove upotrebe: postoji predmemorija koju vrlo često posjećujemo. Postoje neke vrijednosti u njemu i onda ih jednostavno vratimo; ako ne, onda idemo na neki spori IO da ih dobijemo. Ovo drugo želim učiniti asinkrono, što znači da cijela metoda ispada asinkrona. Dakle, očiti način za pisanje metode je sljedeći:

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

Zbog želje za malom optimizacijom i blagog straha od toga što će Roslyn generirati prilikom kompajliranja ovog koda, ovaj primjer možete prepisati na sljedeći način:

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

Doista, optimalno rješenje u ovom slučaju bilo bi optimizirati hot-path, odnosno dobivanje vrijednosti iz rječnika bez ikakvih nepotrebnih dodjela i opterećenja GC-a, dok u onim rijetkim slučajevima kada ipak trebamo ići na IO po podatke , sve će ostati plus/minus na stari način:

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

Pogledajmo pobliže ovaj dio koda: ako postoji vrijednost u predmemoriji, stvaramo strukturu, inače će pravi zadatak biti umotan u smisleni. Pozivnom kodu nije važno na kojoj je stazi ovaj kod izvršen: ValueTask će se, sa stajališta C# sintakse, u ovom slučaju ponašati isto kao obični zadatak.

TaskSchedulers: upravljanje strategijama pokretanja zadataka

Sljedeći API koji bih želio razmotriti je klasa zadatak Planer i njegove izvedenice. Već sam spomenuo gore da TPL ima mogućnost upravljanja strategijama za distribuciju zadataka kroz niti. Takve su strategije definirane u potomcima klase TaskScheduler. Gotovo svaka strategija koja vam može zatrebati može se pronaći u knjižnici. ParallelExtensionsExtras, koji je razvio Microsoft, ali nije dio .NET-a, već se isporučuje kao Nuget paket. Osvrnimo se ukratko na neke od njih:

  • CurrentThreadTaskScheduler — izvršava Zadatke na trenutnoj niti
  • LimitedConcurrencyLevelTaskScheduler — ograničava broj zadataka koji se izvode istovremeno parametrom N, koji je prihvaćen u konstruktoru
  • OrderedTaskScheduler — definira se kao LimitedConcurrencyLevelTaskScheduler(1), tako da će se zadaci izvršavati sekvencijalno.
  • WorkStealingTaskScheduler - pribor rad-krađa pristup raspodjeli zadataka. U biti to je zaseban ThreadPool. Rješava problem što je u .NET ThreadPool statična klasa, jedna za sve aplikacije, što znači da njeno preopterećenje ili nepravilna uporaba u jednom dijelu programa može dovesti do nuspojava u drugom. Štoviše, vrlo je teško razumjeti uzrok takvih nedostataka. Da. Može postojati potreba za korištenjem zasebnih WorkStealingTaskSchedulera u dijelovima programa gdje korištenje ThreadPoola može biti agresivno i nepredvidivo.
  • QueuedTaskScheduler — omogućuje izvršavanje zadataka u skladu s pravilima čekanja prioriteta
  • ThreadPerTaskScheduler — stvara zasebnu nit za svaki zadatak koji se na njoj izvršava. Može biti korisno za zadatke za koje je potrebno nepredvidivo dugo vrijeme.

Postoji dobar detalj članak o TaskSchedulerima na Microsoftovom blogu.

Za prikladno otklanjanje pogrešaka svega što je povezano sa Zadacima, Visual Studio ima prozor Zadaci. U ovom prozoru možete vidjeti trenutno stanje zadatka i skočiti na redak koda koji se trenutno izvršava.

.NET: Alati za rad s višenitnošću i asinkronijom. 1. dio

PLinq i klasa Parallel

Uz Tasks i sve rečeno o njima, u .NET-u postoje još dva zanimljiva alata: PLinq (Linq2Parallel) i klasa Parallel. Prvi obećava paralelno izvođenje svih Linq operacija na više niti. Broj niti se može konfigurirati pomoću metode proširenja WithDegreeOfParalleism. Nažalost, najčešće PLinq u svom zadanom načinu rada nema dovoljno informacija o unutarnjim dijelovima vašeg izvora podataka kako bi osigurao značajno povećanje brzine, s druge strane, cijena pokušaja je vrlo niska: samo trebate pozvati AsParallel metodu prije nego lanac Linq metoda i pokrenite testove performansi. Štoviše, moguće je prenijeti dodatne informacije u PLinq o prirodi vašeg izvora podataka pomoću mehanizma particija. Možete pročitati više здесь и здесь.

Statička klasa Parallel pruža metode za paralelno ponavljanje kroz kolekciju Foreach, izvršavanje For petlje i izvršavanje više delegata u paralelnom Invokeu. Izvršenje trenutne niti će biti zaustavljeno dok se izračuni ne završe. Broj niti se može konfigurirati prosljeđivanjem ParallelOptions kao zadnjeg argumenta. Također možete odrediti TaskScheduler i CancellationToken pomoću opcija.

Zaključci

Kada sam počeo pisati ovaj članak na temelju materijala mog izvješća i informacija koje sam prikupio tijekom rada nakon njega, nisam očekivao da će ga biti toliko. Sada, kad mi uređivač teksta u kojem upisujem ovaj članak prijekorno javi da je stranica 15 nestala, rezimirat ću međurezultate. Drugi trikovi, API-ji, vizualni alati i zamke bit će obrađeni u sljedećem članku.

Zaključak:

  • Morate poznavati alate za rad s nitima, asinkroniju i paralelizam kako biste mogli koristiti resurse modernih računala.
  • .NET ima mnogo različitih alata za te svrhe
  • Nisu se svi pojavili odjednom, pa često možete pronaći naslijeđene, no postoje načini za pretvaranje starih API-ja bez puno napora.
  • Rad s nitima u .NET-u predstavljen je klasama Thread i ThreadPool
  • Metode Thread.Abort, Thread.Interrupt i Win32 API TerminateThread su opasne i ne preporučuju se za korištenje. Umjesto toga, bolje je koristiti mehanizam CancellationToken
  • Protok je vrijedan resurs i njegova je ponuda ograničena. Treba izbjegavati situacije u kojima su niti zauzete čekanjem događaja. Za to je zgodno koristiti klasu TaskCompletionSource
  • Najmoćniji i najnapredniji .NET alati za rad s paralelizmom i asinkronom su Zadaci.
  • C# async/await operatori implementiraju koncept neblokirajućeg čekanja
  • Možete kontrolirati distribuciju zadataka kroz niti pomoću klasa izvedenih iz TaskSchedulera
  • Struktura ValueTask može biti korisna u optimizaciji vrućih staza i memorijskog prometa
  • Prozori Zadaci i Niti Visual Studija pružaju puno korisnih informacija za otklanjanje pogrešaka u višenitnom ili asinkronom kodu
  • PLinq je super alat, ali možda neće imati dovoljno informacija o vašem izvoru podataka, ali to se može popraviti pomoću mehanizma particioniranja
  • Da bi se nastavio ...

Izvor: www.habr.com

Dodajte komentar