.NET: Alati za rad sa višenitnim i asinhronijom. Dio 1

Na Habru objavljujem originalni članak čiji je prijevod objavljen u korporativnom blog post.

Potreba da se nešto radi asinhrono, bez čekanja na rezultat ovdje i sada, ili da se veliki posao podijeli na nekoliko jedinica koje ga obavljaju, postojala je prije pojave kompjutera. Njihovim dolaskom ova potreba je postala vrlo opipljiva. Sada, 2019. godine, kucam ovaj članak na laptopu sa 8-jezgarnim Intel Core procesorom, na kojem paralelno radi više od stotinu procesa, pa čak i više niti. U blizini se nalazi malo pohaban telefon, kupljen prije par godina, ima 8-jezgarni procesor. Tematski resursi su puni članaka i video zapisa u kojima se njihovi autori dive ovogodišnjim vodećim pametnim telefonima koji imaju 16-jezgrene procesore. MS Azure pruža virtuelnu mašinu sa procesorom od 20 jezgara i 128 TB RAM-a za manje od 2 USD po satu. Nažalost, nemoguće je izvući maksimum i iskoristiti ovu moć bez mogućnosti upravljanja interakcijom niti.

Terminologija

Proces - OS objekat, izolovani adresni prostor, sadrži niti.
Thread - OS objekat, najmanja jedinica izvršenja, dio procesa, niti dijele memoriju i druge resurse među sobom unutar procesa.
Multitasking - OS svojstvo, mogućnost pokretanja nekoliko procesa istovremeno
Multi-core - svojstvo procesora, mogućnost korištenja nekoliko jezgara za obradu podataka
Višeprocesiranje - svojstvo računara, mogućnost istovremenog fizičkog rada sa nekoliko procesora
Multithreading — svojstvo procesa, sposobnost distribucije obrade podataka između nekoliko niti.
Paralelizam - obavljanje nekoliko radnji fizički istovremeno u jedinici vremena
Asinhroni — izvršenje operacije bez čekanja na završetak ove obrade; rezultat izvršenja može se obraditi kasnije.

Metafora

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

