.NET: Nástroje pre prácu s multithreadingom a asynchróniou. Časť 1

Uverejňujem pôvodný článok o Habrovi, ktorého preklad je zverejnený v korporáte blogový príspevok.

Potreba robiť niečo asynchrónne, bez čakania na výsledok tu a teraz, alebo rozdeliť veľkú prácu medzi niekoľko jednotiek, ktoré ju vykonávajú, existovala už pred príchodom počítačov. S ich príchodom sa táto potreba stala veľmi hmatateľnou. Teraz, v roku 2019, píšem tento článok na notebooku s 8-jadrovým procesorom Intel Core, na ktorom paralelne beží viac ako sto procesov a ešte viac vlákien. Neďaleko je trochu ošúchaný telefón, kúpený pred pár rokmi, má na palube 8-jadrový procesor. Tematické zdroje sú plné článkov a videí, kde ich autori obdivujú tohtoročné vlajkové smartfóny s 16-jadrovými procesormi. MS Azure poskytuje virtuálny stroj so 20-jadrovým procesorom a 128 TB RAM za menej ako 2 USD/hodinu. Žiaľ, nie je možné vyťažiť maximum a využiť túto silu bez toho, aby sme dokázali zvládnuť interakciu vlákien.

terminológie

Proces - Objekt OS, izolovaný adresný priestor, obsahuje vlákna.
Niť - objekt OS, najmenšia jednotka vykonávania, súčasť procesu, vlákna zdieľajú medzi sebou pamäť a iné zdroje v rámci procesu.
multitasking - Vlastnosť OS, schopnosť spúšťať niekoľko procesov súčasne
Viacjadrový - vlastnosť procesora, možnosť využiť na spracovanie údajov viacero jadier
Viacnásobné spracovanie - vlastnosť počítača, schopnosť súčasne fyzicky pracovať s viacerými procesormi
Multithreading — vlastnosť procesu, schopnosť rozdeliť spracovanie údajov medzi niekoľko vlákien.
Paralelizmus - vykonávanie viacerých úkonov fyzicky súčasne za jednotku času
Asynchrónnosť — vykonanie operácie bez čakania na dokončenie tohto spracovania; výsledok vykonania je možné spracovať neskôr.

metafora

Nie všetky definície sú dobré a niektoré potrebujú ďalšie vysvetlenie, takže k formálne zavedenej terminológii pridám metaforu o varení raňajok. Varenie raňajok v tejto metafore je proces.

Pri rannej príprave raňajok som (CPU) Prichádzam do kuchyne (počítačový). Mám 2 ruky (Jadra). V kuchyni je množstvo zariadení (IO): rúra, rýchlovarná kanvica, hriankovač, chladnička. Zapnem plyn, postavím naň panvicu a nalejem do nej olej bez toho, aby som čakal, kým sa rozohreje (asynchrónne, Non-Blocking-IO-Wait), vyberiem vajcia z chladničky a rozbijem ich na tanier, potom ich jednou rukou rozšľahám (Vlákno č. 1) a druhý (Vlákno č. 2) držaním taniera (Zdieľaný zdroj). Teraz by som chcel zapnúť kanvicu, ale nemám dosť rúk (Niť hladovanie) Počas tejto doby sa rozohreje panvica (Spracovanie výsledku) do ktorej nalejem to, čo som vyšľahala. Natiahnem sa po kanvici, zapnem ju a hlúpo sledujem, ako v nej vrie voda (Blocking-IO-Wait), hoci počas tejto doby mohol umyť tanier, kde šľahal omeletu.

Uvarila som omeletu len 2 rukami a viac nemám, no zároveň v momente šľahania omelety prebehli naraz 3 operácie: šľahanie omelety, držanie taniera, nahrievanie panvice. CPU je najrýchlejšia časť počítača, IO je to, čo sa najčastejšie všetko spomaľuje, preto je často efektívnym riešením niečím obsadiť CPU a zároveň prijímať dáta z IO.

Pokračovanie v metafore:

  • Ak by som sa v procese prípravy omelety pokúsil aj prezliecť, toto by bol príklad multitaskingu. Dôležitá nuansa: počítače sú v tomto oveľa lepšie ako ľudia.
  • Kuchyňa s viacerými kuchármi, napríklad v reštaurácii – viacjadrový počítač.
  • Veľa reštaurácií vo food courte v nákupnom centre - dátovom centre

