Ponořme se do async/await v jazyce C# – 2. část

V tomto dílu se podíváme na pokročilejší použití asynchronního programování s async/await v jazyce C# a nahlédneme pod kapotu, abychom zjistili, jak to celé funguje.

Co je vlastně asynchronní metoda

Zatím jsme si ukázali, jak vytvořit asynchronní metodu, která volá jiné asynchronní metody. Toto je v praxi nejběžnější případ, ale jistě jste si již položili otázku, jak vytvořit metodu provádějící skutečnou asynchronní operaci. Začneme tím, jak implementovat CPU bound asynchronní metodu. V tomto případě nám TPL nabízí několik možností s různou úrovní nastavení. Společným jmenovatelem je v tomto případě lambda metoda. Zjednodušeně řečeno předáte TPL lambdu nebo delegát s kódem, který chcete provést asynchronně, a TPL vám vrátí Task, který bude dokončen, až daný kód doběhne.

// CPU bound asynchronní metoda
await Task.Run(() =>
{
    DoSomething();
});

// CPU bound asynchronní metoda s dalším asynchronním voláním
// využívá asynchronní lambda metodu
await Task.Run(async () =>
{
    await DoSomethingAsync();
    DoSomething();
});

// CPU bound asynchronní metoda s návratovou hodnotou
var x = await Task.Run(() =>
{
    return DoSomethingWithReturnValue();
});

// vytvoření Tasku s větší možnosti nastavení
await Task.Factory.StartNew(() =>
{
    DoSomething();
},
TaskCreationOptions.PreferFairness,
TaskScheduler.FromCurrentSynchronizationContext()
);

 

Task.Run je v podstatě zabalené volání Task.Factory.StartNew s běžně používanými parametry. Navíc obsahuje i přetížení, které umožní zadávat asynchronní lambda metodu, tedy lambdu, která používá await a vrací Task. Detaily o nastavení StartNew naleznete v dokumentaci. Její popis je mimo rozsah tohoto článku. Více se později budeme věnovat pouze TaskScheduleru.

Pokud bychom vytvářeli IO bound operaci, ve většině případů využijeme již nějaké existující asynchronní .NET API (pro práci se sítí, souborovým systémem atd.). Můžeme si ale popsat princip, na kterém IO bound operace fungují. Na nejnižší úrovni .NET API se ve většině případů jedná o volání API operačního systému. Do systému se zapíše callback, což je v podstatě delegát, který se zavolá v okamžiku, kdy operační systém detekuje dokončení asynchronní operace. Tento callback následně změní stav Tasku na dokončený, a tím se spustí pokračování metody, která asynchronní volání iniciovala. Pokud by vás toto téma zajímalo podrobněji, doporučuji velmi podrobný článek od Stephena Clearyho.

Někdy se ale i mimo low-level API hodí mít možnost vytvořit Task, který se označí jako dokončený na náš pokyn. Přímá změna stavu Tasku není možná. K tomuto slouží třída TaskCompletionSource<T> . Obzvláště se tato třída hodí, pokud chceme na Task migrovat staré asynchronních API založené na událostech. Její použití je poměrně přímočaré a ukážeme si ho na jednoduché konzolové aplikaci, která asynchronně čeká na stisknutí Ctrl+C.

class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("Stiskni Ctrl+C");
        await WaitForCancelKeyPressAsync();
        Console.WriteLine("Hotovo");
    }

    public static Task WaitForCancelKeyPressAsync()
    {
        // TaskCompletionSource nemá negenerickou variantu, proto musíme použít object
        TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();

        // reakce na stisknutí Ctrl+C nebo Ctrl+Break
        Console.CancelKeyPress += (s, e) =>
        {
            // nastavení výsledku Tasku, tím se Task označí jako hotový a asynchronní operace bude dokončena
            tcs.SetResult(null);
        };

        // vrátíme Task, který je řízen naším TaskCompletionSource
        return tcs.Task;
    }
}

 

V příkladu vidíme, že i když nechceme, aby asynchronní metoda cokoli vracela, musíme použít nějaký typový parametr pro TaskCompletionSource<T>. V tomto případě je použit object a návratová hodnota se nastavuje vždy na null. Třída Task<T> dědí od Task. Díky tomu je možné použít jako návratový typ metody typ Task, i když tcs.Task je typu Task<object>. V novém .NET 5.0 je k dispozici i negenerická varianta TaskCompletionSource, díky které již nebude třeba zadávat zbytečný typový parametr.

Může vzniknout potřeba implementovat asynchronní rozhraní, i když naše implementace nemůže být asynchronní. Typickým příkladem je situace, kdy chceme vracet pouze konstantu. Někdy se můžeme setkat s tím, že programátor tento problém vyřešil pomocí metody Task.Run. To je špatné řešení, protože kvůli jedné konstantě se spustí kód na dalším vlákně. Navíc se po dokončení lambdy znovu plánuje, na jakém vlákně poběží zbytek metody. Mnohem elegantnějším a hlavně optimálnějším řešením je použití statické metody Task.FromResult. Tato metoda vytvoří instanci třídy Task<T>, která je ihned po vytvoření označena jako dokončená a jako výsledek obsahuje definovanou hodnotu typu T. Díky tomu, že Task je hned ukončený, se nezabere žádné další vlákno, ani se pokračování metody nijak neplánuje. Zbytek metody se tudíž spustí na stejném vlákně tak, jak jsme si vysvětlovali výše.

// špatné 
public Task<int> GetValueAsync()
{
    return Task.Run(() => { return 42; });
}

// ještě horší - kvůli vrácení konstanty překladač vygeneruje velmi komplexní a zbytečný kód
public async Task<int> GetValueAsync()
{
    return await Task.Run(() => { return 42; });
}

// o něco lepší, stále ale překladač vygeneruje velmi komplexní a zbytečný kód
public async Task<int> GetValueAsync()
{
    return await Task.FromResult(42);
}

// dobré
public Task<int> GetValueAsync()
{
    return Task.FromResult(42);
}

 

Na předchozím příkladu vás možná zarazilo, že metoda vrací Task, ale není tam ani async, ani await. Podstatné pro asynchronní metodu je, že vrací TaskAsyncawait nám pouze pomohou zjednodušit zápis, pokud v metodě pracujeme s více asynchronními metodami nebo pokud potřebujeme získat a dále upravovat výsledek asynchronní metody. V tomto případě pouze předáváme asynchronní operaci dále bez toho, abychom ji jakkoli upravovali. Nevytváříme tedy nový Task , a proto ho stačí předat o úroveň výše. Ušetříme tím překladači trochu práce a kód, který překladač vygeneruje, bude jednodušší. Neznamená to ale, že můžeme nechat Task bez await. Každý Task by měl mít svůj await. V tomto případě se ho ale dočká až o úroveň výše.

Asynchronní metoda nevracející Task

Dosud jsme všude předpokládali, že asynchronní metoda musí vracet Task nebo Task<T>. Není to tak úplně pravda. V některých speciálních případech nemusí mít asynchronní metoda žádný návratový typ. Nicméně je třeba upozornit, že než něco takového použijete, musíte si být jisti, co děláte. Jinak se připravte na budoucí problémy.

C# umožňuje použít klíčové slovo async i u metod, které nevrací žádnou hodnotu (void). Na takovou metodu tedy nelze použít operátor await, a pokud v ní nastane nějaká chyba (tzn. je vyhozena výjimka), nemůže ji volající zachytit pomocí konstrukce try/catch. Toto chování může vést k velmi těžko odladitelným chybám a v minulosti mohlo způsobit i pád celé aplikace. Z tohoto důvodu se doporučuje async void metody nepoužívat. Existuje však jedno místo, kde se jejich použití nevyhneme – obsluha událostí (event). Pokud bychom například chtěli volat nějakou asynchronní metodu po stisknutí tlačítka ve WinForm aplikaci, nemáme jinou možnost. V tomto případě je chování ohledně výjimek přijatelné, protože už stejně není možné výjimku někde výš zachytit. Pravidlo pro používání async void metod tedy zní:

Používejte async void výhradně při obsluze událostí nejvyšší úrovně.

 

Nejvyšší úrovní se myslí, že událost nemůže být spuštěna žádným jiným kódem aplikace, než je framework. Jakýkoli jiný scénář je bez nadsázky cesta do pekel.

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();

        button1.Click += button1_Click;
    }

    private async void button1_Click(object sender, EventArgs e)
    {
        try
        {
            await SomeOperationAsync();
        }
        catch
        {
            // zalogování výjimky
        }
    }
}

 

Jazyk C# nedefinuje async/await přímo nad třídou Task. Pokud jiná třída splňuje pravidla definovaná v normě jazyka, může být v souvislosti s klíčovým slovem async použita jakákoli jiná třída než Task. Definice takové třídy je mimo rozsah tohoto článku. Vytváření vlastní obdoby Tasku je opravdu velmi neobvyklé a skutečně málokdy narazíte na něco, co by Taskem nešlo vyřešit. Nicméně i .NET obsahuje druhý typ, který lze použít místo TaskTask<T>. Jedná se o strukturu ValueTask. Tato struktura slouží pro optimalizaci velmi často volaných metod, které ne vždy probíhají asynchronně (např.: často čtou z cache). V tomto případě může opakované vytváření instance třídy Task na haldě (heap) brzdit výkon. ValueTask tento problém částečně řeší, neboť se jedná o strukturu. Pokud je vrácena hodnota bez provádění asynchronní operace, vytvoří se pouze struktura s navrácenou hodnotou. Jedná se ale o velmi minoritní záležitost s několika omezeními, a tak se v tomto článku spokojíme pouze s tímto krátkým popisem. Zájemci si mohou detaily najít v dokumentaci nebo si přečíst podrobný článek od Stephena Touba na toto téma.

Jak to funguje pod kapotou

Nyní již víme, jak async/await používat. V této části se podíváme na důležité stavební kameny, díky kterým to celé funguje. Zároveň také uvidíme, jakým způsobem mohou různé typy aplikací ovlivňovat způsob plánování spuštění zbytku metody.

ExecutionContext

S touto třídou se pravděpodobně nikdy přímo při programování nesetkáte. Nepřímo se s ní ale setkáváte neustále. Při každém běhu jakékoli aplikace v .NET je ExecutionContext přítomný. Zajišťuje informace o tom, který uživatel aplikaci používá, informaci o kulturním nastavení uživatele atd. Všechny třídy, které zajišťují spouštění kódu na různých vláknech s ExecutionContext pracují. Je tím zajištěno, že když kód mění vlákno, na kterém běží, uloží se kontext z předchozího vlákna, který se pak obnoví na novém vlákně. ExecutionContext obsahuje ještě mnoho dalších informací, nám ale stačí vědět, že tu je, a že se díky němu nemusíme bát přeskakovat z vlákna na vlákno.

ThreadPool

Dosud jsme se při vysvětlování omezili pouze na tvrzení, že pokračování po dokončení asynchronní operace běží na nějakém dalším vlákně. Kdyby systém vždy vytvářel nové vlákno, nebylo by to optimální, neboť vytvoření vlákna vyžaduje nemalou režii. A právě tento problém řeší statická třída ThreadPool. Tato třída si udržuje určitý počet vláken a jejich počet podle potřeby zvyšuje nebo snižuje. Pomocí metody ThreadPool.QueueUserWorkItem můžeme poolu předat delegát, který chceme na jednom z vláken provést. ThreadPool si tento požadavek uloží do fronty, a pokud je některé z vláken volné, tak na něm požadovaný kód spustí (s ExecutionContextem volajícího vlákna). Až vlákno dokončí zadaný požadavek, podívá se do fronty, jestli není potřeba vykonat nějakou další práci. Pokud ne, uspí se až do doby, než bude zase potřeba. Díky tomuto mechanismu se výrazně zjednodušuje spouštění částí metod na jiných vláknech a zároveň se vyhneme zbytečné režii.

ThreadPool nevznikl pouze pro plánování spouštění asynchronních operací na různých vláknech. Tato třída je součástí .NET téměř od jeho začátku. Mimo spouštění kódu na vláknech také nabízí možnost nastavit callback na dokončení asynchronní operace na úrovni operačního systému. To je často využito u low-level částí IO bound asynchronních operací. Takto hluboko ale nepůjdeme. Zájemci si mohou přečíst podrobnou dokumentaci na webu Microsoftu.

SynchronizationContext

Ani tato třída není v .NET žádnou novinkou, ale díky async/await ji budete používat, a ani o tom nebudete vědět. Jedná se v podstatě o abstrakci nad tím, jak se má aplikace zachovat po dokončení asynchronní operace. Umožňuje spustit delegát takovým způsobem, který odpovídá požadovanému chování v daném typu aplikace. Například technologie WinForm požaduje, aby se pokračování asynchronní metody spuštěné na UI vlákně opět provádělo na tom samém vlákně.

SynchronizationContext se přiřazuje pro každé vlákno zvlášť. Aktuálnímu vláknu se synchronizační kontext nastaví pomocí metody SynchronizationContext.SetSynchronizationContext. Pokud bychom chtěli zjistit, jaký synchronizační kontext má nastavené aktuální vlákno, použijeme statickou property SynchronizationContext.Current. Díky tomu je možné si na začátku asynchronní operace uložit synchronizační kontext aktuálního vlákna. Po dokončení operace je možné pomocí uloženého kontextu spustit zbývající část asynchronní metody. Ke spuštění delegáta na kontextu použijeme metodu Post. To, jakým způsobem bude delegát spuštěn, je plně na konkrétní implementaci uloženého SynchronizationContextu.

Výchozím synchronizačním kontextem pro každé vlákno je hodnota null. To lze interpretovat tak, že vlákno nemá žádné požadavky na spuštění navazujícího kódu po konci asynchronní operace. Tento kód tedy může být spuštěn na kterémkoli vlákně. Různé typy aplikací při inicializaci nastavují vláknu svůj synchronizační kontext. Například technologie WinForm nastaví UI vláknu instanci třídy WindowsFormsSynchronizationContext, která interně využívá mechanismus Control.Invoke. Obdobně mají své synchronizační kontexty i technologie WPF a UWP, které využívají jiné mechanismy pro synchronizaci s UI vláknem. Jejich princip je ale velmi podobný. Desktopové aplikace však nejsou jediné, které synchronizační kontext využívají. ASP.NET před vydáním .NET Core taktéž mělo svůj kontext, který zajišťoval předávání HTTP kontextu do dalších vláken. Nový ASP.NET Core byl navržen tak, že již není synchronizační kontext potřeba. Synchronizační kontext ve výchozím stavu nepoužívají ani konzolové aplikace.

Jako u každého synchronizačního mechanismu i u SynchronizationContext hrozí nebezpečí deadlocku. Tedy situace, kdy je vlákno, na kterém se snažíme spustit náš kód, zablokované a odblokuje se až spuštěním požadovaného kódu na daném vlákně. Pokud tedy pracujete s technologií, která synchronizační kontext využívá, musíte si na deadlocky dávat pozor. Později, při vysvětlení funkce awaiteru, si ukážeme jednoduchou zásadu, jak se dá riziku deadlocku předcházet.

TaskScheduler

TaskScheduler je součást TPL a je pevně svázán s třídami TaskTask<T>. Umožňuje definovat, jakým způsobem se bude vykonávat úloha reprezentovaná Taskem, včetně jejích zanořených asynchronních volání. Každá část asynchronní metody je spuštěna přes TaskScheduler, jehož konkrétní implementace může ovlivňovat například prioritu operací, vlákno na kterém se spustí, celkový počet využitých vláken atd.

Výchozím schedulerem je ThreadPoolTaskScheduler, který všechny úlohy spouští pomocí ThreadPool. Pokud bychom chtěli například zajistit, aby se celá asynchronní úloha prováděla pouze na omezeném počtu vláken, můžeme použít jiný TaskScheduler. Ten si buď vytvoříme sami, nebo budeme spoléhat na některou z existujících knihoven.

Koncept TaskScheduler není tak úplně navržen pro práci s async/await. Jediná možnost, jak můžete Tasku požadovaný scheduler předat, je pomocí parametru metody Task.Factory.StartNew. Svůj smysl tak má hlavně v případě, kdy spouštíte nějakou CPU bound operaci a chcete ovlivnit, jak se bude provádět. Async/await nicméně nemůže TaskScheduler úplně ignorovat. Pokud použijete operátor await v asynchronní úloze spuštěné s konkrétním schedulerem, musí být tento scheduler zohledněn při plánování pokračování metody.

Awaiter

Všechny stavební kameny, které jsme si dosud popsali, do sebe zapadnou právě v objektu, kterému se říká Awaiter. Awaiter je objekt, který zajistí propojení TPL a operátoru await. Jeho smysl lze přirovnat k enumerátoru, který zajišťuje stejné rozhraní pro různé třídy tak, aby s ním mohla pracovat konstrukce foreach. Operátor await lze použít nad každým objektem, který má metodu GetAwaiter. Tato metoda musí vracet instanci třídy, která splňuje tyto podmínky:

  • Implementuje rozhraní INotifyCompletion nebo jeho rozšíření ICriticalNotifyCompletion.
  • Má metodu IsComplete, která vrací bool.
  • Má metodu GetResult, která má libovolný návratový typ (včetně void).

Pokud třída splňuje tyto podmínky, je nazývána jako „awaitable“ a lze na ni aplikovat operátor await. Pokud tedy na objektu typu Task použijete await, překladač vygeneruje kód, který pomocí GetAwaiter získá instanci awaiteru. Následně pomocí metody IsComplete ověří, zda je již úloha dokončená. Pokud dokončená ještě není, naplánuje pomocí INotifyCompletion.OnComplete, co se má provést po dokončení. Právě v metodě OnComplete se použije SynchronizationContext nebo TaskScheduler. Pokud byla při vytváření awaiteru na vláknu nastavena instance SynchronizationContextu, uloží se tato instance do Tasku. Pomocí OnComplete se předá delegát, který spustí pokračování přes uložený synchronizační kontext. Pokud kontext definován nebyl, spustí se pokračování přes TaskScheduler uložený v daném Tasku.

Schéma funkce awaiteru třídy Task
Schéma funkce awaiteru třídy Task

Jak již bylo naznačeno, v rámci .NET existuje více awaitable objektů. Například metoda Task.Yield využívá speciální awaiter, jehož metoda IsComplete vrací vždy false. Díky tomu dojde vždy k asynchronnímu plánování zbytku metody. Dalším, často používaným příkladem speciálního awaitable objektu, je metoda ConfigureAwaiter u objektů třídy Task. Tato metoda vrací awaitable objekt, jehož awaiter ignoruje zachycený SynchronizationContext při plánování pokračování metody. Vždy se tedy využije TaskScheduler.

Překlad async metody

Nyní se podrobněji podíváme, jak s klíčovým slovem async a s operátorem await naloží překladač jazyka C#. Tato konstrukce není součástí jazyka CIL (Common Intermidiate Language), tedy jazyka, do kterého se kód v jazyce C# překládá. Překladač tedy musí konstrukce async/await přeložit do ekvivalentního kódu bez těchto klíčových slov. Již dříve jsme si naznačili způsob, jakým lze TPL využívat bez async/await. Překladač tuto transformaci ale dělá trochu komplexněji, protože musí zajistit správné chování try/catch a using bloků, cyklů atd. Pro každou asynchronní metodu je vygenerována nová třída. Při překladu v produkčním režimu je to dokonce struktura. Tato třída/struktura reprezentuje stavový stroj (state machine) dané asynchronní metody. Stav reprezentuje vždy jednu část metody mezi asynchronními voláními. Součástí této třídy/struktury jsou také všechny lokální proměnné asynchronní metody. Každé pokračování asynchronní metody je volání stále stejné metody tohoto vygenerovaného objektu. Podle stavu se při volání rozhodne, kterou část metody je potřeba provést. Následně se změní stav, aby se při příštím volání provedla zase jiná část metody.

Do větších podrobností o principu překladu asynchronních metod nebudeme zacházet. Pokud by vás toto téma zajímalo blíže, můžete si ve skvělém nástroji SharpLab.io vyzkoušet napsat nějakou asynchronní metodu a podívat se, co překladač vygeneruje. Zajímavá je především metoda s minimálně dvěma asynchronními voláními a try/catch blokem nebo cyklem. V takovém případě se výsledný kód začne poměrně rychle komplikovat.

A to je pro tento díl všechno. V příštím dílu se podíváme na nejčastější chyby při používání async/await.

 

Autor: Petr Haljuk

Fullstack developer