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

Publikuji původní článek o Habrovi, jehož překlad je vyvěšen v korporátu blogový příspěvek.

Potřeba dělat něco asynchronně, bez čekání na výsledek tady a teď, nebo rozdělit velkou práci mezi několik jednotek, které ji provádějí, existovala již před příchodem počítačů. S jejich příchodem se tato potřeba stala velmi hmatatelnou. Nyní, v roce 2019, píšu tento článek na notebooku s 8jádrovým procesorem Intel Core, na kterém paralelně běží více než sto procesů a ještě více vláken. Nedaleko je trochu ošuntělý telefon, koupený před pár lety, má na desce 8jádrový procesor. Tematické zdroje jsou plné článků a videí, kde jejich autoři obdivují letošní vlajkové smartphony s 16jádrovými procesory. MS Azure poskytuje virtuální počítač se 20jádrovým procesorem a 128 TB RAM za méně než 2 USD/hodinu. Bohužel je nemožné vytěžit maximum a využít tuto sílu, aniž bychom byli schopni řídit interakci vláken.

Terminologie

Proces - Objekt OS, izolovaný adresní prostor, obsahuje vlákna.
Vlákno - objekt OS, nejmenší jednotka provádění, součást procesu, vlákna sdílejí mezi sebou paměť a další zdroje v rámci procesu.
Multitasking - Vlastnost OS, schopnost spouštět několik procesů současně
Vícejádrový - vlastnost procesoru, možnost využít pro zpracování dat několik jader
Multiprocessing - vlastnost počítače, schopnost současně fyzicky pracovat s několika procesory
Vícevláknové zpracování — vlastnost procesu, schopnost distribuovat zpracování dat mezi několik vláken.
Rovnoběžnost - provádění několika akcí fyzicky současně za jednotku času
Asynchronie — provedení operace bez čekání na dokončení tohoto zpracování, výsledek provedení lze zpracovat později.

Metafora

Ne všechny definice jsou dobré a některé potřebují další vysvětlení, takže k formálně zavedené terminologii přidám metaforu o vaření snídaně. Vaření snídaně v této metafoře je proces.

Při ranní přípravě snídaně jsem (procesor) Přijdu do kuchyně (Počítač). Mám 2 ruce (Jádra). V kuchyni je řada zařízení (IO): trouba, varná konvice, toustovač, lednice. Zapnu plyn, dám na něj pánev a naliju do ní olej, aniž bych čekal, až se rozehřeje (asynchronně, Non-Blocking-IO-Wait), vyndám vejce z lednice a rozdrobím je na talíř, pak je jednou rukou rozklepnu (Vlákno č. 1), a druhý (Vlákno č. 2) držící talíř (sdílený zdroj). Nyní bych chtěl zapnout konvici, ale nemám dost rukou (Hladovění vláken) Během této doby se rozehřeje pánev (Zpracování výsledku) do které naliji, co jsem našlehal. Sáhnu po konvici, zapnu ji a hloupě sleduji, jak se v ní vaří voda (Blocking-IO-Wait), i když během této doby mohl umýt talíř, kde šlehal omeletu.

Uvařil jsem omeletu pouze 2 rukama a víc jich nemám, ale zároveň v okamžiku šlehání omelety proběhly 3 operace najednou: šlehání omelety, držení talíře, nahřívání pánve CPU je nejrychlejší část počítače, IO je to, co se nejčastěji vše zpomaluje, takže často je efektivním řešením něčím zabrat CPU při příjmu dat z IO.

Pokračování metafory:

  • Pokud bych se v procesu přípravy omelety také pokusil převléknout, byl by to příklad multitaskingu. Důležitá nuance: počítače jsou v tom mnohem lepší než lidé.
  • Kuchyně s několika kuchaři, například v restauraci - vícejádrový počítač.
  • Mnoho restaurací ve food courtu v nákupním centru - datovém centru

Nástroje .NET

.NET umí dobře pracovat s vlákny, stejně jako s mnoha jinými věcmi. S každou novou verzí zavádí další a další nové nástroje pro práci s nimi, nové vrstvy abstrakce nad vlákny OS. Při práci s konstrukcí abstrakcí používají vývojáři rámců přístup, který při použití abstrakce na vysoké úrovni ponechává příležitost sestoupit o jednu nebo více úrovní níže. Většinou to není nutné, ve skutečnosti to otevírá dveře střelbě do nohy brokovnicí, ale někdy, ve vzácných případech, to může být jediný způsob, jak vyřešit problém, který na současné úrovni abstrakce není vyřešen .