Nástroje .NET

.NET je dobrý v práci s vláknami, ako s mnohými inými vecami. S každou novou verziou predstavuje viac a viac nových nástrojov na prácu s nimi, nové vrstvy abstrakcie nad vláknami OS. Pri práci s konštrukciou abstrakcií vývojári rámca používajú prístup, ktorý pri použití abstrakcie na vysokej úrovni ponecháva príležitosť ísť o jednu alebo viac úrovní nižšie. Najčastejšie to nie je potrebné, v skutočnosti to otvára dvere streleniu sa do nohy brokovnicou, ale niekedy, v zriedkavých prípadoch, to môže byť jediný spôsob, ako vyriešiť problém, ktorý na súčasnej úrovni abstrakcie nie je vyriešený. .

Nástrojmi mám na mysli ako aplikačné programovacie rozhrania (API) poskytované rámcom a balíkmi tretích strán, ako aj celé softvérové ​​riešenia, ktoré zjednodušujú vyhľadávanie akýchkoľvek problémov súvisiacich s viacvláknovým kódom.

Spustenie vlákna

Trieda Thread je najzákladnejšia trieda v .NET pre prácu s vláknami. Konštruktor prijíma jedného z dvoch delegátov:

  • ThreadStart — Žiadne parametre
  • ParametrizedThreadStart - s jedným parametrom typu objekt.

Delegát sa vykoná v novovytvorenom vlákne po zavolaní metódy Start Ak bol do konštruktora odovzdaný delegát typu ParametrizedThreadStart, potom je potrebné odovzdať objekt metóde Start. Tento mechanizmus je potrebný na prenos akýchkoľvek miestnych informácií do prúdu. Stojí za zmienku, že vytvorenie vlákna je nákladná operácia a samotné vlákno je ťažký objekt, prinajmenšom preto, že alokuje 1 MB pamäte v zásobníku a vyžaduje interakciu s OS API.

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

Trieda ThreadPool predstavuje koncept fondu. V .NET je fond vlákien kusom inžinierstva a vývojári v Microsofte vynaložili veľké úsilie na zabezpečenie optimálneho fungovania v širokej škále scenárov.

Všeobecná koncepcia:

Od okamihu spustenia aplikácia vytvorí na pozadí niekoľko vlákien v zálohe a poskytne možnosť ich použiť. Ak sa vlákna používajú často a vo veľkom počte, skupina sa rozšíri, aby vyhovovala potrebám volajúceho. Keď v fonde nie sú v správnom čase žiadne voľné vlákna, počká, kým sa jedno z vlákien vráti, alebo vytvorí nové. Z toho vyplýva, že fond vlákien je skvelý pre niektoré krátkodobé akcie a málo vhodný pre operácie, ktoré bežia ako služby počas celej prevádzky aplikácie.

Na použitie vlákna z oblasti existuje metóda QueueUserWorkItem, ktorá akceptuje delegáta typu WaitCallback, ktorý má rovnaký podpis ako ParametrizedThreadStart a parameter, ktorý je mu odovzdaný, vykonáva rovnakú funkciu.

ThreadPool.QueueUserWorkItem(...);

Menej známa metóda oblasti vlákien RegisterWaitForSingleObject sa používa na organizáciu neblokujúcich operácií IO. Delegát odovzdaný tejto metóde sa zavolá, keď je WaitHandle odovzdaný metóde „Uvoľnený“.

ThreadPool.RegisterWaitForSingleObject(...)

.NET má časovač vlákna a líši sa od časovačov WinForms/WPF tým, že jeho obsluha bude volaná na vlákne prevzatom z fondu.

System.Threading.Timer

Existuje aj pomerne exotický spôsob, ako poslať delegáta na vykonanie do vlákna z fondu – metóda BeginInvoke.

DelegateInstance.BeginInvoke

Chcel by som sa krátko pozastaviť nad funkciou, na ktorú možno zavolať mnohé z vyššie uvedených metód – CreateThread z Kernel32.dll Win32 API. Existuje spôsob, vďaka mechanizmu externých metód, ako túto funkciu zavolať. Takéto volanie som videl iba raz v hroznom príklade dedičného kódu a motivácia autora, ktorý presne toto urobil, mi stále zostáva záhadou.

