.NET: Alati za rad s višedretvenošću i asinhronim operacijama. Dio 1

Objavljujem na Habr original članka, čiji je prijevod objavljen u korporativnom blogu.

Potrebno je raditi nešto asinkrono, ne čekajući rezultat ovdje i sada, ili podijeliti veliki posao među nekoliko jedinica koje ga obavljaju, bilo je i prije nego što su računala postojala. S njihovim dolaskom, ta potreba postala je vrlo očita. Sada, u 2019. godini, pišući ovaj članak na prijenosnom računalu s 8-jezgrenim procesorom Intel Core, na kojem paralelno radi ne jedna stotina procesa, a niti tijekova još više. Pored toga, leži malo izgreban telefon kupljen prije nekoliko godina, koji ima 8-jezgreni procesor. Na specijaliziranim resursima ima puno članaka i videa, gdje njihovi autori veličaju flagship pametne telefone ove godine koji imaju 16-jezgrene procesore. MS Azure nudi virtualnu mašinu s 128-jezgrenim procesorom i 2 TB RAM-a za manje od 20 $/sat. Nažalost, nije moguće izvući maksimum i ukrotiti tu snagu ne znajući upravljati interakcijama tijekova.

Terminologija

Proces (Process) — objekt OS-a, izolirani adresni prostor, sadrži tijekove.
Tijek (Thread) — objekt OS-a, najmanja jedinica izvršavanja, dio procesa, tijekovi dijele memoriju i druge resurse međusobno unutar procesa.
Višezadaćnost — svojstvo OS-a, mogućnost izvođenja nekoliko procesa istovremeno
Višejezgrenost — svojstvo procesora, mogućnost korištenja više jezgri za obradu podataka
Višenamjenska obrada — svojstvo računala, mogućnost istovremnog rada s nekoliko fizičkih procesora
Višestruko izvršavanje — svojstvo procesa, mogućnost raspodjele obrade podataka među nekoliko tijekova.
Paralelnost — izvršavanje nekoliko radnji fizički istovremeno u jedinici vremena
Asinkronost — izvršavanje operacije bez čekanja na završetak te obrade, rezultat se može obraditi kasnije.

Metafora

Nisu sve definicije dobre i neke zahtijevaju dodatna objašnjenja, stoga ću formalno uvedenoj terminologiji dodati metaforu o pripremi doručka. Priprema doručka u ovoj metafori — proces.

