Asynchronní programování není v moderním softwarovém vývoji volitelným přístupem – stalo se nutností. Výkonné aplikace, které reagují bez prodlevy a dokáží zpracovávat více operací současně bez zamrzání uživatelského rozhraní nebo přetěžování systémových prostředků, vyžadují promyšlené využití asynchronních technik. V prostředí C# 5.0 se tato potřeba setkala s jazykovou podporou v podobě async a await, které zpřístupnily komplexní asynchronní modely běžným vývojářům bez nutnosti explicitní správy vláken.

V desktopových aplikacích s uživatelským rozhraním je asynchronie klíčová pro zachování odezvy aplikace. Každý dlouhotrvající výpočet nebo vstupně-výstupní operace provedená na hlavním vláknu způsobí, že aplikace „zamrzne“. To degraduje uživatelský zážitek a v některých případech může způsobit selhání systému. Asynchronní metody umožňují přesun těchto náročných operací mimo hlavní vlákno a jejich elegantní návrat do kontextu uživatelského rozhraní bez explicitní manipulace s Thread, ThreadPool, nebo BackgroundWorker.

Výstižnou analogií je kavárna, kde číšník přijímá objednávku a místo čekání na její přípravu rovnou obsluhuje další zákazníky. Objednávka se připravuje na pozadí a číšník je upozorněn, až je hotová. Podobně async metoda „předá práci“ jinému vlákno nebo operaci a po jejím dokončení pokračuje přesně tam, kde skončila. Díky tomu lze psát sekvenční kód, který se chová paralelně.

Na webových serverech, jako jsou ASP.NET aplikace, přináší asynchronní programování zásadní zlepšení škálovatelnosti. Vlákno serveru může obsluhovat jiné požadavky, zatímco čeká na externí zdroje – databáze, API nebo diskové operace. Bez asynchronie by bylo vlákno zablokováno a nepoužitelné, což dramaticky omezuje počet současně obsluhovaných požadavků. Asynchronní model tedy není jen optimalizací – je základním předpokladem pro moderní architektury orientované na vysokou propustnost a nízkou latenci.

Příbuznou analogií je kuchyně v restauraci, kde jednotliví kuchaři připravují různé pokrmy a mezi tím reagují na nové objednávky. Pokud by kuchař musel stát nečinně u každého pokrmu po celou dobu jeho vaření, kapacita kuchyně by byla nevyužitá. Asynchronní kód se chová podobně – efektivně rozděluje čas výpočetních prostředků mezi čekání a aktivní práci.

Důležité je chápat, že asynchronní kód není totožný s paralelním výpočtem. Asynchronie se zabývá efektivním plánováním práce, která musí čekat – například na odpověď ze sítě nebo na disk. Paralelismus naproti tomu maximalizuje využití vícejádrových procesorů. Použití Task a await v C# umožňuje propojit oba přístupy tak, aby program dokázal obsloužit mnoho současných požadavků bez vytváření zbytečných vláken, přetížení scheduleru nebo ztráty determinismu v chování programu.

Nezanedbatelný je vliv asynchronního návrhu na architekturu samotné aplikace. async metody jsou „nákazlivé“ – pokud jedna metoda využívá await, její volající často také musí být async, což propaguje strukturu programu odspodu nahoru. Vývojář tedy musí promýšlet asynchronní tok programu už při návrhu, nikoliv jako dodatečnou optimalizaci. To vede ke změně mentálního modelu programování, kde tradiční sekvenční myšlení nestačí.

Přesto je třeba mít na paměti, že asynchronní programování není univerzální řešení. Není vhodné pro všechny úlohy a nesprávné použití může vést k horší čitelnosti, těžko reprodukovatelným chybám nebo regresi výkonu. Operace, které nejsou blokující – například jednoduché výpočty v paměti – by neměly být asynchronní. async by měl být používán tam, kde je čekání na vnější zdroj reálné a nákladné z hlediska vláken.

Kromě jazykových konstrukcí

Jak vytvořit vlastní kombinátory pro asynchronní operace

Pojem kombinátorů, jako je například WhenAll nebo WhenAny, označuje metody, které kombinují různé úkoly (Tasks) a umožňují jejich paralelní zpracování. I když tyto metody vracejí úkoly, nejsou samotnými asynchronními metodami, ale spíše nástroji pro kombinování dalších úkolů způsobem, který je užitečný v různých situacích. Vytváření vlastních kombinátorů může být velmi užitečné pro definování opakovaně použitelných paralelních chování, které lze aplikovat na různá místa v kódu. Ukažme si to na příkladu.

Představme si, že bychom chtěli přidat timeout k jakémukoli úkolu. Tento úkol by mohl být vytvořen tak, že se využije jak metoda Delay, tak i WhenAny. Ačkoli je napsání této funkcionality poměrně jednoduché, poskytuje to dobrou ukázku, jak kombinovat různé techniky asynchronního programování.

