Objekty SynchronizationContext hrají zásadní roli při řízení, na jakém vlákně bude asynchronní kód pokračovat po dokončení určité operace. I když mohou být ekvivalentní, TPL (Task Parallel Library) je často přiměje provést další volání metody Post, což ovlivňuje výkon i chování aplikace. Typický scénář, kdy UI vlákno pokračuje ve vykonávání zbývající části metody, ukazuje, že všechny kroky zůstávají pod kontrolou uživatelského vlákna. Současně však vlákno z IO completion portu slouží jen k odeslání instrukce zpět na UI vlákno, které následně vykoná zbytek metody.

Každá implementace SynchronizationContext používá metodu Post odlišně a většinou je tato operace relativně nákladná. Proto .NET automaticky nevyužívá Post, pokud je zachycený SynchronizationContext stejný jako ten aktuální v okamžiku dokončení úlohy. To znamená, že při sledování volání v debuggeru se může zásobník volání zdát obrácený, protože nejhlubší metoda spouští další podle pořadí dokončení. Když je SynchronizationContext odlišný, je potřeba drahé volání Post, což může být v kritických částech výkonu nežádoucí.

Pro optimalizaci výkonu a v situacích, kdy nezáleží na konkrétním vlákně pokračování, lze použít metodu ConfigureAwait(false). Ta navrhne, že není potřeba se vracet do původního SynchronizationContext. Ve skutečnosti to funguje jako náznak, že pokračování může proběhnout v libovolném vlákně. Výsledkem je, že pokud úloha skončí na vlákně, které není prioritní (typicky ze thread poolu), pokračování se uskuteční právě tam. Pokud ale úloha skončí na důležitém vlákně (například UI vláknu), .NET ji raději uvolní, aby vlákno mohlo vykonávat jiné úkoly, a pokračování přesune opět do thread poolu.

Při integraci asynchronního kódu s existujícím synchronním prostředím je třeba být velmi opatrný. Přístup k synchronnímu kódu z asynchronního lze řešit například spuštěním synchronní metody v thread poolu přes Task.Run a následným await, což je ovšem spojeno s alokací vlákna a tedy i náklady. Naopak volání asynchronního kódu ze synchronní metody pomocí vlastnosti Result na Tasku často vede k zablokování a potenciálním deadlockům, zejména pokud se operace spouští na SynchronizationContext s jediným vláknem, jako je UI vlákno. V tomto případě čekající synchronní metoda zablokuje vlákno, na kterém by se mělo asynchronně pokračovat, a tím vzniká nerozluštitelná smyčka čekání.

Řešením bývá spustit asynchronní kód v rámci thread poolu pomocí Task.Run a až poté volat Result, čímž se deadlockům předchází, ale takový přístup je spíše nouzový a nevhodný pro dlouhodobé řešení. Lepší je v maximální možné míře konvertovat synchronní volající kód na asynchronní, aby se plně využily výhody asynchronního modelu.

Výjimky v asynchronním kódu jsou ošetřeny odlišně než v synchronním. Při vyhození výjimky uvnitř async metody je tato výjimka zachycena a uložena do Tasku, který se označí jako Faulted. Pokud je metoda, která daný Task awaituje, právě pozastavena, vyvolá se výjimka z await, což umožňuje zachytit chyby v běžných try..catch blocích. Tento mechanismus napodobuje chování synchronního kódu, i když skutečný zásobník volání po await je přerušený a obsahuje především frameworkové interní metody, které by jinak byly nečitelným překážkou při ladění.

Výjimky, které jsou zachyceny v async metodách, uchovávají kompletní stopu zásobníku, která je doplňována při postupném zpracování dalších awaitů. Díky tomu lze zpětně vystopovat řetězec volání, i když je asynchronní. Tento přístup je důležitý pro udržení přehlednosti a efektivního ladění i v komplexních asynchronních scénářích.