Pripremajući doručak ujutro ja (CPU) dolazim u kuhinju (Računalo). Imam 2 ruke (Jezgre). U kuhinji postoji niz uređaja (IO): peć, kuhalo, toster, hladnjak. Uključujem plin, stavim tiganj i nalijem ulje, ne čekajući da se zagrije (asinkrono, Non-Blocking-IO-Wait), vadim jaja iz hladnjaka i razbijam ih u tanjur, nakon čega ih miksam jednom rukom (Thread#1), dok drugom (Thread#2) držim tanjur (Shared Resource). Sada bih mogao uključiti kuhalo, ali mi nedostaje ruku (Thread Starvation) Dok se tiganj zagrijava (Obrada rezultata) u njega ulijem ono što sam miksao. Dosežem kuhalo i uključujem ga i jednostavno gledam kako voda u njemu zavre (Blocking-IO-Wait), iako bih u to vrijeme mogao oprati tanjur u kojem sam miksao omlet.

Pravio sam omlet koristeći samo 2 ruke, a više ih nemam, ali u trenutku miksanja omleta događale su se 3 operacije: miksanje omleta, držanje tanjura, zagrijavanje tiganja. CPU je najbrži dio računala, IO je ono što najčešće usporava, stoga je često učinkovito rješenje zadržati CPU zauzet dok se podaci primaju s IO.

Nastavljajući metaforu:

  • Da sam tijekom izrade omleta pokušavao promijeniti odjeću, to bi bio primjer multitasking-a. Važna nijansa: kod računala je to daleko bolje nego kod ljudi.
  • Kuhinja s nekoliko kuhara, kao u restoranu — višekratni računalni sustav.
  • Mnoštvo restorana na food courtu u trgovačkom centru — data centar.

Alati .NET

U radu s nitima, kao i u mnogim drugim stvarima, .NET je dobar. Svakom novom verzijom predstavlja sve više novih alata za rad s njima, nove slojeve apstrakcije nad nitima OS-a. U radu na izgradnji apstrakcija, programeri frameworka koriste pristup koji omogućava korištenje visokog razine apstrakcije, ali i spuštanje na jedan ili više nižih nivoa. Često u tome nema potrebe, više od toga otvara mogućnost za samopucanje iz puške, ali ponekad, u rijetkim slučajevima, može se pokazati kao jedini način da se problem riješi, koji se ne rješava na trenutnoj razini apstrakcije.

Pod alatima mislim na programske sučelje (API) koje pruža framework i vanjski paketi, kao i cijela programska rješenja koja olakšavaju pronalazak problema povezanih s višedretvenim kodom.

Pokretanje niti

Klasa Thread, najosnovnija u .NET za rad sa threadovima. U konstruktor prima jedan od dva delegata:

  • ThreadStart — Bez parametara
  • ParametrizedThreadStart — s jednim parametrom tipa object.

Delegat će biti izvršen u novom threadu nakon poziva metode Start; ako je u konstruktor proslijeđen delegat tipa ParametrizedThreadStart, u metodu Start potrebno je proslijediti objekt. Ovaj mehanizam je potreban za prijenos bilo kojih lokalnih informacija u thread. Važno je napomenuti da je kreiranje threada operacija koja troši mnogo resursa, a sam thread je težak objekt, barem zato što zahtijeva alokaciju 1MB memorije na stogu i potrebno je interakcije s OS API-jem.

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

Klasa ThreadPool predstavlja koncept bazena. U .NET-u, bazen threadova je plod inženjerskog umijeća i Microsoftovi su programeri uložili mnogo truda kako bi osigurali da optimalno radi u raznim scenarijima.

Opšta koncepcija:

Od trenutka pokretanja, aplikacija u pozadini stvara nekoliko threadova za rezervu i omogućava njihovo korišćenje. Ako se threadovi često koriste i u velikim količinama, bazen se širi kako bi zadovoljio potrebu pozivnog koda. Kada u bazenu u određenom trenutku nema slobodnih threadova, on ili čeka da jedan od njih bude vraćen ili stvara novi. Iz ovoga proizlazi da je bazen threadova odličan za kratke akcije, a loš za operacije koje rade kao servisi tokom cijelog rada aplikacija.

Za korišćenje threadova iz bazena, postoji metoda QueueUserWorkItem, koja prima delegat tipa WaitCallback, koji po potpisu odgovara ParametrizedThreadStart, a proslijeđeni parametar u njemu vrši istu funkciju.

ThreadPool.QueueUserWorkItem(...);

Mnogo manje poznata metoda bazena threadova RegisterWaitForSingleObject služi za organizovanje neblokirajućih IO operacija. Delegat proslijeđen u ovu metodu će biti pozvan kada se WaitHandle proslijeđen u metodu 'otpusti' (Released).

ThreadPool.RegisterWaitForSingleObject(...);

U .NET-u postoji thread timer koji se razlikuje od timera WinForms/WPF time što će njegov obrtnik biti pozvan u threadu uzetom iz bazena.

System.Threading.Timer

Također postoji prilično egzotičan način slanja delegata na izvršenje u thread iz bazena — metoda BeginInvoke.

DelegateInstance.BeginInvoke

Želim još malo spomenuti funkciju na koju se oslanja većina od gorenavedenih metoda — CreateThread iz Kernel32.dll Win32 API-ja. Postoji način, zahvaljujući mehanizmu vanjskih metoda, da pozovete ovu funkciju. Takav poziv sam vidio samo jednom u užasnom primjeru naslijeđenog koda, a motivacija autora koji je to učinio i dalje mi ostaje zagonetka.

Kernel32.dll CreateThread

Pregled i debagiranje niti

Nitima koje ste vi osobno kreirali, svim trećim komponentama i .NET grupom možete pristupiti u prozoru Threads Visual Studio-a. Ovaj prozor će prikazati informacije o nitima samo kada se aplikacija nalazi u režimu debagiranja i kada je u stanju pauze (Break mode). Ovdje možete pregledati stek imena i prioritete svake niti, te prebaciti debagiranje na određenu nit. Svojstvo Priority klase Thread može odrediti prioritet niti, koji će OC i CLR smatrati preporukom prilikom raspodjele procesorskog vremena između niti.

.NET: Alati za rad s višedretvenošću i asinhronim operacijama. Dio 1

Task Parallel Library

Task Parallel Library (TPL) je uvedena u .NET 4.0. To je sada standard i osnovni alat za rad s asinhronošću. Bilo koji kod koji koristi starije pristupe smatra se naslijeđenim. Osnovna jedinica TPL-a je klasa Task iz prostora imena System.Threading.Tasks. Task predstavlja apstrakciju iznad niti. S novom verzijom jezika C# dobili smo elegantan način rada s Task-ovima — async/await operatori. Ove su koncepcije omogućile pisanje asinhronog koda kao da je jednostavan i sinkronan, što je omogućilo čak i ljudima koji slabo razumiju unutarnje funkcioniranje niti da pišu aplikacije koje ih koriste, aplikacije koje se ne zamrzavaju prilikom izvođenja dugih operacija. Korištenje async/await tema je za jedan ili čak nekoliko članaka, ali ću pokušati u nekoliko rečenica sažeti suštinu:

  • async je modifikator metode koja vraća Task ili void
  • a await je operator neblokirajućeg čekanja na Task.

Još jednom: operator await, u općem slučaju (ima iznimaka), nastavlja trenutni tok izvršenja dalje, a kada Task završi svoje izvođenje, tok (zapravo je to preciznije reći kontekst, ali o tome malo kasnije) će biti slobodan da nastavi izvršavanje metode dalje. Unutar .NET, ovaj mehanizam je implementiran slično kao i yield return, kada se napisana metoda pretvara u cijelu klasu koja je stroj stanja i može se izvršavati u odvojenim dijelovima ovisno o tim stanjima. Oni koji su zainteresirani mogu napisati bilo koji jednostavan kod koristeći async/await, kompajlirati i pregledati sastav pomoću JetBrains dotPeek s uključenim Compiler Generated Code.

Razmotrit ćemo opcije pokretanja i korištenja Task-a. Na primjeru koda ispod, kreiramo novu Task koja ne radi ništa korisno (Thread.Sleep(10000)), ali u stvarnom životu to bi trebala biti neka složena CPU-intenzivna aktivnost.

using TCO = System.Threading.Tasks.TaskCreationOptions;

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

    await Task.Factory.StartNew(
        // Kod akcije će se izvršiti u drugom kontekstu
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    // Kod nakon await će se izvršiti u uhvaćenom kontekstu
}

Task se stvara s nizom opcija:

  • LongRunning — sugestija da zadatak neće biti brzo izvršen, stoga, možda bi bilo dobro razmisliti o tome da ne uzima tok iz ravnoteže, već stvoriti poseban tok za ovu Task-u kako ne bi naškodio ostalima.
  • AttachedToParent — Task-ovi se mogu organizirati u hijerarhije. Ako je ova opcija korištena, Task može biti u stanju kada je sam izvršen i čeka na izvršenje svojih podređenih.
  • PreferFairness — znači da bi bilo dobro izvršavati Task-ove poslane na izvršenje ranije prije nego one koje su poslane kasnije. No, to je samo preporuka i rezultat nije zajamčen.

Drugi parametar u metodi je proslijeđen CancellationToken. Za ispravnu obradu otkazivanja operacije nakon njenog pokretanja, izvršni kod mora biti ispunjen provjerama stanja CancellationToken. Ako nema provjera, metoda Cancel pozvana na objektu CancellationTokenSource moći će zaustaviti izvršavanje Task-a samo do njegovog pokretanja.

Posljednji parametar je objekt scheduler tipa TaskScheduler. Ova klasa i njeni nasljednici namijenjeni su upravljanju strategijama raspodjele Task-ova po nitima; prema zadanim postavkama, Task će biti izvršen na nasumičnoj niti iz bazena.

Na kreirani Task primijenjen je operator await, što znači da će kod napisan nakon njega, ako ga ima, biti izvršen u istom kontekstu (često to znači na istoj niti) kao i kod prije await-a.

Metoda je označena kao async void, što znači da u njoj nije dopušteno koristiti operator await, ali pozivateljski kod ne može čekati njezino izvršavanje. Ako je takva mogućnost potrebna, metoda treba vraćati Task. Metode označene kao async void često se nalaze: uglavnom su to obrada događaja ili druge metode koje rade po principu izvršiti i zaboraviti (fire and forget). Ako je potrebno ne samo omogućiti čekanje na završetak izvršavanja, već i vratiti rezultat, tada treba koristiti Task.

Na Task-u koji je vratio metod StartNew, kao i na bilo kojem drugom, može se pozvati metoda ConfigureAwait s parametrom false, tada će se izvršavanje nakon await-a nastaviti ne u zadržanom kontekstu, već na proizvoljnom. Ovo se uvijek treba raditi kada kontekst izvršavanja nakon await-a nije bitan. To je također preporuka od MS prilikom pisanja koda koji će biti isporučen u obliku pakirane biblioteke.

Pogledajmo još malo kako možemo čekati na završetak izvršavanja Task-a. Ispod je primjer koda, s komentarima, kada je čekanje uvjetno dobro i kada je uvjetno loše.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // dobro

    result = AsyncMethod().Result; // loše

    AsyncMethod().Wait(); // loše

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

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

    Task.WaitAll(tasks.ToArray()); // loše
}