Kernel32.dll CreateThread

Prezeranie a ladenie vlákien

Vlákna, ktoré ste vytvorili, všetky komponenty tretích strán a fond .NET si môžete prezerať v okne Vlákna vo Visual Studiu. Toto okno zobrazí informácie o vlákne iba vtedy, keď je aplikácia v ladení a v režime prerušenia. Tu môžete pohodlne zobraziť názvy zásobníkov a priority každého vlákna a prepnúť ladenie na konkrétne vlákno. Pomocou vlastnosti Priority triedy Thread môžete nastaviť prioritu vlákna, ktorú OC a CLR budú vnímať ako odporúčanie pri rozdeľovaní času procesora medzi vlákna.

.NET: Nástroje pre prácu s multithreadingom a asynchróniou. Časť 1

Paralelná knižnica úloh

Task Parallel Library (TPL) bola predstavená v .NET 4.0. Teraz je to štandard a hlavný nástroj pre prácu s asynchróniou. Akýkoľvek kód, ktorý používa starší prístup, sa považuje za starý. Základnou jednotkou TPL je trieda Task z menného priestoru System.Threading.Tasks. Úloha je abstrakcia cez vlákno. S novou verziou jazyka C# sme získali elegantný spôsob práce s Tasks – operátory async/wait. Tieto koncepty umožnili písať asynchrónny kód, ako keby bol jednoduchý a synchrónny, čo umožnilo aj ľuďom s malým pochopením vnútorného fungovania vlákien písať aplikácie, ktoré ich používajú, aplikácie, ktoré nezamŕzajú pri vykonávaní dlhých operácií. Používanie async/await je téma pre jeden alebo dokonca niekoľko článkov, ale pokúsim sa to pochopiť niekoľkými vetami:

  • async je modifikátor metódy, ktorý vracia Task alebo void
  • a wait je neblokujúci operátor čakania na úlohy.

Ešte raz: operátor wait vo všeobecnom prípade (existujú výnimky) uvoľní aktuálne vykonávané vlákno ďalej, a keď úloha dokončí svoje vykonávanie, a vlákno (v skutočnosti by bolo správnejšie povedať kontext , ale o tom neskôr) bude pokračovať vo vykonávaní metódy ďalej. Vo vnútri .NET je tento mechanizmus implementovaný rovnakým spôsobom ako výnos výnosu, keď sa napísaná metóda zmení na celú triedu, ktorá je stavovým automatom a môže byť vykonaná v samostatných častiach v závislosti od týchto stavov. Každý, kto má záujem, môže napísať ľubovoľný jednoduchý kód pomocou asynс/await, zostaviť a zobraziť zostavu pomocou JetBrains dotPeek s povoleným kódom generovaným kompilátorom.

Pozrime sa na možnosti spustenia a používania úlohy. V nižšie uvedenom príklade kódu vytvoríme novú úlohu, ktorá nerobí nič užitočné (Thread.Sleep(10000)), ale v reálnom živote by to mala byť zložitá práca náročná na 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
}

Vytvorí sa úloha s niekoľkými možnosťami:

  • LongRunning je náznak, že úloha nebude dokončená rýchlo, čo znamená, že možno stojí za zváženie nevyberať vlákno z fondu, ale vytvoriť samostatné vlákno pre túto úlohu, aby ste nepoškodili ostatných.
  • AttachedToParent - Úlohy môžu byť usporiadané v hierarchii. Ak bola použitá táto možnosť, potom Úloha môže byť v stave, keď sa sama dokončila a čaká na vykonanie svojich potomkov.
  • PreferFairness – znamená, že by bolo lepšie vykonávať úlohy odoslané na vykonanie skôr, než tie, ktoré boli odoslané neskôr. Ale je to len odporúčanie a výsledky nie sú zaručené.

Druhý parameter odovzdaný metóde je CancellationToken. Aby bolo možné správne zvládnuť zrušenie operácie po jej spustení, vykonávaný kód musí byť vyplnený kontrolami stavu CancellationToken. Ak neexistujú žiadne kontroly, metóda Cancel volaná na objekte CancellationTokenSource bude môcť zastaviť vykonávanie úlohy iba pred jej spustením.