Dok sam ujutro pripremao doručak ja (CPU) Dolazim u kuhinju (Computer). imam 2 ruke (Jezgra). U kuhinji se nalazi niz uređaja (IO): pećnica, kuhalo za vodu, toster, hladnjak. Upalim gas, stavim tiganj i sipam ulje bez cekanja da se zagrije (asinhrono, Non-Blocking-IO-Wait), izvadim jaja iz frižidera i razbijem ih u tanjir, pa ih istučem jednom rukom (Thread#1), i drugo (Thread#2) držeći ploču (Zajednički resurs). Sad bih htio da uključim čajnik, ali nemam dovoljno ruku (Thread Gladovanje) Za to vrijeme se zagrije tiganj (Obrada rezultata) u koji sipam ono što sam umutio. Posegnem za čajnikom i uključim ga i glupo gledam kako voda ključa u njemu (Blokiranje-IO-Čekaj), iako je za to vrijeme mogao oprati tanjir na kojem je umutio omlet.

Omlet sam skuhala sa samo 2 ruke, a nemam više, ali istovremeno su se u trenutku mućenja omleta odvijale 3 operacije odjednom: mućenje omleta, držanje tanjira, zagrevanje tiganja CPU je najbrži dio kompjutera, IO je ono što najčešće sve usporava, pa je često efikasno rješenje zauzeti CPU nečim dok primate podatke od IO-a.

Nastavljamo metaforu:

  • Ako bih u procesu pripreme omleta pokušao i da se presvučem, ovo bi bio primjer multitaskinga. Važna nijansa: kompjuteri su u tome mnogo bolji od ljudi.
  • Kuhinja s nekoliko kuhara, na primjer u restoranu - višejezgarni računar.
  • Mnogi restorani u restoranu u trgovačkom centru - data centar

.NET alati

.NET je dobar u radu sa nitima, kao i sa 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 sa konstrukcijom apstrakcija, programeri okvira koriste pristup koji ostavlja mogućnost, kada se koristi apstrakcija visokog nivoa, da se spusti jedan ili više nivoa ispod. Najčešće to nije potrebno, zapravo otvara vrata pucanju sebi u nogu iz puške, ali ponekad, u rijetkim slučajevima, to može biti jedini način da se riješi problem koji nije riješen na trenutnom nivou apstrakcije .

Pod alatima mislim na sučelje za programiranje aplikacija (API) koje obezbjeđuje okvir i paketi trećih strana, kao i na cjelokupna softverska rješenja koja pojednostavljuju potragu za bilo kakvim problemima povezanim sa višenitnim kodom.

Pokretanje teme

Klasa Thread je najosnovnija klasa u .NET-u za rad sa nitima. Konstruktor prihvata jednog od dva delegata:

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

Delegat će se izvršiti u novokreiranoj 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 bilo koje lokalne informacije u tok. Vrijedi napomenuti da je kreiranje niti skupa operacija, a sama nit je težak objekat, barem zato što dodjeljuje 1MB memorije na stog i zahtijeva interakciju sa OS API-jem.

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

Klasa ThreadPool predstavlja koncept bazena. U .NET-u, skup niti je dio inženjeringa, a programeri u Microsoftu su uložili mnogo truda da osiguraju da radi optimalno u širokom spektru scenarija.

Opšti koncept:

Od trenutka kada se aplikacija pokrene, kreira nekoliko niti u rezervi u pozadini i pruža mogućnost da ih uzme za upotrebu. Ako se niti koriste često i u velikom broju, skup se širi kako bi zadovoljio potrebe pozivatelja. Kada nema slobodnih niti u spremištu u pravo vrijeme, on će ili čekati da se jedna od niti vrati, ili će kreirati novu. Iz toga slijedi da je skup niti odličan za neke kratkoročne radnje i slabo prikladan za operacije koje se pokreću kao usluge tokom cijelog rada aplikacije.

Da biste koristili nit iz spremišta, postoji QueueUserWorkItem metoda 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 spremišta niti RegisterWaitForSingleObject se koristi za organiziranje neblokirajućih IO operacija. Delegat proslijeđen ovoj metodi će biti pozvan kada je WaitHandle proslijeđen metodi “Osloboden”.

ThreadPool.RegisterWaitForSingleObject(...)

.NET ima tajmer niti i razlikuje se od WinForms/WPF tajmera po tome što će njegov rukovatelj biti pozvan na niti preuzetoj iz spremišta.

System.Threading.Timer

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

DelegateInstance.BeginInvoke

Želio bih se ukratko zadržati na funkciji kojoj 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 poziv sam vidio samo jednom u strašnom primjeru naslijeđenog koda, a motivacija autora koji je upravo to uradio i dalje mi ostaje misterija.

Kernel32.dll CreateThread

Pregled i otklanjanje grešaka u nitima

Niti koje ste kreirali, sve komponente treće strane i .NET bazen mogu se vidjeti u prozoru Threads Visual Studio-a. Ovaj prozor će prikazati informacije o niti samo kada je aplikacija u fazi otklanjanja grešaka i u režimu prekida. Ovdje možete zgodno vidjeti nazive stekova i prioritete svake niti i prebaciti otklanjanje grešaka na određenu nit. Koristeći svojstvo Priority klase Thread, možete postaviti prioritet niti, što će OC i CLR shvatiti kao preporuku kada dijele vrijeme procesora između niti.

.NET: Alati za rad sa višenitnim i asinhronijom. Dio 1

Paralelna biblioteka zadataka

Task Parallel Library (TPL) uvedena je u .NET 4.0. Sada je to standard i glavni alat za rad sa asinhronijom. Svaki kod koji koristi stariji pristup smatra se naslijeđem. Osnovna jedinica TPL-a je klasa Task iz imenskog prostora System.Threading.Tasks. Zadatak je apstrakcija preko niti. Sa novom verzijom jezika C#, dobili smo elegantan način rada sa zadacima - async/await operatori. Ovi koncepti su omogućili pisanje asinhronog koda kao da je jednostavan i sinhroni, što je omogućilo čak i ljudima koji malo razumiju unutrašnje funkcionisanje niti da pišu aplikacije koje ih koriste, aplikacije koje se ne smrzavaju prilikom izvođenja dugih operacija. Korištenje async/await je tema za jedan ili čak nekoliko članaka, ali pokušat ću shvatiti suštinu u nekoliko rečenica:

  • async je modifikator metode koja vraća Task ili void
  • a await je neblokirajući operator zadatka na čekanju.

Još jednom: operator await, u opštem slučaju (postoje izuzeci), će dalje otpustiti trenutnu nit izvršenja, a kada zadatak završi svoje izvršenje, a nit (u stvari, ispravnije bi bilo reći kontekst , ali više o tome kasnije) nastavit će dalje izvršavati metodu. Unutar .NET-a, ovaj mehanizam je implementiran na isti način kao i vraćanje prinosa, kada se napisana metoda pretvara u cijelu klasu, koja je državna mašina i može se izvršiti u odvojenim dijelovima ovisno o tim stanjima. Svi zainteresovani mogu napisati bilo koji jednostavan kod koristeći asyns/await, kompajlirati i pogledati sklop koristeći JetBrains dotPeek sa omogućenim kodom generiranim kompajlerom.

Pogledajmo opcije za pokretanje i korištenje Task-a. U primjeru koda ispod, kreiramo novi zadatak koji ne radi ništa korisno (Thread.Sleep(10000)), ali u stvarnom životu ovo bi trebao biti složen posao koji zahtijeva 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
}