U prvom primjeru čekamo na izvršavanje Task-a ne blokirajući pozivateljsku nit, a na obradi rezultata se vraćamo tek kada on već bude gotov, do tada je pozivateljska nit prepuštena sama sebi.

U drugoj verziji blokiramo pozivajući thread sve dok se ne izračuna rezultat metode. To nije dobro ne samo zato što smo zauzeli thread, toliko vrijedan resurs programa, trivijalnim besposlicama, nego i zato što ukoliko u kodu metode koju pozivamo postoji await, a kontekst sinkronizacije predviđa vraćanje u pozivajući thread nakon await, dobit ćemo deadlock: pozivajući thread čeka da se izračuna rezultat asinkrone metode, dok asinkrona metoda uzalud pokušava nastaviti svoje izvršavanje u pozivajućem threadu.

Još jedan nedostatak ovog pristupa je složenija obrada grešaka. Naime, greške u asinkronom kodu uz korištenje async/await se veoma lako obrađuju — ponašaju se kao da je kod sinhron. Dok, kada primjenjujemo sinhrono čekanje na Task, originalna iznimka se omotava u AggregateException, te za obradu iznimke moramo istraživati tip InnerException i sami pisati lanac if unutar jednog catch bloka ili koristiti konstrukciju catch when, umjesto poznatijeg u C# svijetu lanca catch blokova.