Posledným parametrom je objekt plánovača typu TaskScheduler. Táto trieda a jej potomkovia sú navrhnuté tak, aby spravovali stratégie distribúcie úloh medzi vláknami; štandardne sa úloha vykoná na náhodnom vlákne z fondu.

Operátor wait sa aplikuje na vytvorenú úlohu, čo znamená, že kód napísaný za ňou, ak nejaký existuje, bude vykonaný v rovnakom kontexte (často to znamená v rovnakom vlákne) ako kód predtým wait.

Metóda je označená ako async void, čo znamená, že môže použiť operátora wait, ale volací kód nebude môcť čakať na vykonanie. Ak je takáto funkcia potrebná, metóda musí vrátiť úlohu. Metódy označené ako async void sú celkom bežné: spravidla ide o obsluhu udalostí alebo iné metódy, ktoré fungujú na princípe oheň a zabudni. Ak potrebujete nielen dať príležitosť počkať do konca vykonávania, ale aj vrátiť výsledok, musíte použiť Task.

Na úlohe, ktorú vrátila metóda StartNew, ako aj na ktorejkoľvek inej, môžete zavolať metódu ConfigureAwait s parametrom false, potom vykonávanie po čakaní nebude pokračovať v zachytenom kontexte, ale v ľubovoľnom kontexte. Toto by sa malo robiť vždy, keď kontext vykonávania nie je dôležitý pre kód po čakaní. Toto je tiež odporúčanie od MS pri písaní kódu, ktorý bude dodaný zabalený v knižnici.

Zastavme sa trochu viac pri tom, ako môžete čakať na dokončenie úlohy. Nižšie je uvedený príklad kódu s komentármi o tom, kedy sa očakávanie vykoná podmienečne dobre a kedy sa vykoná podmienečne zle.

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 prvom príklade čakáme na dokončenie úlohy bez zablokovania volajúceho vlákna, k spracovaniu výsledku sa vrátime, až keď tam už bude, dovtedy je volajúce vlákno ponechané na svoje vlastné zariadenia.

V druhej možnosti blokujeme volajúce vlákno, kým sa nevypočíta výsledok metódy. To je zlé nielen preto, že sme vlákno, taký cenný zdroj programu, obsadili jednoduchou nečinnosťou, ale aj preto, že ak kód metódy, ktorú voláme, čaká a kontext synchronizácie vyžaduje návrat do volajúceho vlákna po čakať, potom sa dostaneme do mŕtveho bodu : Volajúce vlákno čaká na výpočet výsledku asynchrónnej metódy, asynchrónna metóda sa márne pokúša pokračovať vo svojom vykonávaní vo volajúcom vlákne.

Ďalšou nevýhodou tohto prístupu je komplikované spracovanie chýb. Faktom je, že chyby v asynchrónnom kóde pri použití async/await sa dajú veľmi ľahko zvládnuť – správajú sa rovnako, ako keby bol kód synchrónny. Zatiaľ čo ak na Úlohu aplikujeme synchrónny exorcizmus čakania, pôvodná výnimka sa zmení na AggregateException, t.j. Aby ste zvládli výnimku, budete musieť preskúmať typ InnerException a napísať if reťazec sami do jedného catch bloku alebo použiť catch pri konštrukcii namiesto reťazca catch blokov, ktorý je známejší vo svete C#.

Tretí a posledný príklad je tiež označený ako zlý z rovnakého dôvodu a obsahuje všetky rovnaké problémy.

Metódy WhenAny a WhenAll sú mimoriadne pohodlné pri čakaní na skupinu úloh; zabalia skupinu úloh do jednej, ktorá sa spustí buď pri prvom spustení úlohy zo skupiny, alebo keď všetky z nich dokončia svoju realizáciu.

Zastavenie vlákien