Zde je ukázka, jak by mohl vypadat kombinátor pro přidání timeoutu k úkolu:

csharp
private static async Task WithTimeout(Task task, int time) { Task delayTask = Task.Delay(time); Task firstToFinish = await Task.WhenAny(task, delayTask); if (firstToFinish == delayTask) { task.ContinueWith(HandleException); throw new TimeoutException(); } return await task; }

Tento příklad ukazuje, jak vytvořit úkol pomocí Delay, který se splní po určitém timeoutu, a poté použít WhenAny, aby se čekalo na dokončení toho úkolu, nebo na uplynutí času. Pokud uplyne čas, vyvoláme výjimku TimeoutException, jinak vrátíme výsledek původního úkolu.

Všimněte si, že jsem zde pečlivě ošetřil výjimky v případě, že dojde k vypršení času. K původnímu úkolu připojuji pokračování pomocí metody ContinueWith, která zajišťuje, že jakmile původní úkol skončí, dojde k ošetření případných výjimek. Samotný timeout nemůže vyvolat výjimku, a proto se jí nemusím věnovat.

Samotná implementace metody pro ošetření výjimek může vypadat následovně:

csharp
private static void HandleException(Task task)
{ if (task.Exception != null) { logging.LogException(task.Exception); } }

Co je důležité si uvědomit, je, že každá asynchronní operace může mít různé způsoby, jak se zachovat v případě výjimek. V tomto případě je výjimka zachycena a ošetřena pomocí logování, ale výběr správné strategie závisí na konkrétní aplikaci a požadavcích na zpracování chyb.

Další klíčovou funkcionalitou asynchronního programování je zrušení operací. Zrušení je umožněno prostřednictvím typu CancellationToken. Tento token lze předat metodám, které podporují zrušení, aby bylo možné požádat o přerušení operace.

Zde je příklad, jak by mohl vypadat základní kód pro zrušení asynchronní operace:

csharp
CancellationTokenSource cts = new CancellationTokenSource();
cancelButton.Click += delegate { cts.Cancel(); }; int result = await dbCommand.ExecuteNonQueryAsync(cts.Token);

Po zavolání metody Cancel na instanci CancellationTokenSource se token přepne do stavu "zrušeno", a pokud daná operace používá tento token, může se předčasně ukončit vyhozením výjimky OperationCanceledException.

Ve své podstatě CancellationToken umožňuje flexibilní a efektivní zrušení operací jak ve vlákně, tak v rámci paralelních nebo sekvenčních výpočtů, což dává vývojáři možnost koordinovat zrušení několika operací najednou.

Další důležitou funkcí pro zlepšení uživatelské zkušenosti při asynchronním zpracování je indikace pokroku. Pomocí rozhraní IProgress můžete během dlouhotrvajících operací informovat uživatele o aktuálním stavu. Asynchronní metody obvykle přijímají parametr IProgress, který umožňuje odesílat hodnoty o pokroku. Tento postup je užitečný například při stahování dat nebo jiných operacích, které uživateli zabírají čas.

Zde je příklad, jak by mohl vypadat kód pro monitorování pokroku při stahování dat:

csharp
Task DownloadDataTaskAsync(Uri address, CancellationToken cancellationToken, IProgress<int> progress)

Pomocí implementace Progress<int> můžete snadno sledovat, jaký pokrok operace dosáhla. Implementace Progress zachycuje kontext synchronizace, což znamená, že se vaše aktualizace budou vykonávat na správném vlákně, tedy na vlákně uživatelského rozhraní, což je velmi důležité pro zajištění správného zobrazení pokroku.

Je také důležité vědět, že když implementujete IProgress, měli byste dávat pozor na typ předávaný do metody Report. Například pro jednoduché procentuální hodnoty je vhodný typ int, ale pokud potřebujete více informací, můžete zvolit komplexnější typy.

Klíčové je, že použití tohoto mechanismu zajišťuje, že pokrok bude zaznamenáván správně, i když se operace provádí na různých vláknech, což by jinak mohlo vést k problémům se synchronizací.

Jak efektivně využívat asynchronní programování a modely actorů pro zajištění paralelního zpracování

V oblasti asynchronního programování je klíčové správně řídit přístup k prostředkům, které mohou být sdíleny mezi různými částmi aplikace, zejména při použití konstrukce await. Při čekání na výsledek asynchronní operace je důležité pochopit, že zdroje by neměly být blokovány, jinak by mohl nastat konflikt při vykonávání kódu. V podstatě je nejlepším přístupem vyhýbat se rezervaci prostředků během čekání na await, protože účelem asynchronního programování je umožnit uvolnění prostředků během čekání.