Treći i posljednji primjer također je označen lošim iz istog razloga i sadrži sve iste probleme.

Metode WhenAny i WhenAll izrazito su korisne za čekanje grupe Taskova, omotavaju grupu Taskova u jedan koji će se aktivirati ili po prvom aktiviranju Taska iz grupe ili kada svi završe svoje izvršavanje.

Zaustavljanje niti

Iz različitih razloga može se pojaviti potreba da se zaustavi nit nakon njenog pokretanja. Za to postoji niz načina. Klasa Thread ima dvije metode s odgovarajućim nazivima — to su Prekid i Interrupt. Prva se izuzetno ne preporučuje za korištenje, jer nakon njenog poziva u bilo kojem slučajnom trenutku, tijekom obrade bilo koje instrukcije, doći će do iznimke ThreadAbortedException. Ne očekujete da će takva iznimka nastati prilikom inkrementacije neke cijele varijable, zar ne? No, korištenjem ove metode, to je sasvim realna situacija. U slučaju potrebe da se zabrani CLR-u generiranje takve iznimke u određenom dijelu koda, možete ga obaviti s pozivima Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Takvim izazovima obavija se svaki kod napisan u finally bloku. Zbog toga se u osnovi koda frameworka mogu pronaći blokovi s praznim try, ali ne i praznim finally. Microsoft toliko ne preporučuje korištenje ove metode da je nije uključio u .net core.

