Publikujem na Habr originál článku, preklad ktorého je umiestnený v korporátnom .
Potřeba vykonávat něco asynchronně, nečekajíc na výsledek zde a nyní, či rozdělovat velkou práci mezi několik vykonávajících jednotek existovala už před vznikem počítačů. S jejich příchodem se tato potřeba stala velmi výraznou. Nyní, v roce 2019, když píšu tento článek na notebooku s 8 jádrovým procesorem Intel Core, na kterém současně běží stovky procesů, a vláken ještě více. Vedle leží trochu opotřebovaný telefon, zakoupený před pár lety, který má 8 jádrový procesor. Na tematických stránkách je spousta článků a videí, kde jejich autoři obdivují vlajkové smartphony tohoto roku, které mají 16ti-jádrové procesory. MS Azure poskytuje virtuální stroj s 128 jádrovým procesorem a 2 TB RAM za méně než 20 $/hod. Bohužel není možné vytěžit maximum a zkrotit tuto sílu, když neovládáte interakci mezi vlákny.
Terminológia
Proces (Process) — objekt OS, izolovaný adresný priestor, obsahuje vlákna.
Vlákno (Thread) — objekt OS, najmenšia jednotka vykonávania, časť procesu, vlákna zdieľajú pamäť a iné zdroje medzi sebou v rámci procesu.
Multitasking — vlastnosť OS, možnosť vykonávať viac procesov súčasne
Viacjadrovosť — vlastnosť procesora, možnosť používať viac jadier na spracovanie dát
Viacprocesorovosť — vlastnosť počítača, možnosť pracovať súčasne s viacerými procesormi fyzicky
Viacvláknovosť — vlastnosť procesu, možnosť rozdeliť spracovanie dát medzi viaceré vlákna.
Paralelnosť — vykonávanie viacerých činností fyzicky súčasne v jednotke času
Asynchrónnosť — vykonávanie operácie bez čakania na ukončenie spracovania, výsledok však môže byť spracovaný neskôr.
Metafora
Nie všetky definície sú dobré a niektoré potrebujú ďalšie vysvetlenie, preto k formálne zavedeným terminológiám pridám metaforu o príprave raňajok. Príprava raňajok v tejto metafore — process.
Keď pripravujem raňajky ráno ja (CPU) prichádzam do kuchyne (Počítač). Mám 2 ruky (Jadrá). V kuchyni je rad zariadení (H, High): pec, kanvica, toaster, chladnička. Zapínam plyn, dávam naň panvicu a nalievam do nej olej, bez čakania, kým sa zahreje (asynchrónne, Non-Blocking-IO-Wait), vytiahnem z chladničky vajcia a rozbijem ich do misy, potom ich šľahám jednou rukou (Vlákno#1), a druhou (Vlákno#2) držím misku (Zdroj s zdieľaním). Teraz by som ešte mal zapnúť kanvicu, ale rúk nie je dosť (Thread Starvation) Počas tejto doby sa panvica zahreje (Spracovanie výsledku), do ktorej nalievam to, čo som vyšľahal. Natiahujem sa k kanvici a zapínam ju a len sledujem, ako sa voda v nej varí (Blocking-IO-Wait), aj keď by som mohol za ten čas umyť misku, kde som šľahal omeletu.
Pripravoval som omeletu s použitím iba 2 rúk, viac ich ani nemám, ale počas šľahania omelety prebiehali súčasne 3 operácie: šľahanie omelety, držanie misky, zahřívanie panvice. CPU je najrýchlejšou časťou počítača, IO to, čo najčastejšie brzdí, takže často efektívnym riešením je zamestnať CPU, zatiaľ čo sa získavajú dáta z IO.
Pokračujúc v metafore:
- Keby som sa počas prípravy omelety ešte pokúšal premeniť, bol by to príklad multitaskingu. Dôležitý detail: počítače sú na tom s tým oveľa lepšie ako ľudia.
- Kuchyňa s viacerými kuchármi, napríklad v reštaurácii — viacjadrový počítač.
- Množstvo reštaurácií na food corte v nákupnom centre — dátové centrum
Nástroje .NET
V práci s vláknami, ako aj v mnohých iných veciach, je .NET dobrý. S každou novou verziou predstavuje stále viac nových nástrojov na prácu s nimi, nové vrstvy abstrakcie nad vláknami OS. Pri budovaní abstrakcií vývojári frameworku používajú prístup, ktorý ponecháva možnosť klesnúť na jeden alebo viac úrovní nižšie pri použití vysokoúrovňovej abstrakcie. Často to nie je potrebné, ba dokonca to otvára možnosť zasiahnuť si do nohy brokovnicou, ale niekedy, v zriedkavých prípadoch, to môže byť jediný spôsob, ako vyriešiť problém, ktorý sa nedá vyriešiť na aktuálnej úrovni abstrakcie.
Pod nástrojmi mám na mysli ako programové rozhrania (API) poskytované frameworkom a externými balíkmi, tak aj celé softvérové riešenia uľahčujúce hľadanie akýchkoľvek problémov spojených s viacvláknovým kódom.
Spustenie vlákna
Trieda Thread je najzákladnejšia v .NET na prácu so vláknami. Do konštruktora prijíma jeden z dvoch delegátov:
- ThreadStart — Bez parametrov
- ParametrizedThreadStart — s jedným parametrom typu object.
Delegát bude vykonaný v novo vytvorenom vlákne po vyvolaní metódy Start. Ak bol do konštruktora zadaný delegát typu ParametrizedThreadStart, je potrebné do metódy Start poslať objekt. Tento mechanizmus slúži na prenos akejkoľvek lokálnej informácie do vlákna. Je potrebné poznamenať, že vytvorenie vlákna je nákladná operácia a samotné vlákno je ťažký objekt, aspoň preto, že sa vyžaduje pridelovanie 1MB pamäte na zásobník a vyžaduje interakciu s API OS.
new Thread(...).Start(...);
Trieda ThreadPool predstavuje koncept poolu. V .NET je pool vlákien majstrovským dielom inžinierstva a vývojári z Microsoftu vynaložili množstvo úsilia, aby fungoval optimálne v rôznych scenároch.
Hlavná myšlienka:
Od momentu spustenia aplikácie na pozadí vytvára niekoľko rezervných vlákien a poskytuje možnosť používať ich. Ak sa vlákna používajú často a vo veľkom množstve, pool sa rozšíri, aby uspokojil potreby volajúceho kódu. Keď nie sú v pooli voľné vlákna, buď čaká na návrat jedného z vláken, alebo vytvorí nové. Z toho vyplýva, že pool vlákien je skvelý pre krátke akcie, ale nie je vhodný pre operácie bežiace ako služby počas celej doby behu aplikácií.
Na použitie vlákna z poolu je metóda QueueUserWorkItem, ktorá prijíma delegát typu WaitCallback, čo podľa podpisu zodpovedá ParametrizedThreadStart, a parameter, ktorý do nej posielame, vykonáva tú istú funkciu.
ThreadPool.QueueUserWorkItem(...);
Menej známa metóda poolu vlákien RegisterWaitForSingleObject slúži na organizovanie neblokujúcich IO operácií. Delegát zaslaný do tejto metódy bude vyvolaný, keď bude WaitHandle zaslaný do metódy „uvoľnený“ (Released).
ThreadPool.RegisterWaitForSingleObject(...)
V .NET existuje vlákno-timer, ktorý sa líši od timerov WinForms/WPF tým, že jeho obsluhovač bude vyvolaný vo vlákne zo zásobníka.
System.Threading.Timer
Existuje aj pomerne exotický spôsob zaslania delegátu na vykonanie vo vlákne z poolu — metóda BeginInvoke.
DelegateInstance.BeginInvoke
Chcem sa ešte krátko zastaviť pri funkcii, na ktorú sa väčšina z vyššie uvedených metód spolieha — CreateThread z Kernel32.dll Win32 API. Existuje spôsob, ako touto funkciou zavolať túto metódu pomocou mechanizmu externých metód. Viděl som takéto volanie len raz v hroznom príklade legacy kódu a motivácia autora, ktorý to takto urobil, mi stále zostáva záhadou.
Kernel32.dll CreateThread
Prezeranie a ladenie vlákien
Vytvorené vami, všetkými tretími stranami a .NET vláknami môžete zobraziť v okne Threads vo Visual Studio. Toto okno zobrazí informácie o vláknach len v prípade, že aplikácia je pod ladením a v režime zastavenia (Break mode). Tu môžete pohodlne prezerať zásobník, mená a priority každého vlákna, prepínať ladenie na konkrétne vlákno. Pomocou vlastnosti Priority triedy Thread môžete nastaviť prioritu vlákna, ktorú OC a CLR budú brať ako odporúčanie pri rozdeľovaní procesorového času medzi vláknami.

Knižnica paralelných úloh
Knižnica paralelných úloh (TPL) sa objavila vo .NET 4.0. Teraz je to štandard a hlavný nástroj na prácu s asynchrónnosťou. Akýkoľvek kód, ktorý používa staršie prístupy, sa považuje za legacy. Hlavnou jednotkou TPL je trieda Task zo menného priestoru System.Threading.Tasks. Task predstavuje abstrakciu nad vláknom. S novou verziou jazyka C# sme získali elegantný spôsob práce s úlohami — operátory async/await. Tieto koncepty umožnili písať asynchrónny kód tak, ako keby bol jednoduchý a synchronizovaný, čo umožnilo aj ľuďom, ktorí málo rozumejú vnútornej prevádzke vlákien, písať aplikácie, ktoré ich používajú a nevisia pri vykonávaní dlhých operácií. Použitie async/await je témou na jeden alebo dokonca niekoľko článkov, ale pokúsim sa v niekoľkých vetách zhrnúť podstatu:
- async je modifikátor metódy, ktorá vracia Task alebo void
- a await je operátor neblokujúceho čakania na Task.
Ešte raz: operátor await v zásade (existujú výnimky) uvoľní aktuálny tok vykonávania ďalej, a keď sa úloha dokončí, a tok (vlastne by bolo presnejšie povedať kontext, ale o tom neskôr) bude voľný, pokračuje vo vykonávaní metódy ďalej. V rámci .NET je tento mechanizmus implementovaný rovnako ako yield return, keď napísaná metóda sa mení na celý triedu, ktorá je strojom stavov a môže byť vykonávaná oddelenými kusmi v závislosti od týchto stavov. Kto má záujem, môže napísať akýkoľvek jednoduchý kód s použitím async/await, skompilovať a prezrieť zostavu pomocou JetBrains dotPeek so zapnutým Kódom generovaným kompilátorom.
Pozrime sa na možnosti spustenia a použitia úlohy. Na príklade kódu nižšie vytvárame novú úlohu, ktorá nerobí nič užitočné (Thread.Sleep(10000)), ale v reálnom živote by to mala byť nejaká zložitá práca využívajúca CPU.
using TCO = System.Threading.Tasks.TaskCreationOptions;
public static async void VoidAsyncMethod() {
var cancellationSource = new CancellationTokenSource();
await Task.Factory.StartNew(
// Kód akcie bude vykonaný v inom kontexte
() => Thread.Sleep(10000),
cancellationSource.Token,
TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
scheduler
);
// Kód po await bude vykonaný v zachytenom kontexte
}
Úloha sa vytvára s radom možností:
- LongRunning — náznak, že úloha nebude vykonaná rýchlo, a teda možná, je dobré zvážiť, aby sme nezobrali tok z poolu, ale vytvorili samostatný pre túto úlohu, aby sme neublížili ostatným.
- AttachedToParent — Úlohy sa môžu usporiadať do hierarchií. Ak bola použitá táto možnosť, úloha môže byť v stave, keď sa sama vykonala a čaká na dokončenie podriadených.
- PreferFairness — znamená, že by bolo dobré vykonávať úlohy, ktoré boli zaslané na vykonanie skôr, pred tými, ktoré boli zaslané neskôr. Ale to je len odporúčanie a výsledok nie je zaručený.
Druhým parametrom v metóde je odovzdaný CancellationToken. Pre správne spracovanie zrušenia operácie po jej spustení musí byť vykonaný kód naplnený kontrolami stavu CancellationToken. Ak nechýbajú kontroly, metóda Cancel vyvolaná na objekte CancellationTokenSource dokáže zastaviť vykonávanie úlohy len pred jej spustením.
Posledným parametrom sa odovzdáva objekt scheduler typu TaskScheduler. Tento trieda a jej dedičia sú určené na riadenie stratégií rozdelenia úloh medzi vláknami, predvolene bude úloha vykonaná na náhodnom vlákne z poolu.
Na vytvorenú úlohu bol aplikovaný operátor await, čo znamená, že kód napísaný po ňom, ak nejaký existuje, bude vykonaný v tom istom kontexte (často to znamená, že na tom istom vlákne), aký bol pred await.
Metóda je označená ako async void, čo znamená, že v nej je prípustné použitie operátora await, ale volajúci kód nebude schopný čakať na dokončenie. Ak je taká možnosť potrebná, musí metóda vracať Task. Metódy označené ako async void sa vyskytujú pomerne často: spravidla ide o obsluhy udalostí alebo iné metódy, ktoré fungujú na princípe vykonať a zabudnúť (fire and forget). Ak je potrebné nielen dať možnosť počkať na dokončenie, ale aj vrátiť výsledok, treba použiť Task.
Na Task, ktorý vrátil metóda StartNew, a na akomkoľvek inom, je možné zavolať metódu ConfigureAwait s parametrom false, potom sa vykonanie po await bude pokračovať nie na zachytenom kontexte, ale na ľubovolnom. Toto by sa malo robiť vždy, keď pre kód po await nie je kontext vykonania zásadný. Je to tiež odporúčanie od MS pri písaní kódu, ktorý bude dodávaný zabalený v knižnici.
Zastavme sa ešte na chvíľu pri tom, ako je možné počkať na dokončenie vykonania Task. Nižšie je príklad kódu s komentármi, kedy je čakanie urobené podmienene dobre a kedy podmienene zle.
public static async void AnotherMethod() {
int result = await AsyncMethod(); // dobré
result = AsyncMethod().Result; // zlé
AsyncMethod().Wait(); // zlé
IEnumerable tasks = new Task[] {
AsyncMethod(), OtherAsyncMethod()
};
await Task.WhenAll(tasks); // dobré
await Task.WhenAny(tasks); // dobré
Task.WaitAll(tasks.ToArray()); // zlé
}
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ď už bude, do tej doby je volajúce vlákno ponechané samo na seba.
V druhej variante blokujeme vyvolávajúci tok, kým nie je spočítaný výsledok metódy. To je zlé nielen preto, že sme obsadili tok, tak cenný zdroj programu, jednoduchým nič nerobením, ale aj preto, že ak je v kóde metódy, ktorú voláme, await a kontext synchronizácie predpokladá návrat do vyvolávajúceho toku po await, dostaneme deadlock: vyvolávajúci tok čaká, kým sa spočíta výsledok asynchrónnej metódy, zatiaľ čo asynchrónna metóda márne sa snaží pokračovať vo svojom vykonávaní vo vyvolávajúcom toku.
Ďalšou nevýhodou tohto prístupu je zložitá správa chýb. Skutočnosťou je, že chyby v asynchrónnom kóde pri použití async/await sa veľmi ľahko spracovávajú — správajú sa tak, akoby bol kód synchronný. Kým ak aplikujeme synchronné čakanie na Task, pôvodná výnimka sa obalí do AggregateException. Tým pádom na spracovanie výnimky bude potrebné skúmať typ InnerException a napísať reťazec if v rámci jedného bloku catch alebo použiť konštrukciu catch when, namiesto bežnej reťaze catch blokov v C# svete.
Tretí a posledný príklad je taktiež označený za zlý z rovnakého dôvodu a obsahuje všetky tie isté problémy.
Metódy WhenAny a WhenAll sú mimoriadne pohodlné na čakanie na skupinu Taskov, obalujú skupinu Taskov do jedného, ktorý sa spustí buď pri prvom spustení Tasku zo skupiny, alebo keď sa všetky dokončia.
Zastavenie tokov
Z rôznych dôvodov môže nastať potreba zastaviť tok po jeho spustení. Na tento účel existuje množstvo spôsobov. Trieda Thread má dve metódy s vhodnými názvami — sú to Zrušiť a Interrupt. Prvá je veľmi neodporúčaná na použitie, pretože po jej vyvolaní v akomkoľvek náhodnom momente, pri spracovaní akejkoľvek inštrukcie, sa vyhodí výnimka ThreadAbortedException. Veď nečakáte, že takáto výnimka sa objaví pri inkrementácií nejakej celočíselnej premennej, že? A pri použití tejto metódy je to celkom reálna situácia. V prípade potreby zakázať CLR generovať takúto výnimku v určitom úseku kódu, môžete ho obaliť do volaní Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Takými výzvami sa obklopuje akýkoľvek kód napísaný v bloku finally. Z tohto dôvodu je v hĺbkach kódu rámca možné nájsť bloky s prázdnym try, ale nie prázdnym finally. Microsoft je natoľko proti používaniu tejto metódy, že ju nezahrnuli do .net core.
Metóda Interrupt funguje predvídateľnejšie. Môže prerušiť vlákno výnimkou ThreadInterruptedException len vtedy, keď je vlákno v stave očakávania. Do takého stavu prechádza suspendovaním v očakávaní WaitHandle, lock alebo po volaní Thread.Sleep.
Oba opísané varianty sú zlé svojou nepravidelnosťou. Riešením je použitie štruktúry CancellationToken a triedy CancellationTokenSource. Podstata je nasledovná: vytvorí sa inštancia Triedy CancellationTokenSource a iba ten, kto ju vlastní, môže zastaviť operáciu vyvolaním metódy Cancel. Do samotnej operácie sa prenáša iba CancellationToken. Vlastníci CancellationToken nemôžu sami zrušiť operáciu, ale môžu iba skontrolovať, či nebola operácia zrušená. Na to je booleovská vlastnosť IsCancellationRequested a metóda ThrowIfCancelRequested. Posledná vyvolá výnimku TaskCancelledException ak bola na príslušnej inštancii CancellationTokenSource vyvolaná metóda Cancel. A práve túto metódu odporúčam použiť. Je to lepšie ako predchádzajúce varianty, keďže poskytuje úplnú kontrolu nad tým, kedy môže byť výnimka operácia prerušená.
Najdrastickejší variant zastavenia vlákna je volanie funkcie Win32 API TerminateThread. Správanie CLR po vyvolaní tejto funkcie môže byť nepravidelné. Na MSDN o tejto funkcii je napísané nasledovné: „TerminateThread je nebezpečná funkcia, ktorá by sa mala používať len v najextrémnejších prípadoch.“
Transformácia legacy-API na Task Based pomocou metódy FromAsync
Ak ste mali šťastie pracovať na projekte, ktorý bol spustený po tom, čo boli úlohy zavedené a prestali spôsobovať tichý horor väčšine vývojárov, nebudete musieť čeliť veľkému množstvu starých API, či už treťotriednych, alebo vytvorených vaším tímom v minulosti. Našťastie sa tím vývoja .NET Framework postaral o nás, aj keď možno cítili potrebu postarať sa najprv o seba. Ako tak či onak, v .NET sú k dispozícii nástroje na bezbolestnú transformáciu kódu napísaného v starých prístupoch asynchrónneho programovania na nový. Jedným z nich je metóda FromAsync od TaskFactory. V nasledujúcom príklade kódu obalujem 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,
wr.EndGetResponse
);
Toto je len príklad a ťažko by ste to robili so zabudovanými typmi, ale každý starý projekt je skazený metódami BeginDoSomething vracajúcimi IAsyncResult a metódami EndDoSomething, ktoré ich prijímajú.
Transformácia legacy-API na báze úloh pomocou triedy TaskCompletionSource
Ďalším dôležitým nástrojom na zváženie je trieda TaskCompletionSource. Vo funkciách, určení a princípe fungovania môže pripomínať metódu RegisterWaitForSingleObject triedy ThreadPool, o ktorej som písal vyššie. S pomocou tejto triedy je možné ľahko a pohodlne obaliť staré asynchrónne API do úloh.
Poviete si, že som už spomínal metódu FromAsync triedy TaskFactory, ktorá je určená na tieto účely. Tu si treba pripomenúť celú históriu vývoja asynchrónnych modelov vo .NET, ktoré Microsoft ponúkol za posledných 15 rokov: pred Task-Based Asynchronous Pattern (TAP) existoval Asynchronous Programming Pattern (APP), ktorý sa týkal metód BeginDoSomething vracajúcej IAsyncResult a metód EndDoSomething, ktoré ju prijímajú, a pre legacy tých rokov je metóda FromAsync presne to, čo potrebujete, ale časom ju nahradil Event Based Asynchronous Pattern (EAP), ktorý predpokladal, že po dokončení asynchrónnej operácie bude vyvolaná udalosť.
TaskCompletionSource je ideálny na obalenie úloh v legacy API postavených na event-driven modeli. Podstata jeho fungovania spočíva v tom, že objekt tohto typu má verejný property typu Task, ktorého stav môžeme spravovať prostredníctvom metód SetResult, SetException a podobne. V miestach, kde bol použitý operátor await na tento Task, sa vykoná alebo zruší s výnimkou v závislosti od metódy použitej na TaskCompletionSource. Ak to ešte nie je jasné, pozrime sa na tento príklad kódu, v ktorom sa staré API z čias EAP balí do úlohy pomocou TaskCompletionSource: pri aktivácii udalosti sa úloha presunie do stavu Completed a metóda, ktorá použila operátor await na tento Task, obnoví svoj chod získaním objektu. result.
public static Task DoAsync(this SomeApiInstance someApiObj) {
var completionSource = new TaskCompletionSource();
someApiObj.Done +=
result => completionSource.SetResult(result);
someApiObj.Do();
return completionSource.Task;
}
Tipy a triky pre TaskCompletionSource
Obalenie starých API nie je všetko, čo môžeme dosiahnuť pomocou TaskCompletionSource. Použitie tejto triedy otvára zaujímavú možnosť navrhovania rôznych API založených na úlohách, ktoré neblokujú vlákna. Ako vieme, vlákno je cenný zdroj a ich počet je obmedzený (najmä objemom RAM). Tohoto obmedzenia je ľahké dosiahnuť pri vývoji napríklad náročného webového aplikácie so zložitou biznis logikou. Pozrime sa na možnosti, o ktorých hovorím, pri implementácii takého triku ako je Long-Polling.
Stručne povedané, podstata triku spočíva v tom, že potrebujete získať od API informácie o niektorých udalostiach, ktoré sa na jeho strane dejú, pričom API z nejakého dôvodu nemôže oznámiť udalosť, ale môže iba vrátiť stav. Príkladom sú všetky API postavené na HTTP pred príchodom WebSocketu alebo pri nemožnosti z nejakého dôvodu použiť túto technológiu. Klient sa môže opýtať HTTP servera. HTTP server nemôže sám iniciovať komunikáciu s klientom. Jednoduchým riešením je periodické dotazovanie servera, ale to vytvára dodatočnú záťaž na server a dodatočné oneskorenie v priemere TimerInterval / 2. Na obídenie tohto bol vynájdený trik nazývaný Long Polling, ktorý predpokladá oneskorenie odpovede od servera, kým neuplynie Timeout alebo sa nestane udalosť. Ak sa udalosť stane, spracuje sa; ak nie, požiadavka sa znova posiela.
while(!eventOccures && !timeoutExceeded) {
CheckTimout();
CheckEvent();
Thread.Sleep(1);
}
Ale takéto riešenie sa ukáže ako hrozné, akonáhle sa počet klientov čakajúcich na udalosť zvýši, pretože každý takýto klient vo vyčkávaní na udalosť zaberá celý vlákno. A navyše dostávame dodatočné oneskorenie 1 ms pri spúšťaní udalosti, čo väčšinou nie je významné, ale prečo zhoršovať softvér, než ako to môže byť? Ak by sme odstránili Thread.Sleep(1), zbytočne zaťažíme jedno jadro procesora na 100 % v bezcennom cykle. S pomocou TaskCompletionSource môžeme jednoducho prepracovať tento kód a vyriešiť všetky vyššie uvedené problémy:
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);
}
}
Tento kód nie je pripravený na produkciu, ale iba demonštračný. Pre použitie v reálnych prípadoch je potrebné, ako minimálne, spracovať situáciu, keď správa prišla v momente, keď ju nikto nečakal: v takom prípade by metóda AcceptMessageAsync mala vrátiť už dokončený Task. Ak sa tento prípad a je najbežnejší, možno by stálo za to zvážiť aj použitie ValueTask.
Keď dostaneme požiadavku na správu, vytvoríme a uložíme do slovníka TaskCompletionSource, a potom čakáme na to, čo sa stane skôr: vyprší zadaný časový interval alebo dostaneme správu.
ValueTask: prečo a ako
Operátory async/await a operátor yield return generujú z metódy stroj stavov, čo znamená vytvorenie nového objektu, čo zvyčajne nie je dôležité, ale v niektorých prípadoch môže spôsobiť problém. Týmto prípadom môže byť metóda volaná skutočne často, prípadne desiatky a stovky tisíc volaní za sekundu. Ak je takáto metóda napísaná tak, že vo väčšine prípadov vracia výsledok obchádzajúc všetky await metódy, .NET poskytuje nástroj na jej optimalizáciu — štruktúru ValueTask. Aby to bolo jasné, pozrime sa na príklad jej použitia: máme cache, do ktorej často pristupujeme. Niektoré hodnoty tam sú, a tak ich jednoducho vraciame, ak nie, ideme po nich do nejakého pomalého IO. To posledné chceme robiť asynchrónne, a tak je celý metod asynchrónny. Takže zjavný spôsob, ako napísať metódu, je nasledujúci:
public async Task GetById(int id) {
if (cache.TryGetValue(id, out string val))
return val;
return await RequestById(id);
}
Z dôvodu trošku optimalizovať a zľahka sa obávať toho, čo vygeneruje Roslyn pri kompilácii tohto kódu, môže byť tento príklad prepísaný nasledujúcim spôsobom:
public Task GetById(int id) {
if (cache.TryGetValue(id, out string val))
return Task.FromResult(val);
return RequestById(id);
}
Naozaj optimálnym riešením v tomto prípade bude optimalizovať hot-path, a to získať hodnotu zo slovníka úplne bez zbytočných alokácií a záťaže na GC, zatiaľ čo v tých zriedkavých prípadoch, keď musíme ísť do IO po dáta, všetko ostane plus/mínus po starom:
public ValueTask GetById(int id) {
if (cache.TryGetValue(id, out string val))
return new ValueTask(val);
return new ValueTask(RequestById(id));
}
Poďme si podrobnejšie rozobrať tento fragment kódu: pri prítomnosti hodnoty v cache vytvárame štruktúru, v opačnom prípade bude reálny task zabalený do hodnoty. Volajúcemu kódu je jedno, akou cestou sa tento kód vykonával: ValueTask z hľadiska syntaxe C# bude správať rovnako ako bežný Task v tomto prípade.
TaskScheduler’y: riadenie stratégií spúšťania Task’ov
Ďalšie API, ktoré by sme chceli preskúmať, je trieda TaskScheduler a jeho deriváty. Už som spomínal vyššie, že v TPL existuje možnosť spravovať stratégie rozdelenia úloh medzi vláknami. Tieto stratégie sú definované v dedičoch triedy TaskScheduler. Prakticky akákoľvek stratégia, ktorá môže byť potrebná, sa nájde v knižnici ParallelExtensionsExtras, vyvinutej spoločnosťou Microsoft, ale nie je súčasťou .NET, dodáva sa vo forme balíka Nuget. Krátko si prejdeme niektoré z nich:
- CurrentThreadTaskScheduler — vykonáva úlohy na aktuálnom vlákne
- LimitedConcurrencyLevelTaskScheduler — obmedzuje počet súčasne vykonávaných úloh parametrom N, ktorý sa prijíma v konštruktore
- OrderedTaskScheduler — je definovaný ako LimitedConcurrencyLevelTaskScheduler(1), preto úlohy budú vykonávané postupne.
- WorkStealingTaskScheduler — implementuje prístup k rozdeleniu úloh. V podstate ide o samostatný ThreadPool. Rieši problém, že v .NET je ThreadPool statická trieda, jedna pre všetky aplikácie, a teda jeho preťaženie alebo nesprávne používanie v jednej časti programu môže viesť k vedľajším efektom v inej. Navyše je veľmi ťažké pochopiť príčinu takýchto defektov. Tým pádom môže existovať potreba používať samostatné WorkStealingTaskScheduler-y v tých častiach programu, kde používanie ThreadPool môže byť agresívne a nepredictabilné.
- QueuedTaskScheduler — umožňuje vykonávať úlohy podľa pravidiel fronty s prioritami
- ThreadPerTaskScheduler — vytvára samostatné vlákno pre každú úlohu, ktorá sa na ňom vykonáva. Môže byť užitočné pre úlohy, ktoré sú vykonávané predvídateľne dlhú dobu.
Existuje dobrý podrobný o TaskScheduler-och v blogu Microsoft.
Pre pohodlné ladenie všetkého, čo súvisí s úlohami vo Visual Studio, existuje okno Tasks. V tomto okne môžete vidieť aktuálny stav úlohy a prejsť k aktuálne vykonávanému riadku kódu.

