.NET: Nástroje pro práci s multithreadingem a asynchronností. Část 1

Publikuji na Habr originál článku, jehož překlad je umístěn v korporátní blogu.

Potřeba něco dělat asynchronně, nečekat na výsledky zde a teď, nebo rozdělit velkou práci mezi několik vykonávajících se jednotek byla přítomna i 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 8jaderným procesorem Intel Core, na něm běží vedle toho již více než stovka procesů, a vláken je ještě více. Vedle mě leží již lehce opotřebovaný telefon, zakoupený před pár lety, má 8jaderný procesor. Na tematických zdrojích je plno článků a videí, kde jejich autoři obdivují vlajkové smartphony tohoto roku, které mají 16ti-jádrové procesory. MS Azure poskytuje za méně než 20$/hod. virtuální stroj se 128jaderným procesorem a 2 TB RAM. Bohužel není možné vytěžit maximum a zkrotit tuto sílu, aniž bychom uměli řídit interakci vláken.

Terminologie

Proces (Process) — objekt OS, izolovaný adresní prostor, obsahuje vlákna.
Vlákno (Thread) — objekt OS, nejmenší jednotka vykonávání, část procesu, vlákna sdílejí paměť a další zdroje mezi sebou v rámci procesu.
Multitasking — vlastnost OS, možnost vykonávat více procesů současně
Vícejádrovost — vlastnost procesoru, možnost používat více jader pro zpracování dat
Víceprocesorovost — vlastnost počítače, možnost současně pracovat s několika procesory fyzicky
Vícevláknovost — vlastnost procesu, možnost rozdělovat zpracování dat mezi více vlákny.
Paralelismus — vykonávání více činností fyzicky současně v jednom časovém okamžiku
Asynchronnost — vykonávání operace bez čekání na dokončení této zpracování, výsledek však může být zpracován později.

Metafora

Ne všechny definice jsou dobré a některé potřebují další vysvětlení, proto k formálně zavedené terminologii přidám metaforu o přípravě snídaně. Příprava snídaně v této metaforě — process.