Metoda Interrupt djeluje predvidljivije. Može prekinuti nit s iznimkom ThreadInterruptedException samo u trenucima kada se nit nalazi u stanju čekanja. U to stanje prelazi kada čeka na WaitHandle, lock ili nakon poziva Thread.Sleep.

Oba opisana gore varijante loša su zbog svoje nepredvidljivosti. Rješenje je korištenje strukture CancellationToken i klase CancellationTokenSource. Suština je sljedeća: kreira se primjerak klase CancellationTokenSource i samo onaj tko njime upravlja može zaustaviti operaciju pozivajući metodu Cancel. U samu operaciju se prosljeđuje samo CancellationToken. Vlasnici CancellationToken ne mogu sami otkazati operaciju, već samo provjeriti je li operacija otkazana. Za to postoji boolean svojstvo IsCancellationRequested i metoda ThrowIfCancelRequested. Posljednji će generirati iznimku TaskCancelledException ako je na prozoru CancellationToken izdan poziv na metodu Cancel. I upravo ovu metodu preporučujem koristiti. To je bolje od prethodnih opcija jer pruža potpunu kontrolu nad trenucima kada iznimka operacija može biti prekinuta.

Najradikalnija opcija za zaustavljanje niti je poziv funkcije Win32 API TerminateThread. Ponašanje CLR nakon poziva ove funkcije može biti nepredvidljivo. Na MSDN se o ovoj funkciji piše sljedeće: “TerminateThread is a dangerous function that should only be used in the most extreme cases. “

Pretvorba legacy-API u Task Based pomoću metode FromAsync

Ako ste imali sreće da radite na projektu koji je započet nakon što su Taskovi uvedeni i prestali izazivati tihu jezu većine programera, nećete se suočiti s velikim brojem starih API-ja, kako trećih strana, tako i onih koje je vaša ekipa mučila u prošlosti. Na sreću, razvojnog tima .NET Frameworka, pobrinuo se za nas, iako je možda cilj bio briga o sebi. Kako god bilo, u .NET-u postoji niz alata za bezbolno prevođenje koda napisanog starim pristupima asinkronom programiranju u nove. Jedan od njih je metoda FromAsync u TaskFactory-ju. U primjeru koda u nastavku, omotavam stare asinkrone metode klase WebRequest u Task pomoću ove metode.

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

Ovo je samo primjer i teško da ćete morati raditi nešto slično sa ugrađenim tipovima, no svaki stari projekt jednostavno vrvi od metoda BeginDoSomething koji vraćaju IAsyncResult i metoda EndDoSomething koji ih primaju.

Pretvaranje legacy-API-ja u Task Based s pomoću klase TaskCompletionSource

Još jedan važan alat za razmatranje je klasa TaskCompletionSource. Po funkcijama, namjeni i načinu rada može nalikovati metodi RegisterWaitForSingleObject klase ThreadPool o kojoj sam ranije govorio. Uz pomoć ove klase lako i praktično možete omotati stare asinkrone API-je u Task-ove.