Je nezbytné pochopit, že efektivní správa SynchronizationContext, správné používání ConfigureAwait a uvědomění si rizik synchronního blokování v asynchronním prostředí jsou klíčem k psaní výkonného a stabilního kódu. Také je třeba brát v úvahu, že ne všechny async operace se chovají stejně, a proto je nutné sledovat, jakým způsobem a kde jsou Tasky dokončovány a jak s nimi framework zachází. Správné pochopení těchto principů zabraňuje nečekaným zablokováním a umožňuje využít plný potenciál asynchronního programování v .NET.

Jak kompilátor přepisuje async metodu a co to znamená pro běh programu?

V jazyce C# se klíčové slovo await neinterpretuje jako běžný příkaz. Místo toho představuje bod, ve kterém je metoda přerušena, a její vykonávání pokračuje až po dokončení očekávané úlohy. Aby tento mechanismus fungoval, kompilátor přetváří async metody do stavového automatu. V jádru této transformace je metoda MoveNext, která reprezentuje logiku metody rozdělenou podle jednotlivých stavů. Každý await tedy znamená nový stav v tomto automatu. Samotná metoda MoveNext nevrací hodnotu – její návratový typ je void, a skutečná návratová hodnota metody je nastavena přes AsyncTaskMethodBuilder.

Každý výraz return v původním kódu je kompilátorem převeden do volání SetResult, kterým se dokončí příslušný Task. Po dokončení následuje jednoduchý return k ukončení metody MoveNext. Na začátku metody se určuje, ze kterého bodu má být pokračováno – což je realizováno jako přepínač switch podle aktuálního stavu uloženého ve speciálním členském poli. Vytváří se tak vnitřní skrytý goto mechanismus, který umožňuje návrat do místa, kde byl await naposledy přerušen.

Při čekání (await) na úlohu se pomocí GetAwaiter získá TaskAwaiter, přes který se registruje zpětné volání. Dále se aktualizuje aktuální stav a metoda se opustí, čímž se vlákno uvolní. Po dokončení úlohy je MoveNext opět spuštěna, tentokrát v novém stavu, a zavolá se GetResult na TaskAwaiteru, což buď vrátí výsledek, nebo vyhodí výjimku, pokud došlo k chybě.

Významným detailem je, že pokud je očekávaná úloha (Task) již dokončena synchronně, není nutné metodu přerušit – kontrola IsCompleted umožňuje okamžitý přechod do příslušného stavu přes goto. Kompilátor tedy generuje kód, který je funkčně přesný, ale za běžných okolností se s ním programátor nesetká, neboť je skrytý uvnitř IL kódu. I když kód obsahuje goto, je to zcela v pořádku – není určen ke čtení ani údržbě

Jak asynchronní kód zlepšuje výkon a uživatelský zážitek?

Pokud máme pracovat s jedním vláknem, například v grafickém uživatelském rozhraní (GUI), je zcela nevyhnutelné, že každé zadání úkolu bude muset být vykonáno v tomto vlákně. Kdybychom měli více vláken, museli bychom věnovat zvláštní pozornost tomu, jakým způsobem se jednotlivé úkoly provádí, abychom se vyhnuli konfliktům a zbytečnému blokování. V takovém případě bychom museli intenzivně využívat zámky, což by v konečném důsledku vedlo k výraznému poklesu výkonu, a to na úroveň, jako kdyby bylo k dispozici pouze jedno vlákno.

Pro lepší pochopení tohoto problému můžeme použít analogii s kavárnou. Představme si malou kavárnu, kde jediným pracovníkem je majitel, který se stará o zákazníky a připravuje pro ně tousty na snídani. Tento majitel je velmi závislý na poskytování kvalitní zákaznické služby, ale zatím se nenaučil využívat asynchronní techniky. Jeho práce v kavárně tedy odpovídá práci v jediném vlákně počítačového rozhraní.

Když zákazník přijde a požádá o toust, majitel začne připravovat chléb a vloží ho do toustovače. Jakmile je toust v troubě, začne na něj čekat, aby zjistil, kdy bude hotový. V tuto chvíli si však nevšímá ostatních zákazníků, kteří čekají na obsluhu. Tento proces trvá několik minut a během této doby se vytvoří fronta dalších zákazníků, kteří jsou zklamaní, že byli ignorováni. Výsledek? Nízká efektivita a zklamaní zákazníci.