Nástrojem mám na mysli jak aplikační programovací rozhraní (API) poskytovaná frameworkem a balíčky třetích stran, tak celá softwarová řešení, která zjednodušují vyhledávání jakýchkoli problémů souvisejících s vícevláknovým kódem.

Zahájení vlákna

Třída Thread je nejzákladnější třídou v .NET pro práci s vlákny. Konstruktor přijímá jednoho ze dvou delegátů:

  • ThreadStart — Žádné parametry
  • ParametrizedThreadStart - s jedním parametrem typu objekt.

Delegát bude proveden v nově vytvořeném vláknu po zavolání metody Start Pokud byl konstruktoru předán delegát typu ParametrizedThreadStart, pak je třeba předat objekt metodě Start. Tento mechanismus je nutný k přenosu jakýchkoli místních informací do proudu. Stojí za zmínku, že vytvoření vlákna je nákladná operace a vlákno samotné je těžký objekt, přinejmenším proto, že alokuje 1 MB paměti na zásobníku a vyžaduje interakci s OS API.

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

Třída ThreadPool představuje koncept fondu. V .NET je fond vláken kusem inženýrství a vývojáři v Microsoftu vynaložili velké úsilí, aby zajistili, že bude optimálně fungovat v široké škále scénářů.

Obecná koncepce:

Od chvíle, kdy se aplikace spustí, vytvoří na pozadí několik vláken v rezervě a poskytuje možnost je převzít k použití. Pokud se vlákna používají často a ve velkém počtu, skupina se rozšíří tak, aby vyhovovala potřebám volajícího. Když ve fondu nejsou ve správný čas žádná volná vlákna, bude buď čekat, až se jedno z vláken vrátí, nebo vytvoří nové. Z toho vyplývá, že fond vláken je skvělý pro některé krátkodobé akce a špatně se hodí pro operace, které běží jako služby po celou dobu provozu aplikace.

Chcete-li použít vlákno z fondu, existuje metoda QueueUserWorkItem, která přijímá delegáta typu WaitCallback, který má stejný podpis jako ParametrizedThreadStart a parametr, který je mu předán, vykonává stejnou funkci.

ThreadPool.QueueUserWorkItem(...);

Méně známá metoda fondu vláken RegisterWaitForSingleObject se používá k organizaci neblokujících IO operací. Delegát předaný této metodě bude volán, když je WaitHandle předaný metodě „Uvolněno“.

ThreadPool.RegisterWaitForSingleObject(...)

.NET má časovač vláken a liší se od časovačů WinForms/WPF tím, že jeho handler bude volán na vlákně převzatém z fondu.

System.Threading.Timer

Existuje také poměrně exotický způsob, jak poslat delegáta k provedení do vlákna z fondu – metoda BeginInvoke.

DelegateInstance.BeginInvoke

Rád bych se krátce zastavil u funkce, na kterou lze zavolat mnoho z výše uvedených metod – CreateThread z Kernel32.dll Win32 API. Existuje způsob, díky mechanismu externích metod, jak tuto funkci zavolat. Takové volání jsem viděl jen jednou v hrozném příkladu legacy kódu a motivace autora, který přesně to udělal, mi stále zůstává záhadou.

Kernel32.dll CreateThread

Prohlížení a ladění vláken

Vlákna vytvořená vámi, všechny součásti třetích stran a fond .NET lze zobrazit v okně Vlákna sady Visual Studio. Toto okno zobrazí informace o vláknu pouze tehdy, když je aplikace v ladění a v režimu přerušení. Zde můžete pohodlně zobrazit názvy zásobníku a priority každého vlákna a 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 vnímat jako doporučení při rozdělování času procesoru mezi vlákna.

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

Paralelní knihovna úkolů