Reći ćete da sam već govorio o metodi FromAsync klase TaskFactory koja je namijenjena za ove svrhe. Morat ćemo se prisjetiti cijele povijesti razvijanja asinkronih modela u .NET-u koje je Microsoft nudio u posljednjih 15 godina: prije Task Based Asynchronous Pattern (TAP) postojali su Asynchronous Programming Pattern (APP), koji je bio o metodama BeginDoSomething koje vraća IAsyncResult i metodama EndDoSomething koje ga primaju i za legacy tih godina metoda FromAsync odlično odgovara, no s vremenom, umjesto njega došao je Event Based Asynchronous Pattern (EAP), koji je predložio da će se po završetku izvršenja asinkrone operacije pozvati događaj.

TaskCompletionSource savršeno je rješenje za omotavanje legacy-API taskova koji su izgrađeni oko modela događaja. Njegova suština je sljedeća: objekt ove klase ima javnu svojstvo tipa Task čijim se stanjem može upravljati putem metoda SetResult, SetException itd. Klasa TaskCompletionSource. U mjestima gdje je primijenjen operator await na ovom Tasku, on će biti izvršen ili doživjeti iznimku ovisno o metodi primijenjenoj na TaskCompletionSource. Ako još uvijek nije jasno, pogledajmo ovaj primjer koda, gdje se neki stari API iz vremena EAP omotava u Task pomoću TaskCompletionSource: kada se dogodi događaj, Task će biti prebačen u stanje Completed, a metoda koja je primijenila operator await na ovom Tasku nastavit će svoje izvršavanje primajući objekt. result.

public static Task DoAsync(this SomeApiInstance someApiObj) {

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

    return completionSource.Task;
}

TaskCompletionSource Savjeti & Trikovi

Omotavanje starih API-ja nije jedina stvar koju možete izvesti s TaskCompletionSource. Korištenje ove klase otvara zanimljivu mogućnost dizajniranja raznih API-ja, zasnovanih na Taskovima, koji ne koriste niti jedan thread. A thread, kao što znamo, je skup resursa, a njihova je količina ograničena (uglavnom količinom RAM-a). Ovu granicu lako je doseći razvijanjem, na primjer, opterećenih web-aplikacija sa složenom poslovnom logikom. Rassmotrite mogućnosti o kojima govorim na implementaciji takvog trika kao što je Long-Polling.

Ukratko, suština trikova je u tome što trebate dobiti informacije o nekim događajima koji se odvijaju na strani API-ja, dok API iz nekih razloga ne može obavijestiti o događaju, već samo vratiti stanje. Primjeri toga su svi API-ji izgrađeni na HTTP-u prije vremena WebSocketa ili kada nije moguće koristiti tu tehnologiju iz nekog razloga. Klijent može postaviti pitanje HTTP serveru. HTTP server ne može sam izazvati komunikaciju s klijentom. Jedno jednostavno rješenje je ispitivanje servera prema tajmeru, ali to stvara dodatni pritisak na server i dodatno kašnjenje u prosjeku TimerInterval / 2. Da bi se to zaobišlo, izumljen je trik poznat kao Long Polling, koji predviđa odgodu odgovora sa servera sve dok ne istekne Timeout ili se ne dogodi događaj. Ako se događaj dogodi, on se obrađuje, ako ne, zahtjev se ponovno šalje.