Z rôznych dôvodov môže byť potrebné zastaviť tok po jeho spustení. Existuje niekoľko spôsobov, ako to urobiť. Trieda Thread má dve vhodne pomenované metódy: prerušiť и prerušenie. Prvý z nich sa veľmi neodporúča používať, pretože po jej zavolaní v ľubovoľnom náhodnom momente, počas spracovania akejkoľvek inštrukcie, bude vyvolaná výnimka ThreadAbortedException. Neočakávate, že takáto výnimka bude vyvolaná pri inkrementácii akejkoľvek celočíselnej premennej, však? A pri použití tejto metódy je to veľmi reálna situácia. Ak potrebujete zabrániť CLR vo vygenerovaní takejto výnimky v určitej časti kódu, môžete ju zabaliť do volaní Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Akýkoľvek kód napísaný vo finálnom bloku je zabalený do takýchto volaní. Z tohto dôvodu v hĺbke kódu rámca môžete nájsť bloky s prázdnym pokusom, ale nakoniec nie prázdnym. Microsoft odrádza od tejto metódy natoľko, že ju nezahrnul do jadra .net.

Metóda prerušenia funguje predvídateľnejšie. Môže prerušiť vlákno s výnimkou ThreadInterruptedException iba v tých chvíľach, keď je vlákno v čakacom stave. Do tohto stavu sa dostane počas visenia pri čakaní na WaitHandle, uzamknutie alebo po zavolaní Thread.Sleep.

Obe možnosti opísané vyššie sú zlé kvôli ich nepredvídateľnosti. Riešením je použitie štruktúry CancellationToken a trieda CancellationTokenSource. Ide o toto: vytvorí sa inštancia triedy CancellationTokenSource a iba ten, kto ju vlastní, môže zastaviť operáciu volaním metódy Zrušiť. Len CancellationToken sa odovzdá samotnej operácii. Vlastníci tokenu CancellationToken nemôžu operáciu zrušiť sami, ale môžu iba skontrolovať, či bola operácia zrušená. Na to existuje boolovská vlastnosť IsCancellationRequested a metóda ThrowIfCancelRequested. Ten druhý vyvolá výnimku TaskCancelledException ak bola pri papagájovanej inštancii CancellationToken zavolaná metóda Cancel. A toto je metóda, ktorú odporúčam použiť. Ide o zlepšenie oproti predchádzajúcim možnostiam získaním plnej kontroly nad tým, v ktorom bode možno operáciu výnimky prerušiť.

Najbrutálnejšou možnosťou na zastavenie vlákna je volanie funkcie Win32 API TerminateThread. Správanie CLR po volaní tejto funkcie môže byť nepredvídateľné. Na MSDN je o tejto funkcii napísané nasledovné: „TerminateThread je nebezpečná funkcia, ktorá by sa mala používať iba v najextrémnejších prípadoch. “

Konverzia starého API na Task Based pomocou metódy FromAsync

Ak máte to šťastie, že pracujete na projekte, ktorý bol spustený po predstavení Tasks a prestal spôsobovať pokojnú hrôzu pre väčšinu vývojárov, potom sa nebudete musieť zaoberať množstvom starých API, či už tretích strán alebo tých, ktoré váš tím. v minulosti mučil. Našťastie sa o nás postaral tím .NET Framework, aj keď možno cieľom bolo postarať sa o seba. Nech je to akokoľvek, .NET má množstvo nástrojov na bezbolestnú konverziu kódu napísaného starými asynchrónnymi programovacími prístupmi na nový. Jednou z nich je metóda FromAsync z TaskFactory. V nižšie uvedenom príklade kódu zabalím staré asynchrónne metódy triedy WebRequest do úlohy pomocou tejto metódy.

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

Toto je len príklad a je nepravdepodobné, že by ste to museli robiť so vstavanými typmi, ale každý starý projekt sa jednoducho hemží metódami BeginDoSomething, ktoré vracajú metódy IAsyncResult a EndDoSomething, ktoré ho prijímajú.

Preveďte staršie API na Task Based pomocou triedy TaskCompletionSource

Ďalším dôležitým nástrojom, ktorý treba zvážiť, je trieda TaskCompletionSource. Funkciami, účelom a princípom fungovania môže trochu pripomínať metódu RegisterWaitForSingleObject triedy ThreadPool, o ktorej som písal vyššie. Pomocou tejto triedy môžete jednoducho a pohodlne zabaliť staré asynchrónne API do úloh.