Co kdybychom majitele kavárny naučili asynchronnímu přístupu? Prvním krokem je zajistit, aby toustovač fungoval asynchronně, tedy aby měl časovač, který upozorní majitele, až je toust hotový. Když začne připravovat toust, měl by se soustředit na ostatní úkoly – například obsluhovat nové zákazníky. Podobně, jako v asynchronním kódu, se vlákno okamžitě vrátí zpět k vykonávání jiných úkolů, jakmile je dlouhotrvající operace zahájena. Tímto způsobem je možné obsloužit více zákazníků najednou.

Tento přístup má několik výhod. Za prvé, zákazník, který čeká na toust, se nebude ignorovat, což znamená, že se může například zeptat na máslo. Za druhé, majitel kavárny bude schopen zpracovávat více zákazníků v jednom okamžiku, omezen pouze počtem toustovačů, které má k dispozici. Tato metoda je efektivní nejen pro zlepšení zkušenosti zákazníka, ale i pro samotného majitele, který je schopen řídit více úkolů současně.

Avšak jaký problém se zde může vyskytnout? Majitel si může mít problém zapamatovat, který toust patří kterému zákazníkovi, pokud má obsluhovat více lidí najednou. V případě asynchronního kódu v počítačových aplikacích tento problém vzniká také – jakmile vlákno začne dlouhotrvající operaci, už neví, jaké operace čeká na dokončení. Proto je nutné při zahájení každé operace připojit zpětné volání (callback), které nám pomůže sledovat, co máme dělat po dokončení úkolu. Pro majitele kavárny to znamená jednoduché označení toustů jmény zákazníků, ale v případě složitějších operací, například v webových aplikacích, se mohou vyžadovat komplikovanější metody, jak sledovat stav dokončení úkolu.

Ve webových aplikacích se často používají dlouhotrvající operace, jako jsou dotazy na vzdálené databáze. Pokud se při těchto operacích většina času čeká na odpověď, může se zdát, že je dobré zvýšit počet vláken pro zpracování více požadavků. Takto by server mohl vyřídit více požadavků najednou. Tento přístup má ale své limity – když je vlákno zablokováno a čeká, nevyužívá procesor, ale i přesto zůstává aktivní v systému. Tato vlákna spotřebovávají určité množství paměti, což může být problém při vysokém počtu vláken. Každé vlákno rezervuje přibližně megabajt virtuální paměti, a pokud jich začneme mít stovky, systém může začít trpět pomalým návratem do činnosti, pokud se paměť přesune na disk.

Zároveň existuje další náklad v podobě operačního plánovače, který musí rozhodovat, které vlákno bude provedeno a kdy. Tento proces může zpomalit celý systém, což vede k vyšší latenci a nižší propustnosti. Hlavní výhodou asynchronního kódu je, že vlákno, které zahájí dlouhou operaci, je uvolněno a může vykonávat jiné úkoly, zatímco operace běží na pozadí. Tímto způsobem server může zpracovat více požadavků, aniž by bylo nutné používat tolik vláken.

Webové aplikace, a to zejména v moderních rámcích jako node.js, mohou využívat jediný vlákno pro zpracování všech požadavků asynchronně. Tato metoda je nejen efektivní z hlediska využití systémových zdrojů, ale také zjednodušuje strukturu kódu, což může vést k lepší správě a údržbě aplikace.

Důležitým krokem pro správu asynchronního kódu je zajistit, že všechny operace, které budou trvat delší dobu, mají přidělený zpětný volání, aby bylo jasné, co se má dělat, až bude operace dokončena. Když se tento princip aplikuje na webové servery nebo aplikace, zajišťuje to lepší odezvu a umožňuje efektivní správu požadavků, aniž by se vytvářel zbytečný tlak na systém a zdroje serveru.