Zadatak se kreira sa nekoliko opcija:

  • LongRunning je nagoveštaj da zadatak neće biti brzo dovršen, što znači da bi možda bilo vredno razmisliti o tome da ne uzimate nit iz skupa, već da kreirate zasebnu za ovaj zadatak kako ne biste naškodili drugima.
  • AttachedToParent - Zadaci se mogu rasporediti u hijerarhiji. Ako je ova opcija korištena, onda je zadatak možda u stanju u kojem je sam završio i čeka izvršenje svojih djece.
  • PreferFairness - znači da bi bilo bolje da se Zadaci poslani na izvršenje izvrše ranije prije onih poslatih kasnije. Ali ovo je samo preporuka i rezultati nisu zajamčeni.

Drugi parametar koji se prosljeđuje metodi je CancellationToken. Za ispravno rukovanje otkazivanjem operacije nakon što je započela, kod koji se izvršava mora biti ispunjen provjerama za stanje CancellationToken. Ako nema provjera, onda će metoda Cancel pozvana na CancellationTokenSource objektu moći zaustaviti izvršenje Zadatka samo prije nego što počne.

Posljednji parametar je objekt planera tipa TaskScheduler. Ova klasa i njeni potomci su dizajnirani da kontrolišu strategije za distribuciju zadataka među nitima; prema zadanim postavkama, zadatak će se izvršavati na nasumičnoj niti iz skupa.

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

Metoda je označena kao async void, što znači da može koristiti operator await, ali pozivni kod neće moći čekati izvršenje. Ako je takva karakteristika neophodna, onda metoda mora vratiti Task. Metode označene kao async void su prilično česte: po pravilu, to su rukovaoci događaja ili druge metode koje rade na principu požara i zaborava. Ako trebate ne samo dati priliku čekati do kraja izvršenja, već i vratiti rezultat, tada morate koristiti Task.