Poviete si, že som už hovoril o metóde FromAsync triedy TaskFactory určenej na tieto účely. Tu si budeme musieť pripomenúť celú históriu vývoja asynchrónnych modelov v .net, ktoré Microsoft ponúkal za posledných 15 rokov: pred Task-Based Asynchronous Pattern (TAP) existoval vzor asynchrónneho programovania (APP), ktorý bol o metódach ZačaťUrobte niečo, čo sa vracia IAsyncResult a metódy KoniecDoSomething to akceptuje a pre dedičstvo týchto rokov je metóda FromAsync jednoducho dokonalá, ale postupom času bola nahradená asynchrónnym vzorom založeným na udalostiach (EAP), ktorý predpokladal, že po dokončení asynchrónnej operácie bude vyvolaná udalosť.

TaskCompletionSource je ideálny na zabalenie úloh a starších rozhraní API vytvorených okolo modelu udalostí. Podstata jej práce je nasledovná: objekt tejto triedy má verejnú vlastnosť typu Task, ktorej stav je možné ovládať pomocou metód SetResult, SetException atď. triedy TaskCompletionSource. Na miestach, kde bol na túto úlohu aplikovaný operátor čakania, bude táto úloha vykonaná alebo zlyhá s výnimkou v závislosti od metódy použitej na zdroj TaskCompletionSource. Ak to stále nie je jasné, pozrime sa na tento príklad kódu, kde je niektoré staré EAP API zabalené do úlohy pomocou TaskCompletionSource: keď sa udalosť spustí, úloha sa prenesie do stavu Dokončené a metóda, ktorá použila operátora wait k tejto úlohe sa obnoví jej vykonávanie po prijatí objektu následok.

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

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

    result completionSource.Task;
}

Tipy a triky TaskCompletionSource

Zabalenie starých rozhraní API nie je všetko, čo sa dá urobiť pomocou TaskCompletionSource. Použitie tejto triedy otvára zaujímavú možnosť navrhovania rôznych API na úlohách, ktoré nie sú obsadené vláknami. A stream, ako si pamätáme, je drahý zdroj a ich počet je obmedzený (hlavne množstvom pamäte RAM). Toto obmedzenie možno ľahko dosiahnuť vývojom napríklad načítanej webovej aplikácie s komplexnou obchodnou logikou. Uvažujme o možnostiach, o ktorých hovorím pri implementácii takého triku, akým je Long-Polling.

Stručne povedané, podstatou triku je toto: musíte z API dostávať informácie o niektorých udalostiach, ktoré sa vyskytujú na jeho strane, zatiaľ čo API z nejakého dôvodu nemôže udalosť nahlásiť, ale môže iba vrátiť stav. Príkladom toho sú všetky API postavené na HTTP pred časom WebSocket alebo keď z nejakého dôvodu nebolo možné použiť túto technológiu. Klient môže požiadať HTTP server. HTTP server nemôže sám spustiť komunikáciu s klientom. Jednoduchým riešením je dotazovanie servera pomocou časovača, čo však vytvára dodatočné zaťaženie servera a ďalšie oneskorenie v priemere TimerInterval / 2. Aby sa to obišlo, bol vynájdený trik nazývaný Long Polling, ktorý zahŕňa oneskorenie odpovede od server, kým nevyprší časový limit alebo kým nenastane udalosť. Ak udalosť nastala, potom sa spracuje, ak nie, žiadosť sa odošle znova.