Task Parallel Library (TPL) byla představena v .NET 4.0. Nyní je to standardní a hlavní nástroj pro práci s asynchronií. Jakýkoli kód, který používá starší přístup, je považován za starší. Základní jednotkou TPL je třída Task ze jmenného prostoru System.Threading.Tasks. Úloha je abstrakce nad vláknem. S novou verzí jazyka C# jsme získali elegantní způsob práce s Tasks – operátory async/await. Tyto koncepty umožnily psát asynchronní kód, jako by byl jednoduchý a synchronní, to umožnilo i lidem s malým pochopením vnitřního fungování vláken psát aplikace, které je používají, aplikace, které nezamrzají 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 to pochopit v několika větách:

  • async je modifikátor metody vracející Task nebo void
  • a wait je neblokující operátor čekající úlohy.

Ještě jednou: operátor wait v obecném případě (existují výjimky) dále uvolní aktuální vlákno provádění, a když úloha dokončí své provádění, a vlákno (ve skutečnosti by bylo správnější říci kontext , ale o tom později) bude pokračovat v provádění metody dále. Uvnitř .NET je tento mechanismus implementován stejným způsobem jako výnos výnosu, kdy se zapsaná metoda promění v celou třídu, která je stavovým automatem a může být prováděna po samostatných částech v závislosti na těchto stavech. Každý, kdo má zájem, může napsat libovolný jednoduchý kód pomocí asynс/await, zkompilovat a zobrazit sestavení pomocí JetBrains dotPeek s povoleným kódem generovaným kompilátorem.

Podívejme se na možnosti spuštění a použití Task. V níže uvedeném příkladu kódu vytvoříme novou úlohu, která nedělá nic užitečného (Thread.Sleep(10000)), ale v reálném životě by to měla být složitá práce 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
}

Úkol je vytvořen s řadou možností:

  • LongRunning je nápovědou, že úkol nebude dokončen rychle, což znamená, že možná stojí za to zvážit nepřebírání vlákna z fondu, ale vytvoření samostatného vlákna pro tento úkol, aby nedošlo k poškození ostatních.
  • AttachedToParent - Úkoly mohou být uspořádány v hierarchii. Pokud byla použita tato možnost, pak může být Úloha ve stavu, kdy je sama dokončena a čeká na provedení svých potomků.
  • PreferFairness - znamená, že by bylo lepší provádět úkoly odeslané k provedení dříve, než ty, které byly odeslány později. Ale to je jen doporučení a výsledky nejsou zaručeny.

Druhý parametr předaný metodě je CancellationToken. Aby bylo možné správně zvládnout zrušení operace po jejím spuštění, musí být prováděný kód vyplněn kontrolami stavu CancellationToken. Pokud nejsou žádné kontroly, pak metoda Cancel volaná na objektu CancellationTokenSource bude moci zastavit provádění úlohy pouze před jejím spuštěním.

Posledním parametrem je objekt plánovače typu TaskScheduler. Tato třída a její potomci jsou navrženi tak, aby řídili strategie distribuce úloh napříč vlákny; ve výchozím nastavení bude úloha spuštěna na náhodném vláknu z fondu.

Operátor wait se aplikuje na vytvořenou úlohu, což znamená, že kód napsaný za ní, pokud nějaký existuje, bude proveden ve stejném kontextu (často to znamená ve stejném vlákně) jako kód před wait.

Metoda je označena jako asynchronní void, což znamená, že může používat operátora čekání, ale volající kód nebude moci čekat na provedení. Pokud je taková funkce nezbytná, metoda musí vrátit Task. Metody označené jako async void jsou poměrně běžné: zpravidla se jedná o obsluhu událostí nebo jiné metody, které fungují na principu oheň a zapomeň. Pokud potřebujete nejen dát příležitost počkat do konce provádění, ale také vrátit výsledek, musíte použít Task.

Na úloze, kterou vrátila metoda StartNew, stejně jako na jakékoli jiné, můžete zavolat metodu ConfigureAwait s parametrem false, pak provádění po wait nebude pokračovat na zachyceném kontextu, ale na libovolném kontextu. To by mělo být provedeno vždy, když kontext provádění není důležitý pro kód po čekání. Toto je také doporučení od MS při psaní kódu, který bude dodán zabalený v knihovně.