Při přípravě snídaně ráno já (CPU) přicházím do kuchyně (Počítač). Mám 2 ruce (Jádra). V kuchyni je řada zařízení (IO): sporák, konvice, toaster, lednice. Zapínám plyn, dávám na něj pánev a nalévám do ní olej, aniž bych čekal, až se zahřeje (asynchronně, Non-Blocking-IO-Wait), vytahuji z lednice vejce a rozbíjím je do talíře, po čemž šlehám jednou rukou (Thread#1), zatímco druhou (Thread#2) držím talíř (Sdílený zdroj). Teď bych měl zapnout konvici, ale nemám dost rukou (Thread Starvation) Mezitím se zahřívá pánev (Zpracování výsledku), do které vylévám, co jsem ušlehal. Dosahuji k varné konvici a zapínám ji a tupě sleduji, jak se voda vaří (Blocking-IO-Wait), přitom bych mohl během této doby umýt talíř, ve kterém jsem šlehal omeletu.

Připravil jsem omeletu s použitím pouze dvou rukou, víc jich nemám, ale během šlehání omelety probíhaly současně tři operace: šlehání omelety, držení talíře, zahřívání pánve. CPU je nejrychlejší část počítače, IO je to, co často zpomaluje, takže účinným řešením je zaměstnat CPU, zatímco se čeká na data z IO.

Pokračující metafora:

  • Kdybych během přípravy omelety ještě zkoušel se převléknout, byl by to příklad multitasking. Důležitý detail: počítače mají v tomhle mnohem lepší výkon než lidé.
  • Kuchyně s několika kuchaři, například v restauraci - vícejádrový počítač.
  • Spousta restaurací na food courtu v obchodním centru - datové centrum

Nástroje .NET

V práci s vlákny, stejně jako v mnoha jiných věcech, je .NET dobrý. Každá nová verze přináší stále více nových nástrojů pro práci s nimi, nové vrstvy abstrakce nad vlákny operačního systému. Při vytváření abstrakcí vývojáři frameworku používají přístup, který umožňuje při použití vysoce úrovňové abstrakce sestoupit o jednu nebo více úrovní níže. Častěji to není nutné, navíc to otevírá možnost vystřelit si do vlastních nohou s brokovnicí, ale někdy, ve vzácných případech, může být jediným způsobem, jak vyřešit problém, který není vyřešen na aktuální úrovni abstrakce.

Pod nástroji myslím jak programové rozhraní (API) poskytované frameworkem a třetími balíčky, tak i celá programová řešení usnadňující hledání jakýchkoli problémů souvisejících s vícetrhním kódem.

Spuštění vlákna

Třída Thread, nejzákladnější v .NET pro práci s vlákny. V konstruktoru přijímá jeden ze dvou delegátů:

  • ThreadStart — bez parametrů
  • ParametrizedThreadStart — s jedním parametrem typu object.

Delegát bude vykonán v nově vytvořeném vlákně po volání metody Start. Pokud byl do konstruktoru předán delegát typu ParametrizedThreadStart, je třeba do metody Start předat objekt. Tento mechanismus slouží k předání jakýchkoli místních informací do vlákna. Je třeba poznamenat, že vytváření vlákna je nákladná operace a samotné vlákno je těžký objekt, minimálně proto, že dochází k přidělení 1 MB paměti na stack a vyžaduje interakci s API OS.

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

Třída ThreadPool představuje koncept poolu. V .NET je pool vláken mistrovským dílem inženýrství a vývojáři ze společnosti Microsoft investovali mnoho úsilí, aby fungoval optimálně v nejrůznějších scénářích.

Obecná koncepce:

Od okamžiku spuštění aplikace na pozadí vytváří několik náhradních vláken a poskytuje možnost je využívat. Pokud se vlákna používají často a ve velkém množství, pool se rozšiřuje, aby uspokojil potřebu volajícího kódu. Když není v poolu v daný okamžik volné vlákno, buď čeká na návrat jednoho z vláken, nebo vytvoří nové. Z toho vyplývá, že pool vláken je skvělý pro kratší akce a špatně vyhovuje operacím fungujícím jako služby po celou dobu běhu aplikací.

Pro využití vlákna z poolu je k dispozici metoda QueueUserWorkItem, která přijímá delegát typu WaitCallback, což podle signatury odpovídá ParametrizedThreadStart, a předávaný parametr vykonává tutéž funkci.

ThreadPool.QueueUserWorkItem(...);

Méně známá metoda poolu vláken RegisterWaitForSingleObject slouží k organizaci neblokujících IO operací. Delegát předaný do této metody bude vyvolán, když WaitHandle předaný do metody bude 'uvolněn' (Released).

ThreadPool.RegisterWaitForSingleObject(...)

V .NET existuje časovač vlákna a liší se od časovačů WinForms/WPF tím, že jeho zpracovatel bude vyvolán ve vlákně vzatém z poolu.

System.Threading.Timer

Existuje také poměrně exotický způsob, jak poslat delegát k vykonání do vlákna z poolu — metoda BeginInvoke.

DelegateInstance.BeginInvoke

Chci se ještě stručně dotknout funkce, k jejímuž volání se vztahuje mnoho výše uvedených metod — CreateThread z Kernel32.dll Win32 API. Existuje způsob, jak tuto funkci zavolat díky mechanismu externích metod. Takové volání jsem viděl jen jednou v hrůzném příkladu legacy kódu, a motivace autora, který tak učinil, mi stále zůstává záhadou.

Kernel32.dll CreateThread

Náhled a ladění vláken

Vláken, která jste vytvořili vy osobně, všech třetích stran a poolu .NET, si můžete prohlížet v okně Threads ve Visual Studiu. Toto okno zobrazí informace o vláknech pouze tehdy, když aplikace bude pod laděním a v režimu zastavení (Break mode). Zde můžete pohodlně prohlížet zásobník, jména a priority každého vlákna, přepnout ladění na konkrétní vlákno. Pomocí vlastnosti Priority třídy Thread můžete nastavit prioritu vlákna, kterou OC a CLR budou chápat jako doporučení při rozdělování procesorového času mezi vlákny.

.NET: Nástroje pro práci s multithreadingem a asynchronností. Část 1

Knihovna paralelních úloh

Knihovna paralelních úloh (TPL) se objevila v .NET 4.0. Dnes je to standard a hlavní nástroj pro práci s asynchronním programováním. Jakýkoli kód využívající starší přístupy se považuje za legacy. Hlavní jednotkou TPL je třída Task z prostoru názvů System.Threading.Tasks. Task představuje abstrakci nad vláknem. S novou verzí jazyka C# jsme získali elegantní způsob práce s Tasky — operátory async/await. Tyto koncepty umožnily psát asynchronní kód tak, jako by byl jednoduchý a synchronní, což umožnilo i lidem s malým porozuměním vnitřnímu fungování vláken psát aplikace, které je používají, aniž by se aplikace zasekávaly při provádění dlouhých operací. Použití async/await je téma pro jeden nebo dokonce několik článků, ale pokusím se v několika větách shrnout podstatu:

  • async je modifikátor metody, která vrací Task nebo void
  • a await je operátor neblokujícího čekání na Task.

Ještě jednou: operátor await, obecně (existují výjimky), pustí aktuální vlákno provádění dál, a když Task dokončí své vykonání, a vlákno (ve skutečnosti je přesnější říci kontext, ale o tom později) bude volné, pokračuje v provádění metody dál. Uvnitř .NET je tento mechanismus implementován tak, jak i yield return, když napsaná metoda se přetváří v celý třídu, která je strojem stavů a může být prováděna oddělenými kusy v závislosti na těchto stavech. Kdo má zájem, může napsat jakýkoliv jednoduchý kód s použitím async/await, zkompilovat ho a prohlédnout si sestavení pomocí JetBrains dotPeek s povoleným Compiler Generated Code.

Podívejme se na možnosti spuštění a používání Tasku. Na příkladu níže vytváříme nový task, který nedělá nic užitečného (Thread.Sleep(10000)), ale v reálném životě by to měla být nějaká složitá práce vyžadující CPU.

using TCO = System.Threading.Tasks.TaskCreationOptions;

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

    await Task.Factory.StartNew(
        // Kód akce bude prováděn v jiném kontextu
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    // Kód po await bude prováděn v zachyceném kontextu
}

Task se vytváří s řadou možností:

  • LongRunning — náznak, že úloha nebude splněna rychle, a tedy možná stojí za to zvážit, zda nevzít vlákno z bazénu, ale vytvořit samostatné pro tuto Tasku, aby nebyla poškozena ostatní.
  • AttachedToParent — Tasky mohou být uspořádány do hierarchií. Pokud byla použita tato možnost, může být Task ve stavu, kdy sám splnil a čeká na splnění podřízených.
  • PreferFairness — znamená, že by bylo dobré vykonávat Tasky, které byly odeslány k provedení dříve, před těmi, které byly odeslány později. Ale to je pouze doporučení a výsledek není zaručen.

Druhým parametrem v metodě je předaný CancellationToken. Pro správné zpracování zrušení operace po jejím spuštění by měl být vykonávaný kód naplněn kontrolami stavu CancellationToken. Pokud kontroly neexistují, pak metoda Cancel vyvolaná na objektu CancellationTokenSource může zastavit vykonání Tasku pouze před jeho spuštěním.

Posledním parametrem je objekt scheduler typu TaskScheduler. Tato třída a její dědici jsou určeni k řízení strategií plánování úloh na vláknech; ve výchozím nastavení bude úloha provedena na náhodném vlákně z poolu.

K vytvořenému Tasku byl aplikován operátor await, což znamená, že kód napsaný za ním, pokud nějaký existuje, bude proveden ve stejném kontextu (často to znamená, že na stejném vlákně), jako kód před await.

Metoda je označena jako async void, což znamená, že v ní je povoleno použití operátoru await, ale vyvolávající kód si na dokončení nepočká. Pokud je taková možnost nezbytná, metoda by měla vracet Task. Metody označené async void se vyskytují poměrně často: obvykle jde o obslužné rutiny událostí nebo jiné metody pracující na principu 'provedení a zapomenutí' (fire and forget). Pokud je třeba nejen umožnit čekání na dokončení, ale i vrátit výsledek, je nutné použít Task.

Na Tasku, který vrátila metoda StartNew, můžete, stejně jako na jakémkoli jiném, zavolat metodu ConfigureAwait s parametrem false, poté pokračování po await nebude na zachyceném kontextu, ale na libovolném. To by mělo být vždy provedeno, když pro kód po await není kontext provádění zásadní. Je to také doporučení od MS při psaní kódu, který bude dodáván ve formě knihovny.

Zastavme se ještě na chvíli u toho, jak lze počkat na dokončení vykonání Tasku. Níže je příklad kódu s komentáři, kdy je čekání provedeno podmíněně dobře a kdy podmíněně špatně.

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // dobré

    result = AsyncMethod().Result; // špatné

    AsyncMethod().Wait(); // špatné

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

    await Task.WhenAll(tasks); // dobré
    await Task.WhenAny(tasks); // dobré

    Task.WaitAll(tasks.ToArray()); // špatné
}