while(!eventOccures && !timeoutExceeded)  {

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

Takéto riešenie sa ale ukáže ako hrozné, akonáhle sa zvýši počet klientov čakajúcich na akciu, pretože... Každý takýto klient zaberá celé vlákno čakajúce na udalosť. Áno, a pri spustení udalosti dostaneme ďalšie oneskorenie 1 ms, väčšinou to nie je významné, ale prečo robiť softvér horším, než môže byť? Ak odstránime Thread.Sleep(1), tak márne zaťažíme jedno jadro procesora 100% nečinné, rotujúce v zbytočnom cykle. Pomocou TaskCompletionSource môžete tento kód jednoducho prerobiť a vyriešiť všetky vyššie uvedené problémy:

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

Tento kód nie je pripravený na výrobu, ale je to len ukážka. Ak ju chcete použiť v reálnych prípadoch, musíte tiež minimálne zvládnuť situáciu, keď správa príde v čase, keď ju nikto nečaká: v tomto prípade by metóda AsseptMessageAsync mala vrátiť už dokončenú úlohu. Ak je to najbežnejší prípad, môžete premýšľať o použití ValueTask.

Keď dostaneme požiadavku na správu, vytvoríme a umiestnime TaskCompletionSource do slovníka a potom počkáme, čo sa stane ako prvé: uplynie určený časový interval alebo bude prijatá správa.

ValueTask: prečo a ako

Operátory async/wait, podobne ako operátor výnosu, vygenerujú z metódy stavový automat a tým je vytvorenie nového objektu, čo nie je takmer vždy dôležité, no v ojedinelých prípadoch môže spôsobiť problém. V tomto prípade môže ísť o metódu, ktorá sa volá naozaj často, hovoríme o desiatkach a stovkách tisíc hovorov za sekundu. Ak je takáto metóda napísaná tak, že vo väčšine prípadov vráti výsledok, ktorý obíde všetky čakajúce metódy, potom .NET poskytuje nástroj na optimalizáciu – štruktúru ValueTask. Aby bolo jasné, pozrime sa na príklad jeho použitia: existuje vyrovnávacia pamäť, do ktorej chodíme veľmi často. Sú v ňom nejaké hodnoty a potom ich jednoducho vrátime; ak nie, ideme na nejaké pomalé IO, aby sme ich získali. Chcem to urobiť asynchrónne, čo znamená, že celá metóda sa ukáže ako asynchrónna. Zrejmý spôsob napísania metódy je teda nasledujúci:

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

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

Kvôli túžbe trochu optimalizovať a miernemu strachu z toho, čo Roslyn vygeneruje pri kompilácii tohto kódu, môžete tento príklad prepísať takto:

public Task<string> GetById(int id) {

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

Optimálnym riešením by v tomto prípade bola optimalizácia horúcej cesty, konkrétne získanie hodnoty zo slovníka bez akýchkoľvek zbytočných alokácií a zaťaženia GC, zatiaľ čo v tých zriedkavých prípadoch, keď stále potrebujeme ísť pre dáta na IO , všetko zostane plus / mínus starým spôsobom:

public ValueTask<string> GetById(int id) {

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

Pozrime sa bližšie na tento kúsok kódu: ak je vo vyrovnávacej pamäti hodnota, vytvoríme štruktúru, inak sa skutočná úloha zabalí do zmysluplnej. Volajúcemu kódu je jedno, na ktorej ceste bol tento kód vykonaný: ValueTask sa z hľadiska syntaxe C# bude v tomto prípade správať rovnako ako bežná úloha.

TaskSchedulers: správa stratégií spúšťania úloh

Ďalším API, ktoré by som chcel zvážiť, je trieda Plánovač úloh a jeho deriváty. Už som spomenul vyššie, že TPL má schopnosť spravovať stratégie distribúcie úloh cez vlákna. Takéto stratégie sú definované v potomkoch triedy TaskScheduler. V knižnici možno nájsť takmer akúkoľvek stratégiu, ktorú by ste mohli potrebovať. ParallelExtensionsExtras, vyvinutý spoločnosťou Microsoft, ale nie je súčasťou .NET, ale dodáva sa ako balík Nuget. Pozrime sa v krátkosti na niektoré z nich:

  • CurrentThreadTaskScheduler — vykoná úlohy v aktuálnom vlákne
  • LimitedConcurrencyLevelTaskScheduler — obmedzuje počet úloh vykonávaných súčasne parametrom N, ktorý je akceptovaný v konštruktore
  • OrderedTaskScheduler — je definovaný ako LimitedConcurrencyLevelTaskScheduler(1), takže úlohy sa budú vykonávať postupne.
  • WorkStealingTaskScheduler - náradie práca-kradnutie prístup k rozdeleniu úloh. V podstate ide o samostatný ThreadPool. Rieši problém, že v .NET ThreadPool je statická trieda, jedna pre všetky aplikácie, čo znamená, že jej preťaženie alebo nesprávne použitie v jednej časti programu môže viesť k vedľajším účinkom v inej. Okrem toho je mimoriadne ťažké pochopiť príčinu takýchto defektov. To. Môže byť potrebné použiť samostatné WorkStealingTaskSchedulers v častiach programu, kde môže byť použitie ThreadPool agresívne a nepredvídateľné.
  • QueuedTaskScheduler — umožňuje vykonávať úlohy podľa pravidiel prioritného frontu
  • ThreadPerTaskScheduler — vytvorí samostatné vlákno pre každú úlohu, ktorá je na ňom vykonaná. Môže byť užitočný pri úlohách, ktorých dokončenie trvá nepredvídateľne dlho.

Je tam dobrý detail článok o TaskSchedulers na blogu spoločnosti Microsoft.

Na pohodlné ladenie všetkého, čo súvisí s Úlohami, má Visual Studio okno Úlohy. V tomto okne môžete vidieť aktuálny stav úlohy a prejsť na aktuálne vykonávaný riadok kódu.

.NET: Nástroje pre prácu s multithreadingom a asynchróniou. Časť 1

PLinq a trieda Parallel

Okrem Tasks a všetkého, čo sa o nich hovorí, sú v .NET ešte dva zaujímavé nástroje: PLinq (Linq2Parallel) a trieda Parallel. Prvý sľubuje paralelné vykonávanie všetkých operácií Linq na viacerých vláknach. Počet vlákien je možné nakonfigurovať pomocou metódy rozšírenia WithDegreeOfParallelism. Bohužiaľ, najčastejšie PLinq vo svojom predvolenom režime nemá dostatok informácií o vnútornostiach vášho zdroja údajov na to, aby poskytoval výrazné zvýšenie rýchlosti, na druhej strane, náklady na pokus sú veľmi nízke: stačí zavolať metódu AsParallel pred reťazec metód Linq a spustiť výkonnostné testy. Okrem toho je možné poskytnúť PLinq dodatočné informácie o povahe vášho zdroja údajov pomocou mechanizmu Partitions. Môžete si prečítať viac tu и tu.

Statická trieda Parallel poskytuje metódy na paralelné iterovanie cez kolekciu Foreach, vykonávanie cyklu For a vykonávanie viacerých delegátov paralelne Invoke. Vykonávanie aktuálneho vlákna sa zastaví, kým sa nedokončia výpočty. Počet vlákien je možné nakonfigurovať zadaním ParallelOptions ako posledného argumentu. Môžete tiež zadať Plánovač úloh a CancellationToken pomocou možností.

Závery

Keď som začal písať tento článok na základe materiálov mojej správy a informácií, ktoré som počas svojej práce po nej nazbieral, nečakal som, že toho bude až tak veľa. Teraz, keď mi textový editor, v ktorom píšem tento článok, vyčítavo oznámi, že strana 15 je preč, zhrniem priebežné výsledky. Ďalším trikom, API, vizuálnym nástrojom a nástrahám sa budeme venovať v nasledujúcom článku.

Závery:

  • Aby ste mohli využívať prostriedky moderných PC, potrebujete poznať nástroje na prácu s vláknami, asynchrónnosťou a paralelizmom.
  • .NET má na tieto účely mnoho rôznych nástrojov
  • Nie všetky sa objavili naraz, takže často nájdete staršie, existujú však spôsoby, ako previesť staré API bez veľkého úsilia.
  • Práca s vláknami v .NET je reprezentovaná triedami Thread a ThreadPool
  • Metódy Thread.Abort, Thread.Interrupt a Win32 API TerminateThread sú nebezpečné a neodporúčajú sa používať. Namiesto toho je lepšie použiť mechanizmus CancellationToken
  • Tok je cenný zdroj a jeho zásoba je obmedzená. Mali by ste sa vyhnúť situáciám, keď sú vlákna zaneprázdnené čakaním na udalosti. Na tento účel je vhodné použiť triedu TaskCompletionSource
  • Najvýkonnejšie a najpokročilejšie .NET nástroje na prácu s paralelizmom a asynchróniou sú Tasks.
  • Operátori c# async/await implementujú koncept neblokovaného čakania
  • Distribúciu úloh naprieč vláknami môžete ovládať pomocou tried odvodených z TaskScheduler
  • Štruktúra ValueTask môže byť užitočná pri optimalizácii hot-path a pamäťovej prevádzky
  • Okná Tasks and Threads vo Visual Studiu poskytujú množstvo informácií užitočných na ladenie viacvláknového alebo asynchrónneho kódu
  • PLinq je skvelý nástroj, ale nemusí mať dostatok informácií o vašom zdroji údajov, ale to sa dá opraviť pomocou mechanizmu rozdelenia
  • Ak sa chcete pokračovať ...

Zdroj: hab.com

Pridať komentár