Na Zadatku koji je vratila metoda StartNew, kao i na bilo kojem drugom, možete pozvati metodu ConfigureAwait sa parametrom false, a zatim će se izvršavanje nakon čekanja nastaviti ne na uhvaćenom kontekstu, već na proizvoljnom. Ovo uvijek treba učiniti kada kontekst izvršenja nije važan za kod nakon čekanja. Ovo je takođe preporuka od MS-a prilikom pisanja koda koji će biti isporučen upakovan u biblioteku.

Hajde da se zadržimo još malo na tome kako možete čekati završetak Zadatka. Ispod je primjer koda, sa komentarima kada je očekivanje urađeno uslovno dobro, a kada uslovno 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ć tamo; do tada je pozivajuća nit prepuštena vlastitim uređajima.

U drugoj opciji blokiramo nit koja poziva sve dok se ne izračuna rezultat metode. Ovo je loše ne samo zato što smo nit, tako vrijedan resurs programa, zauzeli jednostavnim neradom, već i zato što ako kod metode koju pozivamo sadrži čekanje, a kontekst sinhronizacije zahtijeva povratak na nit koja poziva nakon čekaj, onda ćemo dobiti zastoj : Pozivajuća nit čeka da se izračuna rezultat asinhrone metode, asinhrona metoda uzalud pokušava da nastavi svoje izvršavanje u pozivajućoj niti.

Još jedan nedostatak ovog pristupa je komplikovano rukovanje greškama. Činjenica je da se greške u asinkronom kodu kada se koristi async/await vrlo lako obrađuju – ponašaju se isto kao da je kod sinhroni. Dok ako na zadatak primijenimo sinkroni egzorcizam čekanja, originalni izuzetak se pretvara u AggregateException, tj. Da biste obradili izuzetak, morat ćete ispitati tip InnerException i sami napisati if lanac unutar jednog catch bloka ili koristiti catch kada se konstruira, umjesto lanca catch blokova koji je poznatiji u C# svijetu.

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

Metode WhenAny i WhenAll su izuzetno zgodne za čekanje grupe zadataka; oni omotavaju grupu zadataka u jedan, koji će se pokrenuti ili kada se zadatak iz grupe prvi put pokrene, ili kada svi završ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 da to učinite. Klasa Thread ima dvije odgovarajuće imenovane metode: Prekini и Prekini. Prvi se jako ne preporučuje za upotrebu, jer nakon što ga pozovete u bilo kom nasumičnom trenutku, tokom obrade bilo koje instrukcije, biće izbačen izuzetak ThreadAbortedException. Ne očekujete da će takav izuzetak biti izbačen prilikom povećanja bilo koje cjelobrojne varijable, zar ne? A kada se koristi ova metoda, ovo je vrlo realna situacija. Ako trebate spriječiti CLR da generiše takav izuzetak u određenom dijelu koda, možete ga umotati u pozive Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Svaki kod napisan u finally bloku je umotan u takve pozive. Iz tog razloga, u dubinama koda okvira možete pronaći blokove sa praznim pokušajem, ali ne i praznim konačno. Microsoft toliko obeshrabruje ovu metodu da je nisu uključili u .net jezgro.

Metoda Interrupt radi predvidljivije. Može prekinuti nit sa izuzetkom ThreadInterruptedException samo u onim trenucima kada je nit u stanju čekanja. U ovo stanje ulazi 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. Stvar je u ovome: kreira se instanca klase CancellationTokenSource i samo onaj koji je posjeduje može zaustaviti operaciju pozivanjem metode otkazati. Samo CancellationToken se prosljeđuje samoj operaciji. Vlasnici CancellationTokena ne mogu sami otkazati operaciju, već mogu samo provjeriti da li je operacija otkazana. Za ovo postoji Boolean svojstvo IsCancellationRequested i metod ThrowIfCancelRequested. Potonji će izbaciti izuzetak TaskCancelledException ako je metoda Cancel pozvana na instanci CancellationToken koja se ponavlja. I ovo je metoda koju preporučujem da koristite. Ovo je poboljšanje u odnosu na prethodne opcije dobivanjem potpune kontrole nad točkom u kojoj se operacija izuzetka može prekinuti.

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