PLinq a trieda Parallel
Okrem úloh a všetkého, čo s nimi súvisí v .NET, existujú aj dva zaujímavé nástroje, a to PLinq (Linq2Parallel) a trieda Parallel. Prvý sľubuje paralelné vykonávanie všetkých Linq operácií na viacerých vláknach. Počet vlákien je možné nakonfigurovať rozšírenou metódou WithDegreeOfParallelism. Žiaľ, častejšie PLinq v predvolenom režime nemá dostatok informácií o vnútornostiach vášho zdroja dát, aby zabezpečil výrazné zlepšenie rýchlosti, na druhej strane cena pokusu je veľmi nízka: stačí zavolať metódu AsParallel pred reťazcom Linq metód a vykonať testy výkonu. Navyše existuje možnosť poskytnúť PLinq dodatočné informácie o povahe vášho zdroja dát pomocou mechanizmu Partitions. Viac informácií nájdete a .
Statická trieda Parallel poskytuje metódy na paralelné prechádzanie kolekciou pomocou Foreach, vykonávanie cyklu For a spúšťanie niekoľkých delegátov paralelne pomocou 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ť odovzdaním ParallelOptions ako posledného argumentu. Pomocou možností je tiež možné určiť TaskScheduler a CancellationToken.
Zhrnutie
Keď som začal písať tento článok na základe materiálov svojho reportu a informácií, ktoré som zhromaždil počas môjho pôsobenia po ňom, neočakával som, že sa toho nazbiera toľko. Teraz, keď textový editor, v ktorom píšem tento článok, bezradne hovorí, že je na 15. strane, zhrniem medzičasom výsledky. Iné triky, API, vizuálne nástroje a nástrahy budú prebrané v nasledujúcom článku.
Vývody:
- Je potrebné poznať nástroje na prácu s vláknami, asynchrónnosťou a paralelizmom, aby ste mohli využiť zdroje moderných PC.
- V .NET je množstvo rôznych nástrojov na tieto účely.
- Nie všetky sa objavili naraz, preto často môžete naraziť na legacy, avšak existujú spôsoby, ako transformovať staré API bez zvláštnych úsilia.
- Práca s vláknami v .NET je reprezentovaná triedami Thread a ThreadPool.
- Metódy Thread.Abort, Thread.Interrupt a funkcia Win32 API TerminateThread sú nebezpečné a neodporúčajú sa používať. Namiesto nich je lepšie použiť mechanizmus CancellationTokenov.
- Toky sú cenný zdroj, ich množstvo je obmedzené. Je potrebné sa vyhnúť situáciám, keď sú toky zaneprázdnené čakaním na udalosti. Na tento účel je vhodné používať triedu TaskCompletionSource.
- Najvýkonnejším a najpokročilejším nástrojom .NET pre prácu s paralelizmom a asynchrónnosťou sú úlohy (Tasky).
- Operátory c# async/await realizujú koncept neblokujúceho čakania.
- Správa rozdelenia úloh (Taskov) po tokov je možná pomocou odvodenej triedy TaskScheduler.
- Štruktúra ValueTask môže byť užitočná pri optimalizácii hot-pathov a pamäťovej prevádzky.
- Okna úloh (Tasks) a vlákien (Threads) vo Visual Studiu poskytujú množstvo užitočných informácií na ladanie viacvláknového alebo asynchrónneho kódu.
- PLinq je skvelý nástroj, ale nemusí mať dostatok informácií o vašom zdroji dát; to sa dá však opraviť pomocou mechanizmu partitioning.
- Pokračování následuje…
Zdroj: habr.com