while(!eventOccures && !timeoutExceeded)  {

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

Međutim, takvo rješenje će se pokazati izuzetno lošim kada broj klijenata koji očekuju događaj poraste, jer svaki takav klijent u očekivanju događaja zauzima cijeli nit. Također dobijamo dodatno kašnjenje od 1ms na aktivaciju događaja, što najčešće nije suštinsko, ali zašto bi program bio lošiji nego što može biti? Ako uklonimo Thread.Sleep(1), bespotrebno ćemo opteretiti jedno jezgro procesora s 100% u bezuspješnom ciklusu. Pomoću TaskCompletionSource-a možemo lako prepraviti ovaj kod i riješiti sve gore navedene probleme:

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

Ovaj kod nije spreman za produkciju, već je samo demonstracijski. Za korištenje u stvarnim slučajevima potrebno je, barem, obraditi situaciju kada je poruka stigla u trenutku kada je nitko ne očekuje: u tom slučaju metoda AcceptMessageAsync treba vratiti već dovršeni Task. Ako je taj slučaj najčešći, valja razmisliti i o korištenju ValueTask.

Kada primimo zahtjev za poruku, stvaramo i smještamo u rječnik TaskCompletionSource, a zatim čekamo što će se dogoditi prije: da istekne određeni vremenski interval ili da stigne poruka.

ValueTask: zašto i kako

Operatori async/await, kao i operator yield return, generiraju iz metode stroj stanja, što znači stvaranje novog objekta, što gotovo uvijek nije važno, ali u rijetkim slučajevima može izazvati problem. Takav slučaj može biti metoda koja se zaista često poziva, riječ je o desecima i stotinama tisuća poziva u sekundi. Ako je takva metoda napisana tako da većinom vraća rezultat mimo svih await metoda, .NET nudi alat za optimizaciju — struktura ValueTask. Da bi to postalo jasno, razmotrite primjer njegove upotrebe: postoji keš s kojim često radimo. Neka su vrijednosti u njemu i tada ih jednostavno vraćamo, ako ih nema, idemo do nekog sporog IO-a po njih. Posljednje želimo raditi asinkrono, a to znači da cijela metoda postaje asinkrona. Tako da očigledna opcija pisanja metode izgleda ovako:

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

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

Zbog želje da malo optimiziramo, i blage zabrinutosti oko toga što će Roslyn generirati prilikom kompiliranja ovog koda, ovaj primjer možemo 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);
}

Uistinu optimalno rješenje u ovom slučaju bit će optimizirati hot-path, a to jest dobivanje vrijednosti iz rječnika potpuno bez suvišnih alokacija i opterećenja na GC, dok će u onim rijetkim slučajevima kada nam ipak treba ići u IO po podatke sve ostati manje-više isto:

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 detaljnije ovaj dio koda: u prisutnosti vrijednosti u kešu stvaramo strukturu, u suprotnom će pravi task biti obavijen značajnim. Pozivatelj kodu nije važno kako je ovaj kod izvršen: ValueTask s gledišta sintakse C# ponašat će se isto kao i običan Task u ovom slučaju.

TaskScheduler-i: upravljanje strategijama pokretanja Task-ova

Sljedeće API koje bismo željeli razmotriti je klasa TaskScheduler i njegove varijante. Već sam spomenuo da u TPL postoji mogućnost upravljanja strategijama raspodjele Taskova po nitima. Takve strategije definiraju se u podklasama TaskScheduler. Praktički svaka strategija koja će vam možda zatrebati bit će pronađena u biblioteci ParallelExtensionsExtras, razvijena od strane Microsofta, ali nije dio .NET-a, već se isporučuje kao Nuget paket. Kratko ćemo razmotriti neke od njih:

  • CurrentThreadTaskScheduler — izvršava Taskove na trenutnoj niti
  • LimitedConcurrencyLevelTaskScheduler — ograničava broj istovremeno izvršavanih Taskova parametrima N, koji se prosljeđuje u konstruktoru
  • OrderedTaskScheduler — definira se kao LimitedConcurrencyLevelTaskScheduler(1), tako da će se zadaci izvršavati sekvencijalno.
  • WorkStealingTaskScheduler — implementira work-stealing pristup raspodjeli zadataka. U suštini, to je zaseban ThreadPool. Rješava problem da je u .NET-u ThreadPool statička klasa, jedna za sve aplikacije, što znači da njegova preopterećenost ili nepravilna upotreba u jednom dijelu programa može uzrokovati nuspojave u drugom. Nadalje, razumjeti uzrok takvih nedostataka izuzetno je teško. Tako može postojati potreba za korištenjem zasebnih WorkStealingTaskScheduler-a u onim dijelovima programa gdje korištenje ThreadPool-a može biti agresivno i nepredvidivo.
  • QueuedTaskScheduler — omogućava izvršavanje zadataka prema pravilima reda s prioritetima
  • ThreadPerTaskScheduler — stvara zasebnu nit za svaki Task koji se na njoj izvršava. Može biti korisno za zadatke koji se izvršavaju nepredvidivo dugo.