Pretvaranje naslijeđenog API-ja u baziran na zadacima korištenjem FromAsync metode

Ako imate dovoljno sreće da radite na projektu koji je pokrenut nakon što su Tasks uvedeni i koji je prestao izazivati ​​tihi užas za većinu programera, tada nećete morati da se bavite puno starih API-ja, kako onih trećih strana, tako i onih koji vaš tim je mučio u prošlosti. Srećom, .NET Framework tim se pobrinuo za nas, iako je možda cilj bio da se brinemo o sebi. Kako god bilo, .NET ima niz alata za bezbolno pretvaranje koda napisanog u starim pristupima asinhronog programiranja u novi. Jedna od njih je FromAsync metoda TaskFactory. U primjeru koda ispod, umotavam stare async metode klase WebRequest u zadatak koristeći ovu metodu.

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 vjerovatno da ćete to morati raditi s ugrađenim tipovima, ali svaki stari projekat jednostavno vrvi od BeginDoSomething metoda koje vraćaju IAsyncResult i EndDoSomething metoda koje ga primaju.

Pretvorite naslijeđeni API u Task Based koristeći klasu TaskCompletionSource

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

Reći ćete da sam već govorio o FromAsync metodi klase TaskFactory namijenjenoj za ove svrhe. Ovdje ćemo se morati prisjetiti cjelokupne historije razvoja asinhronih modela u .net-u koje je Microsoft nudio u proteklih 15 godina: prije Asinhronog uzorka zasnovanog na zadacima (TAP) postojao je Asinhroni programski obrazac (APP), koji je radilo se o metodama početiDoSomething returning IAsyncResult i metode krajDoSomething to prihvata i za naslijeđe ovih godina FromAsync metoda je jednostavno savršena, ali s vremenom je zamijenjena Asinkronim uzorkom zasnovanim na događajima (AND AP), koji pretpostavlja da će se događaj pokrenuti kada se asinhrona operacija završi.

TaskCompletionSource je savršen za omotavanje zadataka i naslijeđenih API-ja izgrađenih oko modela događaja. Suština njegovog rada je sledeća: objekat ove klase ima javno svojstvo tipa Task, čije stanje se može kontrolisati preko metoda SetResult, SetException itd. klase TaskCompletionSource. Na mjestima gdje je operator await primijenjen na ovaj zadatak, on će se izvršiti ili neće uspjeti s izuzetkom u zavisnosti od metode primijenjene na TaskCompletionSource. Ako još uvijek nije jasno, pogledajmo ovaj primjer koda, gdje je neki stari EAP API umotan u zadatak koristeći TaskCompletionSource: kada se događaj pokrene, zadatak će biti stavljen u stanje Završeno, a metoda koja je primijenila operator await ovom Zadatku će se nastaviti sa izvršavanjem nakon što je primio objekat 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;
}

Savjeti i trikovi TaskCompletionSource

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 broj je ograničen (uglavnom količinom RAM-a). Ovo ograničenje se može lako postići razvojem, na primjer, učitane web aplikacije sa složenom poslovnom logikom. Hajde da razmotrimo mogućnosti o kojima govorim kada implementiramo takav trik kao što je Long-Polling.

Ukratko, suština trika je sljedeća: od API-ja trebate primiti informacije o nekim događajima koji se dešavaju 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 na vrhu HTTP-a prije vremena WebSocketa ili kada je iz nekog razloga bilo nemoguće koristiti ovu tehnologiju. Klijent može pitati HTTP server. HTTP server ne može sam pokrenuti komunikaciju sa klijentom. Jednostavno rješenje je anketiranje servera pomoću tajmera, ali to stvara dodatno opterećenje na serveru i dodatno kašnjenje u prosjeku TimerInterval / 2. Da bi se ovo zaobišlo, izmišljen je trik pod nazivom Long Polling, koji uključuje odlaganje odgovora od server dok ne istekne Timeout ili će se dogoditi događaj. Ako dođe do događaja, on se obrađuje; ako ne, zahtjev se ponovo šalje.