V prvním příkladu čekáme na provedení Tasku, aniž bychom blokovali vyvolávající vlákno, k zpracování výsledku se vrátíme až když bude k dispozici; do té doby je vyvolávající vlákno ponecháno samo sobě.

Ve druhé variantě blokujeme volající vlákno, dokud není výsledek metody spočítán. To je špatné nejen proto, že jsme zabavili vlákno, které je cenným zdrojem programu, prostým nečinným čekáním, ale také proto, že pokud v kódu metody, kterou voláme, existuje await a kontext synchronizace předpokládá návrat do volajícího vlákna po await, dostaneme deadlock: volající vlákno čeká na vyhodnocení výsledku asynchronní metody, zatímco asynchronní metoda se marně pokouší pokračovat ve svém vykonávání ve volajícím vlákně.

Dalším nedostatkem tohoto přístupu je složitější zpracování chyb. Fakt je ten, že chyby v asynchronním kódu při použití async/await se lehce zpracovávají — chovají se stejně, jako by kód byl synchronní. Naopak, pokud použijeme synchronní čekání na úlohu, původní výjimka se zabalí do AggregateException, takže pro zpracování výjimky budeme muset prozkoumat typ InnerException a sami napsat řetězec if uvnitř jednoho bloku catch nebo použít konstrukci catch when, místo více obvyklého řetězce bloků catch v C#.