Zastavme se trochu více u toho, jak můžete čekat na dokončení Úkolu. Níže je uveden příklad kódu s komentáři, kdy je očekávání provedeno podmíněně dobře a kdy podmíněně špatně.

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 prvním příkladu čekáme na dokončení Task bez blokování volajícího vlákna, ke zpracování výsledku se vrátíme, až když už tam je, do té doby je volající vlákno ponecháno svému vlastnímu zařízení.

Ve druhé možnosti zablokujeme volající vlákno, dokud není vypočítán výsledek metody. To je špatné nejen proto, že jsme vlákno, tak cenný zdroj programu, obsadili jednoduchou nečinností, ale také proto, že pokud kód metody, kterou voláme, čeká, a kontext synchronizace vyžaduje návrat do volajícího vlákna po čekat, pak se dostaneme do uváznutí : Volající vlákno čeká na výpočet výsledku asynchronní metody, asynchronní metoda se marně snaží pokračovat ve svém provádění ve volajícím vláknu.

Další nevýhodou tohoto přístupu je složité zpracování chyb. Faktem je, že chyby v asynchronním kódu při použití async/await jsou velmi snadno zvládnutelné – chovají se stejně, jako kdyby byl kód synchronní. Zatímco pokud na Úkol aplikujeme exorcismus synchronního čekání, původní výjimka se změní na AggregateException, tzn. Chcete-li výjimku zvládnout, budete muset prozkoumat typ InnerException a sami napsat řetězec if do jednoho bloku catch nebo použít catch when konstrukt namísto řetězce catch bloků, který je známější ve světě C#.

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

Metody WhenAny a WhenAll jsou mimořádně vhodné pro čekání na skupinu Úkolů; zabalí skupinu Úkolů do jedné, která se spustí buď při prvním spuštění Úkolu ze skupiny, nebo až všechny dokončí své provedení.

Zastavování vláken

Z různých důvodů může být nutné zastavit průtok po jeho spuštění. Existuje několik způsobů, jak to udělat. Třída Thread má dvě vhodně pojmenované metody: potrat и Přerušit. První z nich se velmi nedoporučuje používat, protože po jejím zavolání v libovolném náhodném okamžiku během zpracování jakékoli instrukce dojde k vyvolání výjimky ThreadAbortedException. Neočekáváte, že taková výjimka bude vyvolána při inkrementaci libovolné celočíselné proměnné, že? A při použití této metody je to velmi reálná situace. Pokud potřebujete zabránit CLR ve generování takové výjimky v určité části kódu, můžete ji zabalit do volání Thread.BeginCriticalRegion, Thread.EndCriticalRegion. Jakýkoli kód napsaný v bloku konečně je zabalen do takových volání. Z tohoto důvodu v hlubinách kódu frameworku můžete najít bloky s prázdným pokusem, ale nakonec ne prázdným. Microsoft tuto metodu natolik odrazuje, že ji nezahrnul do jádra .net.

Metoda přerušení funguje předvídatelněji. Může přerušit vlákno s výjimkou ThreadInterruptedException pouze v těch okamžicích, kdy je vlákno ve stavu čekání. Do tohoto stavu se dostane, když visí při čekání na WaitHandle, zámek nebo po zavolání Thread.Sleep.

Obě výše popsané možnosti jsou špatné kvůli jejich nepředvídatelnosti. Řešením je použití struktury CancellationToken a třída CancellationTokenSource. Jde o toto: je vytvořena instance třídy CancellationTokenSource a pouze ten, kdo ji vlastní, může zastavit operaci voláním metody Zrušit. Samotné operaci je předán pouze CancellationToken. Vlastníci CancellationToken nemohou operaci sami zrušit, ale mohou pouze zkontrolovat, zda byla operace zrušena. K tomu existuje booleovská vlastnost IsCancellationRequested a způsob ThrowIfCancelRequested. Poslední vyvolá výjimku TaskCancelledException pokud byla na papouškovou instanci CancellationToken zavolána metoda Cancel. A toto je metoda, kterou doporučuji použít. Toto je vylepšení oproti předchozím možnostem získáním plné kontroly nad tím, v jakém bodě lze operaci výjimky přerušit.

Nejbrutálnější možností pro zastavení vlákna je volání funkce Win32 API TerminateThread. Chování CLR po volání této funkce 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 se měla používat pouze v nejextrémnějších případech. “