while(!eventOccures && !timeoutExceeded)  {

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

Ali takvo rješenje će se pokazati strašnim čim se poveća broj klijenata koji čekaju događaj, jer... Svaki takav klijent zauzima čitavu nit čekajući događaj. Da, i dobijamo dodatno kašnjenje od 1 ms kada se događaj pokrene, najčešće to nije značajno, ali zašto učiniti softver lošijim nego što može biti? Ako uklonimo Thread.Sleep(1), uzalud ćemo jedno jezgro procesora učitati 100% neaktivno, rotirajući u beskorisnom ciklusu. Koristeći TaskCompletionSource možete jednostavno prepraviti ovaj kod i riješiti sve probleme identificirane gore:

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 ga koristili u stvarnim slučajevima, morate, u najmanju ruku, riješiti situaciju kada poruka stigne u vrijeme kada je niko ne očekuje: u ovom slučaju, metoda AsseptMessageAsync bi trebala vratiti već završen zadatak. Ako je ovo najčešći slučaj, onda možete razmisliti o korištenju ValueTask-a.

Kada primimo zahtjev za porukom, kreiramo i stavljamo TaskCompletionSource u rječnik, a zatim čekamo šta se prvo dogodi: navedeni vremenski interval ističe ili je poruka primljena.

ValueTask: zašto i kako

Operatori async/await, poput operatora vraćanja prinosa, generišu mašinu stanja iz metode, a to je kreiranje novog objekta, što skoro uvek nije važno, ali u retkim slučajevima može stvoriti problem. Ovaj slučaj može biti metoda koja se zove zaista često, govorimo o desetinama i stotinama hiljada 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, onda .NET pruža alat za optimizaciju ovoga - strukturu ValueTask. Da bismo bili jasniji, pogledajmo primjer njegove upotrebe: postoji keš u koji idemo vrlo često. U njemu postoje neke vrijednosti i onda ih jednostavno vraćamo; ako ne, onda idemo u neki spori IO da ih dobijemo. Želim da uradim ovo drugo asinhrono, što znači da se cela metoda ispostavi da je asinhrona. Dakle, očigledan 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 da se malo optimizira i malog 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);
}

Zaista, optimalno rješenje u ovom slučaju bi bilo optimizacija hot-path-a, naime, dobivanje vrijednosti iz rječnika bez nepotrebnih alokacija i učitavanja GC-a, dok u onim rijetkim slučajevima kada još uvijek 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 kešu, kreiramo strukturu, inače će pravi zadatak biti umotan u smisleni zadatak. Pozivnom kodu nije važno na kojoj stazi je ovaj kod izvršen: ValueTask, sa stanovišta C# sintakse, ponašaće se isto kao i običan zadatak u ovom slučaju.

TaskSchedulers: upravljanje strategijama pokretanja zadataka

Sljedeći API koji bih želio razmotriti je klasa TaskScheduler i njegovih derivata. Već sam spomenuo gore da TPL ima mogućnost upravljanja strategijama za distribuciju zadataka kroz niti. Takve strategije su definisane u potomcima klase TaskScheduler. U biblioteci se može pronaći gotovo svaka strategija koja vam može zatrebati. ParallelExtensionsExtras, koji je razvio Microsoft, ali nije dio .NET-a, već se isporučuje kao Nuget paket. Pogledajmo ukratko neke od njih:

  • CurrentThreadTaskScheduler — izvršava zadatke na trenutnoj niti
  • LimitedConcurrencyLevelTaskScheduler — ograničava broj zadataka koji se izvršavaju istovremeno parametrom N, koji je prihvaćen u konstruktoru
  • OrderedTaskScheduler — definira se kao LimitedConcurrencyLevelTaskScheduler(1), tako da će se zadaci izvršavati uzastopno.
  • WorkStealingTaskScheduler - pribor rad-krađa pristup raspodjeli zadataka. U suštini to je poseban ThreadPool. Rješava problem da je u .NET ThreadPool statična klasa, jedna za sve aplikacije, što znači da njeno preopterećenje ili nepravilna upotreba u jednom dijelu programa može dovesti do nuspojava u drugom. Štaviše, izuzetno je teško razumjeti uzrok takvih nedostataka. To. Možda postoji potreba za korištenjem zasebnih WorkStealingTaskSchedulera u dijelovima programa gdje upotreba ThreadPoola može biti agresivna i nepredvidljiva.
  • QueuedTaskScheduler — omogućava vam da izvršavate zadatke prema pravilima prioritetnog reda čekanja
  • ThreadPerTaskScheduler — kreira zasebnu nit za svaki zadatak koji se na njemu izvršava. Može biti korisno za zadatke za koje je potrebno nepredvidivo dugo vremena.