Třetí a poslední příklady jsou rovněž označeny jako špatné ze stejného důvodu a obsahují všechny tyto problémy.

Metody WhenAny a WhenAll jsou extrémně užitečné při čekání na skupinu úloh, obalují skupinu úloh do jedné, která se spustí buď při prvním splnění úlohy ze skupiny, nebo když všechny úlohy dokončí své vykonávání.

Zastavení vláken

Z různých důvodů může vzniknout potřeba zastavit vlákno po jeho startu. K tomu existuje několik způsobů. Třída Thread má dvě metody s vhodnými názvy — a to Abort a Interrupt. První se důrazně nedoporučuje používat, protože po jeho vyvolání do jakéhokoli náhodného okamžiku, při zpracování jakékoli instrukce, bude vyhozena výjimka ThreadAbortedException. Očekáváte snad, že taková výjimka vyvstane při inkrementaci nějaké celočíselné proměnné, že? Při použití této metody je to zcela reálná situace. V případě potřeby lze zakázat CLR generovat takovou výjimku v určité části kódu obalením jej do volání Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Jakékoliv kódy napsané v bloku finally se takto chovají. Z tohoto důvodu můžete v kódu frameworku najít bloky s prázdným try, ale nikoli prázdným finally. Microsoft tak silně nedoporučuje používat tento způsob, že ho nezahrnuli do .net core.