Převod staršího API na Task Based pomocí metody FromAsync

Pokud máte to štěstí, že pracujete na projektu, který byl zahájen po představení Tasks a přestal způsobovat tichou hrůzu pro většinu vývojářů, pak se nebudete muset potýkat se spoustou starých API, a to jak od třetích stran, tak od vašeho týmu. v minulosti mučil. Naštěstí se o nás postaral tým .NET Framework, i když možná cílem bylo postarat se sami o sebe. Ať je to jakkoli, .NET má řadu nástrojů pro bezbolestnou konverzi kódu napsaného starými asynchronními programovacími přístupy na nový. Jednou z nich je metoda FromAsync TaskFactory. V níže uvedeném příkladu kódu zabalím 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,
    we.EndGetResponse
);

Toto je jen příklad a je nepravděpodobné, že byste to museli dělat s vestavěnými typy, ale jakýkoli starý projekt se prostě hemží metodami BeginDoSomething, které vracejí metody IAsyncResult a EndDoSomething, které jej přijímají.

Převeďte starší API na Task Based pomocí třídy TaskCompletionSource

Dalším důležitým nástrojem, který je třeba zvážit, je třída TaskCompletionSource. Funkcemi, účelem a principem fungování může trochu připomínat metodu RegisterWaitForSingleObject třídy ThreadPool, o které jsem psal výše. Pomocí této třídy můžete snadno a pohodlně zabalit stará asynchronní API do Tasks.

Řeknete si, že jsem již mluvil 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 za posledních 15 let nabízel: před Task-Based Asynchronous Pattern (TAP) existoval vzor Asynchronous Programming Pattern (APP), který byla o metodách ZačítNěco se vrací IAsyncResult a metody KonecDoSomething to přijímá a pro dědictví těchto let je metoda FromAsync prostě perfektní, ale postupem času byla nahrazena asynchronním vzorem založeným na událostech (EAP), který předpokládal, že po dokončení asynchronní operace bude vyvolána událost.

TaskCompletionSource je ideální pro zabalení úkolů a starších rozhraní API vytvořených kolem modelu událostí. Podstata její práce je následující: objekt této třídy má veřejnou vlastnost typu Task, jejíž stav lze ovládat pomocí metod SetResult, SetException atd. třídy TaskCompletionSource. V místech, kde byl na tuto úlohu použit operátor čekání, bude tato úloha provedena nebo selže s výjimkou v závislosti na metodě použité na zdroj TaskCompletionSource. Pokud to stále není jasné, podívejme se na tento příklad kódu, kde je některé staré EAP API zabaleno do Task pomocí TaskCompletionSource: když se událost spustí, bude Task umístěna do stavu Dokončeno a metoda, která aplikovala operátor wait k této úloze obnoví její provádění po přijetí objektu následek.

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

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

    result completionSource.Task;
}

TaskCompletionSource Tipy & Triky

Zabalení starých rozhraní API není vše, co lze provést pomocí TaskCompletionSource. Použití této třídy otevírá zajímavou možnost navrhování různých API na Tasks, která nezabírají vlákna. A stream, jak si pamatujeme, je drahý zdroj a jejich počet je omezený (hlavně velikostí RAM). Tohoto omezení lze snadno dosáhnout vývojem například načtené webové aplikace se složitou obchodní logikou. Zvažme možnosti, o kterých mluvím při implementaci takového triku, jako je Long-Polling.

Stručně řečeno, podstatou triku je toto: musíte z API dostávat informace o některých událostech na jeho straně, zatímco API z nějakého důvodu nemůže událost hlásit, ale může pouze vrátit stav. Příkladem toho jsou všechna API postavená na HTTP před dobou WebSocket nebo když z nějakého důvodu nebylo možné tuto technologii použít. Klient se může zeptat serveru HTTP. HTTP server nemůže sám zahájit komunikaci s klientem. Jednoduchým řešením je dotazování serveru pomocí časovače, ale to vytváří další zatížení serveru a další zpoždění v průměrném intervalu TimerInterval / 2. Abychom tomu zabránili, byl vynalezen trik zvaný Long Polling, který zahrnuje zpoždění odpovědi od server, dokud nevyprší časový limit nebo dokud nenastane událost. Pokud událost nastala, je zpracována, pokud ne, je žádost odeslána znovu.