Postoji dobar detalj članak o TaskSchedulerima na Microsoft blogu.

Za praktično otklanjanje grešaka svega što je povezano sa zadacima, Visual Studio ima prozor sa zadacima. U ovom prozoru možete vidjeti trenutno stanje zadatka i skočiti na red koda koji se trenutno izvršava.

.NET: Alati za rad sa višenitnim i asinhronijom. Dio 1

PLinq i klasa Parallel

Pored zadataka 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 se može konfigurirati korištenjem metode proširenja WithDegreeOfParallelism. Nažalost, najčešće PLinq u svom zadanom načinu rada nema dovoljno informacija o unutrašnjosti vašeg izvora podataka da bi pružio značajno povećanje brzine, s druge strane, cijena pokušaja je vrlo niska: samo trebate pozvati metodu AsParallel prije lanac Linq metoda i pokrenite testove performansi. Štaviše, moguće je prenijeti dodatne informacije PLinq-u o prirodi vašeg izvora podataka koristeći mehanizam Particije. Možete pročitati više ovdje и ovdje.

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

nalazi

Kada sam počeo da pišem ovaj članak na osnovu materijala mog izveštaja i informacija koje sam prikupio tokom rada nakon njega, nisam očekivao da će ga biti toliko. Sada, kada mi urednik teksta u koji kucam ovaj članak prijekorno kaže da je stranica 15 otišla, sumiraću privremene rezultate. Drugi trikovi, API-ji, vizualni alati i zamke će biti pokriveni u sljedećem članku.

Zaključci:

  • Morate poznavati alate za rad sa nitima, asinhroniju i paralelizam da biste koristili resurse savremenih računara.
  • .NET ima mnogo različitih alata za ove svrhe
  • Nisu se svi pojavili odjednom, tako da često možete pronaći stare, međutim, postoje načini za pretvaranje starih API-ja bez puno truda.
  • Rad sa 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 upotrebu. Umjesto toga, bolje je koristiti mehanizam CancellationToken
  • Protok je vrijedan resurs i njegova ponuda je ograničena. Treba izbjegavati situacije u kojima su niti zauzete čekanjem događaja. Za ovo je zgodno koristiti klasu TaskCompletionSource
  • Najmoćniji i najnapredniji .NET alati za rad sa paralelizmom i asinhronijom su Tasks.
  • C# operatori async/await implementiraju koncept čekanja bez blokiranja
  • Možete kontrolirati distribuciju zadataka kroz niti koristeći klase izvedene iz TaskSchedulera
  • Struktura ValueTask može biti korisna u optimizaciji vrućih puteva i memorijskog prometa
  • Visual Studio prozori Tasks and Threads pružaju mnogo korisnih informacija za otklanjanje grešaka u višenitnom ili asinkronom kodu
  • PLinq je super alat, ali možda nema dovoljno informacija o vašem izvoru podataka, ali to se može popraviti pomoću mehanizma particioniranja
  • Da se nastavi ...

izvor: www.habr.com

Dodajte komentar