Postoji dobra detaljna članak o TaskScheduler-ima na blogu Microsofta.

Za jednostavno otklanjanje pogrešaka u svemu vezanom za Taskove u Visual Studiju postoji prozor Tasks. U tom prozoru možete vidjeti trenutno stanje zadatka i preći na trenutnu liniju koda koja se izvršava.

.NET: Alati za rad s višedretvenošću i asinhronim operacijama. Dio 1

PLinq i klasa Parallel

Osim Taskova i svega što je o njima rečeno u .NET-u, postoje još dva zanimljiva alata: PLinq (Linq2Parallel) i klasa Parallel. Prvi obećava paralelno izvršavanje svih Linq operacija na više niti. Broj niti može se konfigurirati metodom proširenja WithDegreeOfParallelism. Nažalost, često PLinq u zadnjem režimu rada nema dovoljno informacija o unutrašnjosti vašeg izvora podataka da bi osigurao značajna poboljšanja u brzini, s druge strane, cijena pokušaja je vrlo niska: samo je potrebno pozvati metodu AsParallel prije lanca Linq metoda i obaviti testove performansi. Štoviše, postoji mogućnost proslijediti PLinq dodatne informacije o prirodi vašeg izvora podataka pomoću mehanizma Partitions. Više informacija možete pročitati ovdje i ovdje.

Statička klasa Parallel pruža metode za paralelnu iteraciju kroz kolekciju Foreach, izvođenje For petlji i izvršavanje više delegata paralelno putem poziva Invoke. Izvršavanje trenutne niti bit će zaustavljeno dok se ne završe izračuni. Broj niti može se konfigurirati proslijedivši ParallelOptions kao posljednji argument. Uz pomoć opcija također možete odrediti TaskScheduler i CancellationToken.

Zaključci

Kada sam počeo pisati ovaj članak temeljen na materijalima mog izlaganja i informacijama koje sam prikupio tijekom rada nakon njega, nisam očekivao da će biti toliko toga. Sada, kada tekstualni uređivač u kojem pišem ovaj članak s ogorčenjem ukazuje na to da je prošla 15. stranica, napravit ću privremene zaključke. Ostali trikovi, API-ji, vizualni alati i zamke bit će obrađeni u sljedećem članku.

Zaključci:

  • Morate poznavati alate za rad s nitima, asinhronošću i paralelizmom kako biste iskoristili resurse modernih PC-a.
  • .NET nudi mnogo različitih alata za te svrhe.
  • Nisu svi oni pojavili odjednom, pa često možete naići na legacy, međutim postoje načini za pretvaranje starih API-ja bez naročitih napora.
  • Rad s nitima u .NET-u predstavljen je klasama Thread i ThreadPool.
  • Metode Thread.Abort, Thread.Interrupt, funkcija Win32 API TerminateThread opasne su i ne preporučuju se za korištenje. Umjesto njih bolje je koristiti mehanizam CancellationToken.
  • Tokovi su dragocjeni resurs, a njihova količina je ograničena. Treba izbjegavati situacije u kojima su tokovi zauzeti čekanjem na događaje. Zato je prikladno koristiti klasu TaskCompletionSource.
  • Najmoćniji i najsloženiji alati .NET-a za rad s paralelizmom i asinkronošću su Task-ovi.
  • C# async/await operatori implementiraju koncept neblokirajućeg čekanja.
  • Upravljanje raspodjelom Task-ova između tokova može se postići pomoću izvedenih klasa TaskScheduler.
  • Struktura ValueTask može biti korisna za optimizaciju hot-paths i memorijskog prometa.
  • Prozori Tasks i Threads u Visual Studio-u pružaju mnogo korisnih informacija za debagiranje višedretvenog ili asinkronog koda.
  • PLinq je sjajan alat, ali možda neće imati dovoljno informacija o vašem izvoru podataka; međutim, to se može riješiti mehanizmom particioniranja.
  • Nastavak slijedi…

Izvor: habr.com

Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere 🔥 Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere | ProHoster