while(!eventOccures && !timeoutExceeded)  {

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

Takové řešení se ale ukáže jako hrozné, jakmile se zvýší počet klientů čekajících na akci, protože... Každý takový klient zabírá celé vlákno čekající na událost. Ano, a při spuštění události získáme další zpoždění 1 ms, většinou to není významné, ale proč dělat software horší, než může být? Pokud odstraníme Thread.Sleep(1), tak marně zatížíme jedno jádro procesoru 100% nečinné, rotující v zbytečném cyklu. Pomocí TaskCompletionSource můžete tento kód snadno předělat a vyřešit všechny výše 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 není připraven k výrobě, ale pouze jako demo. Abyste ji mohli použít v reálných případech, musíte také minimálně zvládnout situaci, kdy zpráva dorazí v době, kdy ji nikdo nečeká: v tomto případě by metoda AsseptMessageAsync měla vrátit již dokončený Task. Pokud je to nejběžnější případ, můžete přemýšlet o použití ValueTask.

Když obdržíme požadavek na zprávu, vytvoříme a umístíme TaskCompletionSource do slovníku a poté čekáme, co se stane jako první: vyprší zadaný časový interval nebo je přijata zpráva.

ValueTask: proč a jak

Operátoři async/await, stejně jako operátor výnosu, generují z metody stavový automat a tím je vytvoření nového objektu, což není téměř vždy důležité, ale ve vzácných případech může způsobit problém. V tomto případě se může jednat o metodu, která je volána opravdu často, bavíme se o desítkách a stovkách tisíc hovorů za vteřinu. Pokud je taková metoda napsána tak, že ve většině případů vrací výsledek obchází všechny metody čekání, pak .NET poskytuje nástroj k optimalizaci – strukturu ValueTask. Aby bylo jasno, podívejme se na příklad jeho použití: existuje cache, do které chodíme velmi často. Jsou v něm nějaké hodnoty a pak je prostě vrátíme, pokud ne, tak je dostaneme na nějakou pomalou IO. Chci to udělat asynchronně, což znamená, že celá metoda se ukáže jako asynchronní. Zjevný způsob zápisu metody je tedy následující:

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

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

Kvůli touze trochu optimalizovat a mírnému strachu z toho, co Roslyn vygeneruje při kompilaci tohoto kódu, můžete tento příklad přepsat následovně:

public Task<string> GetById(int id) {

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

Optimálním řešením by v tomto případě byla optimalizace horké cesty, konkrétně získání hodnoty ze slovníku bez zbytečných alokací a zatížení GC, zatímco v těch vzácných případech, kdy stále potřebujeme jít do IO pro data , vše zůstane plus / minus starým způsobem:

public ValueTask<string> GetById(int id) {

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

Podívejme se na tento kousek kódu blíže: pokud je v mezipaměti nějaká hodnota, vytvoříme strukturu, jinak se skutečný úkol zabalí do smysluplné. Volajícímu kódu je jedno, na které cestě byl tento kód proveden: ValueTask se z hlediska syntaxe C# bude v tomto případě chovat stejně jako běžná úloha.

TaskSchedulers: správa strategií spouštění úloh

Další API, které bych chtěl zvážit, je třída Plánovač úloh a jeho deriváty. Již jsem zmínil výše, že TPL má schopnost spravovat strategie pro distribuci Tasks napříč vlákny. Takové strategie jsou definovány v potomcích třídy TaskScheduler. V knihovně lze nalézt téměř jakoukoli strategii, kterou byste mohli potřebovat. ParallelExtensionsExtras, vyvinutý společností Microsoft, ale není součástí .NET, ale dodává se jako balíček Nuget. Podívejme se stručně na některé z nich:

  • CurrentThreadTaskScheduler — provádí úlohy v aktuálním vláknu
  • LimitedConcurrencyLevelTaskScheduler — omezuje počet Úloh prováděných současně parametrem N, který je akceptován v konstruktoru
  • OrderedTaskScheduler — je definováno jako LimitedConcurrencyLevelTaskScheduler(1), takže úkoly budou prováděny postupně.
  • WorkStealingTaskScheduler - nářadí práce-krádež přístup k rozdělování úkolů. V podstatě se jedná o samostatný ThreadPool. Řeší problém, že v .NET ThreadPool je statická třída, jedna pro všechny aplikace, což znamená, že její přetížení nebo nesprávné použití v jedné části programu může vést k vedlejším účinkům v jiné. Kromě toho je velmi obtížné pochopit příčinu takových defektů. Že. V částech programu, kde může být použití ThreadPool agresivní a nepředvídatelné, může být potřeba použít samostatné WorkStealingTaskSchedulers.
  • QueuedTaskScheduler — umožňuje provádět úkoly podle pravidel fronty priority
  • ThreadPerTaskScheduler — vytvoří samostatné vlákno pro každou úlohu, která je na něm provedena. Může být užitečné pro úkoly, jejichž dokončení trvá nepředvídatelně dlouho.

Je tam dobrý detail článek o TaskSchedulers na blogu společnosti Microsoft.

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

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

PLinq a třída Parallel

Kromě Tasks a všeho, co o nich bylo řečeno, jsou v .NET ještě dva zajímavé nástroje: PLinq (Linq2Parallel) a třída Parallel. První slibuje paralelní provádění všech operací Linq na více vláknech. Počet vláken lze konfigurovat pomocí metody rozšíření WithDegreeOfParallelism. Bohužel nejčastěji PLinq ve svém výchozím režimu nemá dostatek informací o vnitřnostech vašeho datového zdroje, aby poskytl významný nárůst rychlosti, na druhou stranu náklady na pokus jsou velmi nízké: stačí zavolat metodu AsParallel před řetězu metod Linq a spusťte výkonnostní testy. Navíc je možné předat PLinq další informace o povaze vašeho zdroje dat pomocí mechanismu Partitions. Můžete si přečíst více zde и zde.

Statická třída Parallel poskytuje metody pro paralelní iteraci kolekcí Foreach, provádění cyklu For a provádění více delegátů paralelně Invoke. Provádění aktuálního vlákna bude zastaveno, dokud nebudou výpočty dokončeny. Počet vláken lze nakonfigurovat předáním ParallelOptions jako posledního argumentu. Pomocí voleb můžete také určit TaskScheduler a CancellationToken.

Závěry

Když jsem začal psát tento článek na základě materiálů své zprávy a informací, které jsem během své práce po ní nasbíral, nečekal jsem, že toho bude tolik. Teď, když mi textový editor, ve kterém píšu tento článek, vyčítavě řekne, že stránka 15 je pryč, shrnu průběžné výsledky. Další triky, API, vizuální nástroje a úskalí probereme v příštím článku.

Závěry:

  • Abyste mohli využívat prostředky moderních PC, musíte znát nástroje pro práci s vlákny, asynchronií a paralelismem.
  • .NET má pro tyto účely mnoho různých nástrojů
  • Ne všechny se objevily najednou, takže často najdete starší verze, nicméně existují způsoby, jak převést stará API bez velkého úsilí.
  • Práce s vlákny v .NET je reprezentována třídami Thread a ThreadPool
  • Metody Thread.Abort, Thread.Interrupt a Win32 API TerminateThread jsou nebezpečné a nedoporučujeme je používat. Místo toho je lepší použít mechanismus CancellationToken
  • Flow je cenný zdroj a jeho nabídka je omezená. Je třeba se vyhnout situacím, kdy jsou vlákna zaneprázdněna čekáním na události. K tomu je vhodné použít třídu TaskCompletionSource
  • Nejvýkonnější a nejpokročilejší nástroje .NET pro práci s paralelismem a asynchronií jsou Tasks.
  • Operátoři c# async/await implementují koncept neblokovaného čekání
  • Distribuci úkolů mezi vlákny můžete řídit pomocí tříd odvozených z TaskScheduler
  • Struktura ValueTask může být užitečná při optimalizaci aktivních cest a provozu paměti
  • Okna Tasks and Threads sady Visual Studio poskytují spoustu informací užitečných pro ladění vícevláknového nebo asynchronního kódu.
  • PLinq je skvělý nástroj, ale nemusí mít dostatek informací o vašem zdroji dat, ale to lze opravit pomocí rozdělovacího mechanismu
  • Chcete-li se pokračovat ...

Zdroj: www.habr.com

Přidat komentář