Metoda Interrupt funguje předvídatelněji. Může přerušit vlákno výjimkou. ThreadInterruptedException pouze v okamžicích, kdy se vlákno nachází ve stavu čekání. Do tohoto stavu přechází, když visí v očekávání WaitHandle, lock nebo po zavolání Thread.Sleep.

Oba popsané případy trpí svou nepředvídatelností. Řešením je použití struktury CancellationToken a třídy CancellationTokenSource. Podstata je následující: vytvoří se instance třídy CancellationTokenSource a pouze ten, kdo ji vlastní, může operaci zastavit zavoláním metody Cancel. Do samotné operace se předává pouze CancellationToken. Vlastníci CancellationToken nemohou operaci sami zrušit, ale mohou pouze ověřit, zda byla operace zrušena. K tomu slouží booleovská vlastnost IsCancellationRequested a metoda ThrowIfCancelRequested. Posledně zmíněná vygeneruje výjimku TaskCancelledException pokud byla na uvedeném CancellationTokenSource zavolána metoda Cancel. A právě tuto metodu doporučuji používat. Je lepší než předchozí možnosti a dává vám plnou kontrolu nad tím, kdy může být výjimka operace přerušena.

Nejdrastičtějším způsobem zastavení vlákna je volání funkce Win32 API TerminateThread. Chování CLR po tomto volání může být nepředvídatelné. Na MSDN je o této funkci napsáno následující: „TerminateThread je nebezpečná funkce, která by měla být používána pouze v nejextrémnějších případech.“

Převod legacy-API na Task Based pomocí metody FromAsync

Pokud máte štěstí a pracujete na projektu, který byl zahájen až po zavedení úloh, které přestaly vyvolávat tichý hrůzu většiny vývojářů, nebudete se muset potýkat s velkým množstvím zastaralých API, ať už třetích stran, nebo těch, které byly v minulosti vyvinuty vaším týmem. Naštěstí se o nás postaral vývojový tým .NET Framework, i když možná primárním cílem byla péče o sebe. Ať tak či onak, v .NET existuje řada nástrojů pro bezbolestnou konverzi kódu napsaného ve starých přístupech asynchronního programování na nové. Jedním z nich je metoda FromAsync třídy TaskFactory. Na příkladu níže obaluji staré asynchronní metody třídy WebRequest do úlohy pomocí této metody.

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

To je pouze příklad a takto byste něco s vestavěnými typy pravděpodobně dělat nemuseli, ale jakýkoli starý projekt je jednoduše přeplněn metodami BeginDoSomething vracejícími IAsyncResult a metodami EndDoSomething, které je přijímají.

Konverze legacy-API na Task Based pomocí třídy TaskCompletionSource

Dalším důležitým nástrojem pro uvážení je třída TaskCompletionSource. Funkčně, svojí úlohou a principem fungování může připomínat metodu RegisterWaitForSingleObject třídy ThreadPool, o které jsem psal výše. Pomocí této třídy je možné snadno a pohodlně obalovat stará asynchronní API do úloh.