Ukázkový příklad může být operace na uživatelském rozhraní (UI). Jelikož existuje pouze jeden vlákno pro UI, působí jako zámek. To znamená, že v jednom okamžiku je vykonávána pouze jedna část kódu, ačkoli, i v tomto případě, může během čekání na asynchronní operace dojít k nečekaným změnám. Uživatel má možnost interagovat s aplikací, například zmáčknout jiný tlačítko, zatímco kód stále čeká na dokončení operace. To je podstatou asynchronního programování v uživatelských aplikacích: udržet rozhraní odpovídající a reagující na uživatelské akce, i když to může být nebezpečné. Asynchronní operace nám umožňuje určit bod, ve kterém mohou nastat jiné akce, a my se musíme naučit umisťovat klíčové body await na bezpečné místo. Po obnovení běhu programu je nezbytné počítat s tím, že stav aplikace se mohl změnit. Někdy to může znamenat, že bude nutné provést kontrolu a rozhodnutí o pokračování operace.

Pokud pracujeme s daty, která mohou být neplatná během asynchronního čekání, je důležité je znovu ověřit, než s nimi budeme pokračovat v práci. Tento princip platí zejména pro komplexní aplikace, kde uživatelé mohou během čekání na jednu operaci provádět jiné akce.

Modely actorů jako alternativa k zámkům

Jedním z výkonných modelů pro paralelní programování je model actorů. Tento model je velmi podobný zámkům, protože i v tomto případě platí, že pouze jedno vlákno může přistupovat k určitému datovému úložišti. Nicméně, na rozdíl od zámků, vlákno nemůže být přítomno ve více než jednom actoru současně. Místo toho musí vlákno udělat asynchronní volání k jinému actorovi a poté může provádět jinou činnost během čekání na výsledek. Tato technika je velmi efektivní, protože zajišťuje bezpečnost práce s daty a zároveň umožňuje paralelizaci mezi různými komponentami aplikace.

Actor je definován jako objekt, který má odpovědnost za určitý soubor dat a jiný actor (nebo vlákno) nemá přístup k těmto datům. V praxi to znamená, že kód běžící na UI vlákně je do určité míry „zámkem“ pro data související s uživatelským rozhraním. Ve větších aplikacích můžeme stavět komponenty, které jsou implementovány v samostatných vláknech, přičemž každá komponenta je zodpovědná za údržbu svého vlastního datového stavu. Tento přístup přispívá k zajištění bezpečnosti a škálovatelnosti aplikací. Modely actorů se ukazují jako silný nástroj pro využívání paralelních výpočetních zdrojů, protože každý actor může běžet na jiném jádře procesoru.

Využití knihovny NAct pro implementaci modelu actorů v C#

Programování pomocí modelu actorů může být implementováno i ručně, ale pro usnadnění existují knihovny, které tuto práci zjednodušují. Knihovna NAct je jednou z těchto knihoven, která využívá asynchronní operace v C# a umožňuje běžným objektům stát se actory, čímž se jejich volání přesouvá na samostatná vlákna. Příkladem může být implementace služby pro kryptografii, která používá sekvenci pseudo-náhodných čísel k šifrování datového proudu. Generování náhodných čísel a jejich následné použití pro šifrování mohou probíhat paralelně, čímž se zlepší využití procesoru.

Příklad implementace generátoru náhodných čísel pomocí modelu actorů ukazuje, jak jednoduše lze tento model implementovat. Vytváříme rozhraní pro generátor náhodných čísel, které implementuje knihovna NAct jako actor. Každý actor provádí svou práci na vlastním vlákně a neblokuje ostatní procesy. V tomto případě, při každé iteraci šifrování datového proudu, čekáme na vygenerování dalšího náhodného čísla, zatímco probíhá výpočet.

Použití datového toku pro paralelní zpracování

Další užitečnou technikou pro paralelní programování, kterou lze efektivně využít s C# async, je programování pomocí datového toku (dataflow). Tento přístup spočívá v definování série operací, které se aplikují na vstupní data, a systém automaticky zajistí jejich paralelizaci. TPL Dataflow, knihovna poskytovaná Microsoftem, je v tomto případě velmi užitečná.

Dataflow programování je zvláště efektivní, když je klíčovým výkonovým prvkem v aplikaci transformace dat. Tato technika umožňuje plynule propojit různé části aplikace, kde jedna část vykonává určitou operaci a předává výsledek další. I v tomto případě lze kombinovat modely actorů a datového toku pro dosažení efektivního paralelního zpracování. Výhodou je, že můžeme udržet jednoduchost a efektivitu při paralelizaci operací, které si to vyžadují.

Závěrem

Pro efektivní paralelní programování v C# je klíčové využívat správné modely, které umožní optimálně využívat dostupné výpočetní prostředky. Použití modelu actorů ve spojení s asynchronními operacemi umožňuje snadnou správu paralelních operací a bezpečné zacházení s daty. K tomu můžeme přidat datový tok pro efektivní paralelizaci výpočetních úloh, což významně zlepšuje výkon aplikace.