.NET: Orodja za delo z večnitnostjo in asinhronijo. 1. del

Na Habru objavljam izvirni članek, katerega prevod je objavljen v korporativnem objava na blogu.

Potreba narediti nekaj asinhrono, brez čakanja na rezultat tukaj in zdaj ali razdeliti veliko delo med več enot, ki ga izvajajo, je obstajala že pred pojavom računalnikov. Z njihovim prihodom je ta potreba postala zelo otipljiva. Zdaj, leta 2019, ta članek tipkam na prenosniku z 8-jedrnim procesorjem Intel Core, na katerem vzporedno teče več kot sto procesov in še več niti. V bližini je malo zanikran telefon, kupljen pred nekaj leti, ima 8-jedrni procesor na krovu. Tematski viri so polni člankov in videov, kjer njihovi avtorji občudujejo letošnje vodilne pametne telefone s 16-jedrnimi procesorji. MS Azure ponuja virtualni stroj s 20-jedrnim procesorjem in 128 TB RAM-a za manj kot 2 USD/uro. Na žalost je nemogoče izvleči maksimum in izkoristiti to moč, ne da bi lahko upravljali interakcijo niti.

Terminologija

Proces - Objekt OS, izoliran naslovni prostor, vsebuje niti.
nit - objekt OS, najmanjša izvršilna enota, del procesa, niti si delijo pomnilnik in druge vire znotraj procesa.
Večopravilnost - Lastnost OS, zmožnost izvajanja več procesov hkrati
Večjedrni - lastnost procesorja, zmožnost uporabe več jeder za obdelavo podatkov
Večprocesiranje - lastnost računalnika, sposobnost fizičnega hkratnega dela z več procesorji
Večnitnost — lastnost procesa, zmožnost porazdelitve obdelave podatkov med več niti.
Paralelizem - izvajanje več dejanj fizično hkrati v časovni enoti
Asinhronost — izvedba operacije brez čakanja na zaključek te obdelave; rezultat izvedbe se lahko obdela kasneje.

Metafora

Niso vse definicije dobre in nekatere potrebujejo dodatno razlago, zato bom formalno uvedeni terminologiji dodal metaforo o kuhanju zajtrka. Kuhanje zajtrka v tej metafori je proces.

Ko sem zjutraj pripravljal zajtrk (CPU) pridem v kuhinjo (Računalnik). Imam 2 roki (Jedra). V kuhinji je kar nekaj naprav (IO): pečica, kuhalnik vode, opekač kruha, hladilnik. Prižgem plin, nanj postavim ponev in vanjo vlijem olje, ne da bi čakala, da se segreje (asinhrono, Non-Blocking-IO-Wait), jajca vzamem iz hladilnika in jih razbijem na krožnik, nato jih stepem z eno roko (Nit #1), in drugič (Nit #2), ki drži krožnik (vir v skupni rabi). Zdaj bi rad prižgal kotliček, pa nimam dovolj rok (Stradanje niti) V tem času se segreje ponev (Obdelava rezultata) v katero vlijem, kar sem stepla. Posežem po kotličku in ga prižgem ter neumno gledam kako voda v njem vre (Blokiranje-IO-Počakaj), čeprav bi v tem času lahko pomil krožnik, kjer je stepel omleto.

Omleto sem spekla samo z 2 rokama in jih nimam več, hkrati pa so se v trenutku stepanja omlete odvijale 3 operacije naenkrat: stepanje omlete, držanje krožnika, segrevanje ponve CPU je najhitrejši del računalnika, IO je tisto, kar največkrat vse upočasni, zato je pogosto učinkovita rešitev, da CPE z nečim zasedemo, medtem ko prejemamo podatke iz IO.

Nadaljevanje metafore:

  • Če bi se med pripravo omlete poskusil tudi preobleči, bi bil to primer večopravilnosti. Pomemben odtenek: računalniki so v tem veliko boljši od ljudi.
  • Kuhinja z več kuharji, na primer v restavraciji - večjedrni računalnik.
  • Številne restavracije v restavracijah v nakupovalnem središču - podatkovnem centru

Orodja .NET

.NET je dober pri delu z nitmi, tako kot pri mnogih drugih stvareh. Z vsako novo različico uvaja vedno več novih orodij za delo z njimi, nove plasti abstrakcije nad niti OS. Pri delu s konstrukcijo abstrakcij razvijalci ogrodja uporabljajo pristop, ki pušča možnost, da se pri uporabi visokonivojske abstrakcije spustijo eno ali več stopenj nižje. Najpogosteje to ni potrebno, pravzaprav odpre vrata, da se s puško ustreliš v stopalo, a včasih, v redkih primerih, je lahko edini način za rešitev problema, ki ni rešen na trenutni ravni abstrakcije. .

Z orodji mislim tako na vmesnike za programiranje aplikacij (API), ki jih zagotavljajo okvir in paketi tretjih oseb, kot tudi na celotne programske rešitve, ki poenostavijo iskanje morebitnih težav, povezanih z večnitno kodo.

Začetek niti

Razred Thread je najosnovnejši razred v .NET za delo z nitmi. Konstruktor sprejme enega od dveh delegatov:

  • ThreadStart — Brez parametrov
  • ParametrizedThreadStart - z enim parametrom tipa object.

Delegat bo izveden v novo ustvarjeni niti po klicu metode Start.Če je bil konstruktorju posredovan delegat tipa ParametrizedThreadStart, je treba predmet posredovati metodi Start. Ta mehanizem je potreben za prenos vseh lokalnih informacij v tok. Omeniti velja, da je ustvarjanje niti draga operacija, sama nit pa težak predmet, vsaj zato, ker skladu dodeli 1 MB pomnilnika in zahteva interakcijo z API-jem OS.

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

Razred ThreadPool predstavlja koncept bazena. V .NET je nabor niti del inženiringa in Microsoftovi razvijalci so vložili veliko truda v zagotavljanje optimalnega delovanja v najrazličnejših scenarijih.

Splošni koncept:

Od trenutka, ko se aplikacija zažene, ustvari več niti v rezervi v ozadju in nudi možnost, da jih vzame v uporabo. Če se niti uporabljajo pogosto in v velikem številu, se skupina razširi, da zadosti potrebam klicatelja. Če v naboru ob pravem času ni prostih niti, bo bodisi počakal, da se ena od niti vrne, bodisi ustvaril novo. Iz tega sledi, da je skupina niti odlična za nekatera kratkoročna dejanja in slabo primerna za operacije, ki se izvajajo kot storitve skozi celotno delovanje aplikacije.

Za uporabo niti iz področja obstaja metoda QueueUserWorkItem, ki sprejme delegata tipa WaitCallback, ki ima enak podpis kot ParametrizedThreadStart, parameter, ki mu je posredovan, pa izvaja isto funkcijo.

ThreadPool.QueueUserWorkItem(...);

Manj znana metoda bazena niti RegisterWaitForSingleObject se uporablja za organizacijo operacij IO brez blokiranja. Delegat, posredovan tej metodi, bo poklican, ko bo WaitHandle, posredovan metodi, »Sproščeno«.

ThreadPool.RegisterWaitForSingleObject(...)

.NET ima časovnik niti in se od časovnikov WinForms/WPF razlikuje po tem, da bo njegov upravljalnik poklican v niti, vzeti iz bazena.

System.Threading.Timer

Obstaja tudi precej eksotičen način za pošiljanje delegata za izvedbo v nit iz bazena - metoda BeginInvoke.

DelegateInstance.BeginInvoke

Na kratko bi se rad osredotočil na funkcijo, na katero je mogoče poklicati številne zgornje metode - CreateThread iz Kernel32.dll Win32 API. Zahvaljujoč mehanizmu zunanjih metod obstaja način za klic te funkcije. Tak klic sem videl le enkrat v strašnem primeru zapuščine kode in motivacija avtorja, ki je naredil točno to, mi še vedno ostaja skrivnost.

Kernel32.dll CreateThread

Ogled in odpravljanje napak niti

Niti, ki ste jih ustvarili vi, vse komponente tretjih oseb in skupino .NET si lahko ogledate v oknu Threads programa Visual Studio. V tem oknu bodo prikazane informacije o niti, ko je aplikacija v fazi odpravljanja napak in v načinu prekinitve. Tukaj si lahko priročno ogledate imena skladov in prioritete vsake niti ter preklopite odpravljanje napak na določeno nit. Z uporabo lastnosti Priority razreda Thread lahko nastavite prioriteto niti, ki jo bosta OC in CLR zaznala kot priporočilo pri razdelitvi procesorskega časa med niti.

.NET: Orodja za delo z večnitnostjo in asinhronijo. 1. del

Vzporedna knjižnica opravil

Task Parallel Library (TPL) je bil predstavljen v .NET 4.0. Zdaj je standard in glavno orodje za delo z asinhronijo. Vsaka koda, ki uporablja starejši pristop, se šteje za podedovano. Osnovna enota TPL je razred Task iz imenskega prostora System.Threading.Tasks. Naloga je abstrakcija nad nitjo. Z novo različico jezika C# smo dobili eleganten način dela s Tasks - async/await operatorji. Ti koncepti so omogočili pisanje asinhrone kode, kot da bi bila preprosta in sinhrona, kar je omogočilo celo ljudem, ki malo razumejo notranje delovanje niti, da napišejo aplikacije, ki jih uporabljajo, aplikacije, ki ne zamrznejo pri izvajanju dolgih operacij. Uporaba async/await je tema za enega ali celo več člankov, vendar bom poskušal razumeti bistvo v nekaj stavkih:

  • async je modifikator metode, ki vrne Task ali void
  • in await je operater čakajočega opravila, ki ne blokira.

Še enkrat: operater čakanja bo v splošnem primeru (obstajajo izjeme) sproščal trenutno nit izvajanja naprej, in ko opravilo konča z izvajanjem, in nit (pravzaprav bi bilo pravilneje reči kontekst , vendar več o tem pozneje) bo nadaljeval z izvajanjem metode naprej. Znotraj .NET je ta mehanizem implementiran na enak način kot yield return, ko se zapisana metoda spremeni v celoten razred, ki je stroj stanja in se lahko izvaja v ločenih delih, odvisno od teh stanj. Vsi zainteresirani lahko napišejo katero koli preprosto kodo z uporabo asynс/await, prevedejo in si ogledajo sestav z uporabo JetBrains dotPeek z omogočeno kodo, ki jo ustvari prevajalnik.

Oglejmo si možnosti za zagon in uporabo Task. V spodnjem primeru kode ustvarimo novo nalogo, ki ne naredi nič koristnega (Thread.Sleep(10000)), toda v resničnem življenju bi to moralo biti zapleteno CPE-intenzivno delo.

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
}

Naloga je ustvarjena s številnimi možnostmi:

  • LongRunning je namig, da opravilo ne bo hitro dokončano, kar pomeni, da je morda vredno razmisliti o tem, da ne vzamete niti iz skupine, temveč ustvarite ločeno nit za to nalogo, da ne škodujete drugim.
  • AttachedToParent – ​​Naloge je mogoče razporediti v hierarhijo. Če je bila uporabljena ta možnost, je opravilo morda v stanju, ko je samo dokončano in čaka na izvedbo svojih podrejenih.
  • PreferFairness - pomeni, da bi bilo naloge, poslane v izvedbo, bolje izvesti prej pred tistimi, ki so poslane pozneje. Vendar je to le priporočilo in rezultati niso zagotovljeni.

Drugi parameter, posredovan metodi, je CancellationToken. Za pravilno obravnavanje preklica operacije, potem ko se je začela, mora biti koda, ki se izvaja, izpolnjena s preverjanji za stanje CancellationToken. Če ni nobenih preverjanj, bo metoda Cancel, poklicana na objektu CancellationTokenSource, lahko ustavila izvajanje naloge šele preden se začne.

Zadnji parameter je objekt planerja tipa TaskScheduler. Ta razred in njegovi potomci so zasnovani za nadzor strategij za distribucijo nalog po nitih; privzeto bo naloga izvedena v naključni niti iz skupine.

Operator await se uporabi za ustvarjeno opravilo, kar pomeni, da se bo koda, napisana za njim, če obstaja, izvedla v istem kontekstu (pogosto to pomeni v isti niti) kot koda pred awaitom.

Metoda je označena kot async void, kar pomeni, da lahko uporablja operator await, vendar klicna koda ne bo mogla čakati na izvedbo. Če je takšna funkcija potrebna, mora metoda vrniti Task. Metode z oznako async void so precej pogoste: praviloma so to obdelovalci dogodkov ali druge metode, ki delujejo po principu požar in pozaba. Če morate ne samo dati priložnost, da počakate do konca izvajanja, ampak tudi vrnete rezultat, potem morate uporabiti Task.

Na nalogi, ki jo je vrnila metoda StartNew, kot tudi na kateri koli drugi, lahko pokličete metodo ConfigureAwait z napačnim parametrom, nato pa se izvajanje po čakanju ne bo nadaljevalo na zajetem kontekstu, ampak na poljubnem. To je treba storiti vedno, ko kontekst izvajanja ni pomemben za kodo po čakanju. To je tudi priporočilo MS pri pisanju kode, ki bo dostavljena zapakirana v knjižnici.

Posvetimo se še malo tem, kako lahko počakate na dokončanje naloge. Spodaj je primer kode s komentarji o tem, kdaj je pričakovanje izvedeno pogojno dobro in kdaj je pogojno slabo.

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
}

V prvem primeru počakamo, da se naloga zaključi, ne da bi blokirali klicno nit; k obdelavi rezultata se bomo vrnili šele, ko je že tam; do takrat je klicoča nit prepuščena sama sebi.

Pri drugi možnosti blokiramo klicno nit, dokler ni izračunan rezultat metode. To ni slabo samo zato, ker smo nit, tako dragocen vir programa, zasedli s preprostim mirovanjem, ampak tudi zato, ker če koda metode, ki jo kličemo, vsebuje await, in kontekst sinhronizacije zahteva vrnitev v klicno nit po await, potem bomo dobili zastoj : Klicajoča nit čaka na izračun rezultata asinhrone metode, asinhrona metoda zaman poskuša nadaljevati svojo izvedbo v klicni niti.

Druga pomanjkljivost tega pristopa je zapleteno obravnavanje napak. Dejstvo je, da je napake v asinhroni kodi pri uporabi async/await zelo enostavno obravnavati – obnašajo se na enak način, kot če bi bila koda sinhrona. Če uporabimo sinhroni čakalni eksorcizem za opravilo, se prvotna izjema spremeni v AggregateException, tj. Za obravnavo izjeme boste morali preučiti vrsto InnerException in sami napisati verigo if znotraj enega bloka catch ali uporabiti konstrukcijo catch when, namesto bolj poznane verige blokov catch v svetu C#.

Tretji in zadnji primer sta prav tako označena kot slabo iz istega razloga in vsebujeta vse iste težave.

Metodi WhenAny in WhenAll sta izjemno priročni za čakanje na skupino opravil; združita skupino opravil v eno, ki se bo sprožilo bodisi, ko bo opravilo iz skupine prvič sproženo ali ko bodo vsa dokončala svojo izvedbo.

Ustavljanje niti

Zaradi različnih razlogov bo morda treba ustaviti tok, potem ko se je začel. To lahko storite na več načinov. Razred Thread ima dve ustrezno poimenovani metodi: splav и Prekinite. Prvi je zelo odsvetovan za uporabo, ker po klicu v katerem koli naključnem trenutku, med obdelavo katerega koli ukaza, bo vržena izjema ThreadAbortedException. Ne pričakujete, da bo taka izjema vržena pri povečevanju katere koli spremenljivke celega števila, kajne? In pri uporabi te metode je to zelo realna situacija. Če morate preprečiti, da bi CLR ustvaril takšno izjemo v določenem delu kode, jo lahko zavijete v klice Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Vsaka koda, zapisana v bloku finally, je ovita v takšne klice. Zaradi tega lahko v globinah kode ogrodja najdete bloke s praznim poskusom, ne pa tudi s praznim finalom. Microsoft to metodo tako odvrača, da je niso vključili v .net core.

Metoda prekinitve deluje bolj predvidljivo. Z izjemo lahko prekine nit ThreadInterruptedException samo v tistih trenutkih, ko je nit v stanju čakanja. V to stanje preide, ko visi med čakanjem na WaitHandle, zaklepanje ali po klicu Thread.Sleep.

Obe zgoraj opisani možnosti sta slabi zaradi svoje nepredvidljivosti. Rešitev je uporaba strukture CancellationToken in razred CancellationTokenSource. Bistvo je naslednje: ustvarjen je primerek razreda CancellationTokenSource in le tisti, ki ima v lasti, lahko prekine operacijo s klicem metode Prekliči. Samo CancellationToken se posreduje sami operaciji. Lastniki CancellationToken ne morejo sami preklicati operacije, ampak lahko le preverijo, ali je bila operacija preklicana. Za to obstaja logična lastnost IsCancellationRequested in metoda ThrowIfCancelRequested. Slednje bo povzročilo izjemo TaskCancelledException če je bila metoda Cancel klicana na primerku CancellationToken, ki je bil reproduciran. In to je metoda, ki jo priporočam. To je izboljšava v primerjavi s prejšnjimi možnostmi, saj pridobite popoln nadzor nad tem, na kateri točki je mogoče prekiniti operacijo izjeme.

Najbolj brutalna možnost za zaustavitev niti je klic funkcije Win32 API TerminateThread. Vedenje CLR po klicu te funkcije je lahko nepredvidljivo. Na MSDN je o tej funkciji zapisano naslednje: »TerminateThread je nevarna funkcija, ki bi jo morali uporabljati le v najbolj skrajnih primerih. “

Pretvarjanje podedovanega API-ja v Task Based z uporabo metode FromAsync

Če imate srečo, da delate na projektu, ki se je začel po uvedbi Tasks in večini razvijalcev ni več povzročal tihe groze, potem se vam ne bo treba ukvarjati z veliko starimi API-ji, tako tistimi tretjih oseb kot tistimi vaše ekipe je v preteklosti mučil. Na srečo je ekipa .NET Framework poskrbela za nas, čeprav je bil morda cilj, da poskrbimo zase. Kakor koli že, .NET ima številna orodja za neboleče pretvorbo kode, napisane v starih pristopih asinhronega programiranja, v novo. Ena izmed njih je metoda FromAsync podjetja TaskFactory. V spodnjem primeru kode s to metodo zavijem stare asinhrone metode razreda WebRequest v opravilo.

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

To je samo primer in malo verjetno vam bo to treba storiti z vgrajenimi tipi, vendar je vsak stari projekt preprosto poln metod BeginDoSomething, ki vrnejo IAsyncResult, in metod EndDoSomething, ki to prejmejo.

Pretvorite stari API v Task Based z uporabo razreda TaskCompletionSource

Drugo pomembno orodje, ki ga je treba upoštevati, je razred TaskCompletionSource. Po funkcijah, namenu in principu delovanja morda nekoliko spominja na metodo RegisterWaitForSingleObject razreda ThreadPool, o kateri sem pisal zgoraj. Z uporabo tega razreda lahko preprosto in priročno zavijete stare asinhrone API-je v opravila.

Rekli boste, da sem že govoril o metodi FromAsync razreda TaskFactory, ki je namenjena za te namene. Tu se bomo morali spomniti celotne zgodovine razvoja asinhronih modelov v .net, ki jih je Microsoft ponudil v zadnjih 15 letih: pred Task-Based Asynchronous Pattern (TAP) je obstajal Asynchronous Programming Pattern (APP), ki je šlo za metode ZačniteNaredi nekaj, kar se vrača IAsyncResult in metode konecDoSomething, ki jo sprejema, in glede na dediščino teh let je metoda FromAsync preprosto popolna, vendar jo je sčasoma nadomestil asinhroni vzorec na podlagi dogodkov (EAP), ki je predvideval, da se bo dogodek sprožil, ko bo asinhrona operacija končana.

TaskCompletionSource je kot nalašč za zavijanje opravil in podedovanih API-jev, zgrajenih okoli modela dogodkov. Bistvo njegovega dela je naslednje: objekt tega razreda ima javno lastnost tipa Task, katere stanje je mogoče nadzorovati z metodami SetResult, SetException itd. razreda TaskCompletionSource. Na mestih, kjer je bil za to opravilo uporabljen operator čakanja, bo izvedeno ali ne bo uspelo z izjemo, odvisno od metode, uporabljene za TaskCompletionSource. Če še vedno ni jasno, si poglejmo ta primer kode, kjer je nekaj starega API-ja EAP zavito v opravilo z uporabo TaskCompletionSource: ko se sproži dogodek, bo opravilo preneseno v stanje dokončano, metoda, ki je uporabila operator čakanja, pa do te naloge bo nadaljevala z izvajanjem po prejemu predmeta povzroči.

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

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

    result completionSource.Task;
}

Nasveti in triki TaskCompletionSource

Ovijanje starih API-jev ni vse, kar je mogoče storiti z uporabo TaskCompletionSource. Uporaba tega razreda odpira zanimivo možnost oblikovanja različnih API-jev za naloge, ki ne zasedajo niti. In tok, kot se spomnimo, je drag vir in njihovo število je omejeno (predvsem s količino RAM-a). To omejitev je mogoče enostavno doseči z razvojem na primer naložene spletne aplikacije s kompleksno poslovno logiko. Razmislimo o možnostih, o katerih govorim, ko izvajamo tak trik, kot je Long-Polling.

Na kratko, bistvo trika je naslednje: od API-ja morate prejeti informacije o nekaterih dogodkih, ki se zgodijo na njegovi strani, medtem ko API iz nekega razloga ne more poročati o dogodku, ampak lahko samo vrne stanje. Primer teh so vsi API-ji, zgrajeni na vrhu HTTP-ja pred časi WebSocket-a ali ko iz nekega razloga ni bilo mogoče uporabiti te tehnologije. Odjemalec lahko vpraša strežnik HTTP. Strežnik HTTP ne more sam začeti komunikacije z odjemalcem. Preprosta rešitev je anketiranje strežnika s časovnikom, vendar to povzroči dodatno obremenitev strežnika in dodatno zamudo pri povprečnem TimerInterval / 2. Da bi se temu izognili, je bil izumljen trik, imenovan Dolgo anketiranje, ki vključuje zakasnitev odziva strežnik, dokler ne poteče časovna omejitev ali se zgodi dogodek. Če se je dogodek zgodil, se ga obdela, če ne, se zahteva ponovno pošlje.

while(!eventOccures && !timeoutExceeded)  {

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

Toda takšna rešitev se bo izkazala za grozljivo, takoj ko se bo število strank, ki čakajo na dogodek, povečalo, saj ... Vsak tak odjemalec zasede celotno nit, ki čaka na dogodek. Da, in ob sprožitvi dogodka dobimo dodatno zakasnitev 1 ms, največkrat to ni pomembno, ampak zakaj bi programsko opremo naredili slabšo, kot bi lahko bila? Če odstranimo Thread.Sleep(1), potem bomo zaman naložili eno procesorsko jedro v 100% mirovanju, ki se vrti v neuporabnem ciklu. Z uporabo TaskCompletionSource lahko preprosto predelate to kodo in rešite vse zgoraj navedene težave:

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

Ta koda ni pripravljena za proizvodnjo, ampak samo demo. Če ga želite uporabiti v resničnih primerih, morate tudi vsaj obvladati situacijo, ko sporočilo prispe v času, ko ga nihče ne pričakuje: v tem primeru mora metoda AsseptMessageAsync vrniti že dokončano opravilo. Če je to najpogostejši primer, potem lahko razmislite o uporabi ValueTask.

Ko prejmemo zahtevo za sporočilo, ustvarimo in postavimo TaskCompletionSource v slovar, nato pa počakamo, kaj se najprej zgodi: poteče določeni časovni interval ali prejme sporočilo.

ValueTask: zakaj in kako

Operatorji async/await, tako kot operator yield return, generirajo stanje stroj iz metode in to je ustvarjanje novega objekta, ki skoraj vedno ni pomemben, vendar lahko v redkih primerih povzroči težavo. Ta primer je morda metoda, ki se kliče zelo pogosto, govorimo o desetinah in sto tisočih klicev na sekundo. Če je takšna metoda napisana tako, da v večini primerov vrne rezultat mimo vseh čakajočih metod, potem .NET ponuja orodje za optimizacijo tega - strukturo ValueTask. Da bo jasno, si poglejmo primer njegove uporabe: obstaja predpomnilnik, ki ga zelo pogosto obiskujemo. V njem je nekaj vrednosti, nato pa jih preprosto vrnemo; če ne, gremo na počasen IO, da jih dobimo. Slednje želim narediti asinhrono, kar pomeni, da se celotna metoda izkaže za asinhrono. Tako je očiten način za pisanje metode naslednji:

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

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

Zaradi želje po manjši optimizaciji in rahlega strahu pred tem, kaj bo Roslyn ustvaril pri prevajanju te kode, lahko ta primer prepišete na naslednji način:

public Task<string> GetById(int id) {

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

Dejansko bi bila optimalna rešitev v tem primeru optimizacija vroče poti, in sicer pridobivanje vrednosti iz slovarja brez nepotrebnih dodelitev in obremenitve GC, medtem ko je v tistih redkih primerih, ko moramo še vedno iti na IO po podatke. , bo vse ostalo plus/minus po starem:

public ValueTask<string> GetById(int id) {

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

Oglejmo si podrobneje ta del kode: če je v predpomnilniku vrednost, ustvarimo strukturo, sicer bo prava naloga ovita v smiselno. Klicne kode ne zanima, na kateri poti je bila ta koda izvedena: ValueTask se bo z vidika sintakse C# v tem primeru obnašal enako kot običajna naloga.

TaskSchedulers: upravljanje strategij zagona opravil

Naslednji API, ki bi ga rad razmislil, je razred TaskScheduler in njegove izpeljanke. Zgoraj sem že omenil, da ima TPL možnost upravljanja strategij za razdeljevanje opravil po nitih. Takšne strategije so definirane v potomcih razreda TaskScheduler. Skoraj vsako strategijo, ki jo morda potrebujete, lahko najdete v knjižnici. ParallelExtensionsExtras, ki ga je razvil Microsoft, vendar ni del .NET, ampak je dobavljen kot paket Nuget. Oglejmo si na kratko nekatere izmed njih:

  • CurrentThreadTaskScheduler — izvaja naloge v trenutni niti
  • LimitedConcurrencyLevelTaskScheduler — omejuje število nalog, ki se izvajajo hkrati s parametrom N, ki je sprejet v konstruktorju
  • OrderedTaskScheduler — je definiran kot LimitedConcurrencyLevelTaskScheduler(1), zato se bodo naloge izvajale zaporedno.
  • WorkStealingTaskScheduler - pripomočki kraje dela pristop k razdelitvi nalog. V bistvu gre za ločen ThreadPool. Rešuje problem, ker je v .NET ThreadPool statični razred, eden za vse aplikacije, kar pomeni, da lahko njegova preobremenitev ali nepravilna uporaba v enem delu programa povzroči stranske učinke v drugem. Poleg tega je zelo težko razumeti vzrok takšnih napak. to. Morda bo treba uporabiti ločene WorkStealingTaskSchedulerje v delih programa, kjer je uporaba ThreadPoola lahko agresivna in nepredvidljiva.
  • QueuedTaskScheduler — omogoča izvajanje nalog v skladu s pravili prednostne čakalne vrste
  • ThreadPerTaskScheduler — ustvari ločeno nit za vsako nalogo, ki se na njej izvaja. Uporabno je lahko za naloge, ki trajajo nepredvidljivo dolgo.

Obstaja dobra podrobnost članek o TaskSchedulers na Microsoftovem blogu.

Za priročno odpravljanje napak v vsem, kar je povezano z opravili, ima Visual Studio okno opravil. V tem oknu lahko vidite trenutno stanje opravila in skočite na trenutno izvajajočo se vrstico kode.

.NET: Orodja za delo z večnitnostjo in asinhronijo. 1. del

PLinq in razred Parallel

Poleg Tasks in vsega povedanega o njih sta v .NET še dve zanimivi orodji: PLinq (Linq2Parallel) in razred Parallel. Prvi obljublja vzporedno izvajanje vseh operacij Linq v več nitih. Število niti je mogoče konfigurirati z metodo razširitve WithDegreeOfParalleism. Na žalost PLinq v svojem privzetem načinu najpogosteje nima dovolj informacij o notranjosti vašega vira podatkov, da bi zagotovil znatno povečanje hitrosti, po drugi strani pa je strošek poskusa zelo nizek: le poklicati morate metodo AsParallel, preden verigo metod Linq in zaženite teste zmogljivosti. Poleg tega je možno PLinqu posredovati dodatne informacije o naravi vašega vira podatkov z uporabo mehanizma particij. Lahko preberete več tukaj и tukaj.

Statični razred Parallel ponuja metode za vzporedno ponavljanje skozi zbirko Foreach, izvajanje zanke For in izvajanje več delegatov v vzporednem Invoke. Izvajanje trenutne niti bo ustavljeno, dokler izračuni niso končani. Število niti je mogoče konfigurirati tako, da podate ParallelOptions kot zadnji argument. Z možnostmi lahko določite tudi TaskScheduler in CancellationToken.

Ugotovitve

Ko sem začel pisati ta članek na podlagi gradiva mojega poročila in informacij, ki sem jih zbral med delom po njem, nisem pričakoval, da ga bo toliko. Zdaj, ko mi urejevalnik besedil, v katerega vnašam ta članek, očitajoče pove, da je stran 15 izginila, bom povzel vmesne rezultate. Drugi triki, API-ji, vizualna orodja in pasti bodo obravnavani v naslednjem članku.

Sklepi:

  • Če želite uporabljati vire sodobnih osebnih računalnikov, morate poznati orodja za delo z nitmi, asinhronijo in paralelizmom.
  • .NET ima veliko različnih orodij za te namene
  • Niso se vsi pojavili naenkrat, zato lahko pogosto najdete podedovane, vendar obstajajo načini za pretvorbo starih API-jev brez veliko truda.
  • Delo z nitmi v .NET predstavljajo razreda Thread in ThreadPool
  • Metode Thread.Abort, Thread.Interrupt in Win32 API TerminateThread so nevarne in jih ne priporočamo za uporabo. Namesto tega je bolje uporabiti mehanizem CancellationToken
  • Pretok je dragocen vir in njegova ponudba je omejena. Izogibati se je treba situacijam, ko so niti zasedene in čakajo na dogodke. Za to je priročno uporabiti razred TaskCompletionSource
  • Najzmogljivejša in najnaprednejša orodja .NET za delo s paralelizmom in asinhronijo so Tasks.
  • Operatorji C# async/await izvajajo koncept čakanja brez blokiranja
  • Porazdelitev opravil po nitih lahko nadzirate z uporabo razredov, izpeljanih iz TaskSchedulerja
  • Struktura ValueTask je lahko uporabna pri optimizaciji vročih poti in pomnilniškega prometa
  • Okna Opravila in Niti programa Visual Studio nudijo veliko informacij, ki so uporabne za razhroščevanje večnitne ali asinhrone kode
  • PLinq je kul orodje, vendar morda nima dovolj informacij o vašem viru podatkov, vendar je to mogoče popraviti z mehanizmom particioniranja
  • Se nadaljuje ...

Vir: www.habr.com

Dodaj komentar