Řeknete, že jsem již hovořil o metodě FromAsync třídy TaskFactory určené pro tyto účely. Zde si budeme muset připomenout celou historii vývoje asynchronních modelů v .NET, které Microsoft nabízel za posledních 15 let: před vzorem asynchronního programování založeným na úlohách (TAP) existoval vzor asynchronního programování (APP), který byl o metodách BeginDoSomething vracejících IAsyncResult a metodách EndDoSomething, které je přijímají a pro legacy z těch let se výborně hodí metoda FromAsync, ale v průběhu času jej nahradil Event Based Asynchronous Pattern (EAP), který předpokládal, že po dokončení asynchronní operace bude vyvolána událost.

TaskCompletionSource je ideální pro obalování do úloh legacies-API založeného na událostní modelu. Podstata jeho fungování spočívá v tom, že objekt této třídy má veřejnou vlastnost typu Task, jejíž stav lze řídit pomocí metod SetResult, SetException a dalších. Tam, kde byl použit operátor await na tuto úlohu, bude provedena nebo zkrachována s výjimkou v závislosti na použité metodě TaskCompletionSource. Pokud to stále není jasné, podívejme se na tento příklad kódu, kde je staré API z dob EAP zabaleno do úlohy pomocí TaskCompletionSource: při vyvolání události bude úloha převedena do stavu Completed a metoda, která použila na tuto úlohu operátor await, obnoví své vykonávání a obdrží objekt. result.

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

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

    return completionSource.Task;
}

Tipy a triky pro TaskCompletionSource

Obalování starých API je jen jednou z věcí, které lze provést pomocí TaskCompletionSource. Použití této třídy otevírá zajímavou možnost navrhování různých API založených na úlohách, které nezabírají vlákna. A vlákno, jak víme, je drahý zdroj a jejich počet je omezen (většinou množstvím RAM). Toho omezení lze snadno dosáhnout při vývoji například vytížené webové aplikace se složitou obchodní logikou. Zvážíme ty možnosti, o kterých hovořím, na realizaci takového triku jako je Long-Polling.

V krátkosti, podstata triků je tato: potřebujete získávat od API informace o některých událostech, které se na jeho straně dějí, přičemž API z nějakých důvodů nemůže oznámit událost, ale pouze vrátit stav. Příkladem jsou všechna API postavená na HTTP před dobou WebSocket nebo při nemožnosti z nějakého důvodu tuto technologii použít. Klient se může zeptat na HTTP server. HTTP server nemůže sám vyvolat komunikaci s klientem. Jednoduchým řešením je dotazování serveru s časovačem, ale to vytváří dodatečné zatížení serveru a dodatečné zpoždění průměrně TimerInterval / 2. Aby se tomu předešlo, byl vynalezen trik nazvaný Long Polling, který předpokládá zpoždění odpovědi od serveru, dokud nevyprší Timeout nebo nenastane událost. Pokud k události dojde, je zpracována, pokud ne, požadavek je zaslán znovu.

while(!eventOccures && !timeoutExceeded)  {

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

Ale toto řešení ukáže své slabiny, jakmile vzroste počet klientů čekajících na událost, protože každý takový klient v očekávání události zabírá celý vláknový proces. A také dostáváme dodatečné zpoždění 1 ms při vyvolání události, což obvykle není podstatné, ale proč zhoršovat software, než je potřebné? Pokud odstraníme Thread.Sleep(1), zbytečně zatížíme jedno jádro procesoru na 100% v zbytečném cyklu. S pomocí TaskCompletionSource můžeme snadno přepracovat tento kód a vyřešit všechny výše 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 není určen pro produkci, ale pouze jako ukázka. Pro použití v reálných případech je třeba ještě alespoň zpracovat situaci, kdy zpráva přišla v okamžiku, kdy ji nikdo nečeká: v takovém případě by měl metoda AcceptMessageAsync vrátit již dokončený Task. Pokud je tento případ nejběžnější, může být také rozumné uvažovat o použití ValueTask.

Při zpracování požadavku na zprávu vytváříme a ukládáme do slovníku TaskCompletionSource a poté čekáme, co se stane dříve: vypršení zadaného časového intervalu nebo obdržení zprávy.

ValueTask: proč a jak

Operátory async/await a také operátor yield return generují ze metody stroj stavů, což znamená vytvoření nového objektu, což obvykle není problém, ale v některých vzácných případech může způsobit potíže. Tímto případem může být metoda, která je volána opravdu často, řekněme, desítky a stovky tisíc volání za sekundu. Pokud je taková metoda napsána tak, že ve většině případů vrací výsledek, aniž by procházela všechny await metody, .NET poskytuje nástroj, jak to optimalizovat – strukturu ValueTask. Abychom to pochopili, podívejme se na příklad jejího použití: existuje cache, do které se dostáváme velmi často. Některé hodnoty v ní jsou, a tak je jednoduše vrátíme, pokud nejsou, jdeme pro ně do nějakého pomalého IO. Poslední chceme dělat asynchronně, což znamená, že celá metoda se stává asynchronní. Takže očividná varianta napsání metody je následující:

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

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

Z důvodu touhy trochu optimalizovat a mírné obavy z toho, co vygeneruje Roslyn při kompilaci tohoto kódu, lze tento příklad přepsat následujícím způsobem:

public Task<string> GetById(int id) {

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

Oprávněně nejlepším řešením v tomto případě bude optimalizovat hot-path, a to konkrétně získání hodnoty ze slovníku vůbec bez zbytečných alokací a zatížení GC, zatímco v těch vzácných případech, kdy potřebujeme jít do IO pro data, vše zůstane plus/mínus podle starého:

public ValueTask<string> GetById(int id) {

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

Pojďme podrobněji prozkoumat tento fragment kódu: pokud existuje hodnota v cache, vytváříme strukturu, jinak bude reálný task zabaleneý do hodnotového. Volající kód je jedno, jakou cestou tento kód prováděl: ValueTask se z hlediska syntaxe C# bude chovat stejně jako běžný Task v tomto případě.

TaskScheduler-y: řízení strategií spouštění Tasků

Další API, které bychom chtěli prozkoumat, je třída TaskScheduler a jeho odvozeniny. Již jsem zmínil výše, že v TPL existuje možnost řídit strategie rozdělování úloh mezi vlákna. Tyto strategie jsou definovány v dědicích třídy TaskScheduler. Prakticky jakákoli strategie, která může být potřebná, bude nalezena v knihovně. ParallelExtensionsExtras, vyvinutá společností Microsoft, ale není součástí .NET a je dodávána jako balíček NuGet. Krátce se podíváme na některé z nich:

  • CurrentThreadTaskScheduler — vykonává úlohy na aktuálním vlákně.
  • LimitedConcurrencyLevelTaskScheduler — omezuje počet úloh vykonávaných simultánně parametrem N přijatým v konstruktoru.
  • OrderedTaskScheduler — je definován jako LimitedConcurrencyLevelTaskScheduler(1), což znamená, že úlohy budou vykonávány postupně.
  • WorkStealingTaskScheduler — implementuje work-stealing přístup k rozdělení úloh. V podstatě se jedná o samostatný ThreadPool. Řeší problém toho, že ve .NET je ThreadPool statická třída, jedna pro všechny aplikace, což znamená, že jeho přetížení nebo nesprávné použití v jedné části programu může vést k vedlejším efektům v jiné. Navíc je velmi těžké pochopit příčinu takových defectů. Může tedy existovat potřeba používat samostatné WorkStealingTaskScheduler v těch částech programu, kde může být použití ThreadPool agresivní a nepředvídatelné.
  • QueuedTaskScheduler — umožňuje vykonávat úlohy podle pravidel fronty s prioritami.
  • ThreadPerTaskScheduler — vytváří samostatné vlákno pro každou úlohu, která se na něm vykonává. Může být užitečné pro úkoly, které trvají nepředvídatelně dlouho.

Existuje dobrý podrobný neaktuální statistika přehled o TaskScheduler v blogu Microsoft.

Pro pohodlné ladění všeho spojeného s úlohami ve Visual Studiu existuje okno Úkoly. V tomto okně můžete vidět aktuální stav úlohy a přejít na prováděný řádek kódu.

.NET: Nástroje pro práci s multithreadingem a asynchronností. Část 1

PLinq a třída Parallel

Kromě úloh a všeho, co s nimi souvisí v .NET, existují ještě dva zajímavé nástroje, a to PLinq (Linq2Parallel) a třída Parallel. První slibuje paralelní provádění všech Linq operací na více vláknech. Počet vláken lze konfigurovat pomocí rozšiřovací metody WithDegreeOfParallelism. Bohužel, ve většině případů PLinq v režimu výchozím nemá dostatek informací o vnitřnostech vašeho datového zdroje, aby zajistil podstatné zrychlení, na druhou stranu je cena pokusu velmi nízká: stačí pouze zavolat metodu AsParallel před řetězcem Linq metod a provést testy výkonnosti. Navíc existuje možnost předat PLinq dodatečné informace o povaze vašeho datového zdroje pomocí mechanismu Partitions. Více se lze dočíst zde a zde.

Statická třída Parallel poskytuje metody pro paralelní iteraci kolekcí Foreach, provádění cyklu For a paralelní vykonání několika delegátů pomocí Invoke. Vykonávání aktuálního vlákna bude zastaveno do dokončení výpočtů. Počet vláken lze konfigurovat předáním ParallelOptions jako posledního argumentu. Pomocí těchto možností lze také specifikovat TaskScheduler a CancellationToken.

Závěry

Když jsem začal psát tento článek na základě materiálů své prezentace a informací, které jsem shromáždil během doby práce po ní, neočekával jsem, že toho bude tolik. Teď, když textový editor, ve kterém píšu tento článek, se mnou nekompromisně komunikuje, že je již 15. stránka, udělám si mezisouhrn. Další triky, API, vizuální nástroje a úskalí budou probrány v dalším článku.

Závěry:

  • Je nutné znát nástroje pro práci s vlákny, asynchronností a paralelismem, abyste mohli efektivně využívat zdroje moderních PC.
  • .NET nabízí mnoho různých nástrojů pro tyto účely
  • Ne všechny se objevily najednou, a proto se často můžete setkat s legacy, nicméně existují způsoby, jak převést staré API s minimálním úsilím.
  • Práce s vlákny v .NET je prezentována třídami Thread a ThreadPool
  • Metody Thread.Abort, Thread.Interrupt a funkce Win32 API TerminateThread jsou nebezpečné a nedoporučuje se je používat. Místo nich je lepší využít mechanismus CancellationTokenů.
  • Toky jsou cenný zdroj, jejichž množství je omezené. Je třeba se vyhnout situacím, kdy jsou toky obsazené čekáním na události. K tomu je vhodné použít třídu TaskCompletionSource.
  • Nejpokročilejším a nejvýkonnějším nástrojem .NET pro práci s paralelismem a asynchronností jsou úkoly (Tasks).
  • Operátory c# async/await implementují koncept neblokujícího čekání.
  • Správa rozdělení úkolů (Tasks) mezi tokami je možná pomocí odvozených tříd TaskScheduler.
  • Struktura ValueTask může být užitečná pro optimalizaci hot-path a paměťového provozu.
  • Okna Tasks a Threads ve Visual Studiu poskytují mnoho užitečných informací pro ladění vícerozměrného nebo asynchronního kódu.
  • PLinq je skvělý nástroj, ale nemusí mít dostatek informací o vašem datovém zdroji, toho se však dá dosáhnout pomocí mechanismu partitioning.
  • Pokračování příště…

Zdroj: habr.com

Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Koupit spolehlivý hosting pro webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster