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

Používání async/await přineslo velký rozmach asynchronního programování na platformě .NET hlavně díky jednoduchosti jeho použití. Ne každý vývojář ale přesně ví, co vše se za async/await skrývá, což může vést k nevědomému vytváření chyb. V této sérii článků postupně nahlédneme pod kapotu a vysvětlíme si potenciálně nebezpečná místa, na která můžete při programování s async/await narazit.

Co je asynchronní volání a proč je dobré ho používat

Asynchronní programování se týká především vláken a jejich blokování. Klasické synchronní volání metody způsobí, že vlákno je po celou dobu operace blokované, ať už prováděním kódu samotné metody, nebo čekáním na dokončení operace probíhající mimo váš program (například databázový dotaz). To znamená, že vlákno po dobu prováděné operace nemůže dělat žádnou jinou práci. V praxi je ale často zapotřebí, aby operace probíhala někde na pozadí a vlákno, ze kterého byla operace volána, bylo využito k vykonání jiné části programu. Když je operace dokončena, je o tom volající informován a může na to nějakým způsobem reagovat. Právě tento přístup k tvorbě programů se nazývá asynchronní programování.

Rozlišujeme dva typy asynchronních operací:

  • CPU Bound – Asynchronní operaci vykonává jiné vlákno našeho programu, může jít například o nějaký náročný dlouhotrvající výpočet.
  • IO Bound – Asynchronní operace běží mimo vlákno a procesor, např. čtení z disku, síťová komunikace.

U desktopových aplikací s uživatelským rozhraním (WinForm, WPF, UWP) se vyplatí oba typy asynchronních operací. Tyto aplikace mají zpravidla jedno vlákno, které se stará o vykreslení uživatelského rozhraní a jeho interakci s uživatelem – UI vlákno. Pokud tedy toto vlákno zablokujeme delším synchronním voláním, aplikace „zamrzne“ a nereaguje na činnost uživatele. Pokud použijeme asynchronní operaci, volání metody neblokuje UI vlákno a uživatelské rozhraní může dále interagovat s uživatelem. Po dokončení asynchronní operace pokračuje program opět na UI vlákně.

CPU bound a IO bound asynchronní operace v desktopové aplikaci
CPU bound a IO bound asynchronní operace v desktopové aplikaci

U některých typů aplikací není žádné „hlavní“ vlákno, které by nemělo být blokováno. To je třeba případ webových aplikací v ASP.NET Core nebo většiny konzolových aplikací. Zde najdou uplatnění zejména IO bound operace, které nám pomohou snížit počet vláken v aplikaci. V případě tohoto typu operací není žádná činnost prováděna na dalším vlákně vaší aplikace, ale čeká se na odpověď jiného systému (např. databáze). Volající vlákno odešle požadavek a dále se může věnovat jiné činnosti. Když operační systém zaznamená dokončení asynchronní operace, informuje o tom váš program a je spuštěn kód, který má následovat po jejím dokončení. Ani u tohoto typu aplikací vám samozřejmě nikdo nebrání použít i CPU bound operaci. Někdy se to hodí, ale je to rozhodně méně obvyklé.

Použití asynchronního volání u webových aplikací sníží počet potřebných vláken pro obsluhu HTTP požadavků. Zejména u ASP.NET Core se tím zlepší propustnost webového serveru. Webové aplikace velmi často provádí operace, které mohou být asynchronní a jsou typicky IO bound. Příkladem takové operace je práce s databází nebo volání webových služeb přes SOAP nebo REST. Standardně je každý HTTP požadavek na serveru obsluhován jedním vláknem. Pokud aplikace provede IO bound asynchronní operaci, uvolní se vlákno pro obsluhu dalšího HTTP požadavku. Po dokončení asynchronní operace pokračuje zpracování původního požadavku na jiném vlákně. Zpracování může pokračovat i na stejném vlákně, ale není to zaručeno a ani to není potřeba. Díky tomuto sdílení vláken mezi požadavky se sníží celkový počet vláken potřebných pro běh serveru.

IO bound asynchronní operace na webovém serveru
IO bound asynchronní operace na webovém serveru

Jak je patrné z předchozích odstavců, jednotlivé typy aplikací mají různé požadavky na to, jakým způsobem se spouští pokračování programu po dokončení asynchronní operace. Někdy nezáleží, jaké to bude vlákno, někdy to má být UI vlákno atd. Prozatím nám bude stačit fakt, že řešení asynchronního volání v C# je navrženo velmi robustně a dovoluje každému typu aplikace specifikovat jeho vlastní chování ohledně plánování pokračování programu. Později si tyto mechanismy vysvětlíme.

Asynchronní volání pomocí konstrukce async/await

Podpora asynchronního programování není v .NET žádná novinka. Dříve upřednostňované řešení bylo např. rozhraní IAsyncResult nebo event-based asynchronous pattern. V .NET 4.0 byla přidána Task Parallel Library (TPL), která je od té doby preferovanou možností pro asynchronní operace v .NET. V TPL reprezentuje asynchronní operaci instance třídy Task nebo Task<T>. Druhá varianta se použije v případě, že operace vrací návratovou hodnotu typu T. Následně v C# 5 přibyla konstrukce async/await, která velmi usnadňuje práci s  TPL, a díky tomu je asynchronní programování skoro stejně jednoduché, jako psaní synchronního kódu. Pojďme se nyní podívat, jak se s async/await pracuje:

public async Task<int> DoSomethingAsync(int argument)
{
    //asynchronní operace
    Data data = await databaseRepository.ReadDataAsync(argument);

    Data transformedData = transformData(data);

    //asynchronní operace
    await databaseRepository.SaveDataAsync(transformedData); 

    return data.Id;
}

 

Na příkladu vidíme ukázkovou metodu, která načte data z databáze a data nějakým způsobem upraví (v metodě transformData). Následně data uloží zpět do databáze a vrátí ID načtených dat. Pokud jste se s async/await ještě nesetkali, určitě vás zaujalo klíčové slovo async, operátor await a návratový typ Task<int>. Nejdříve se zaměříme na typ Task. Každá asynchronní operace v TPL je reprezentována instancí této třídy, přesněji jednou z jejích variant. Pokud asynchronní operace nevrací žádný výsledek, je jejím návratovým typem Task. Pokud nějakou návratovou hodnotu má, je jejím návratovým typem Task<T>, kde T je datový typ výsledku. Tato třída obsahuje informace o stavu asynchronní operace – zejména to jsou tyto:

  • zda už byla operace dokončena,
  • návratová hodnota operace,
  • případně výjimka, kterou asynchronní operace vyhodila.

Zvláště poslední jmenované je potřeba si uvědomit – pokud asynchronní operace vyhodí výjimku, je tato výjimka uschována do property objektu třídy Task.

Nyní se zaměříme na klíčové slovo async, které se nachází v deklaraci metody. Tímto klíčovým slovem sdělíme překladači, že se jedná o metodu obsahující alespoň jedno asynchronní volání. V našem příkladu jsou tam asynchronní volání dvě: ReadDataAsyncSaveDataAsync. Klíčové slovo async můžeme připsat i k metodě, která žádné asynchronní volání neobsahuje. Překladač se s tím vyrovná, ale bude vás upozorňovat, že je v deklaraci klíčové slovo async použito zbytečně. Pokud je u metody uvedeno async, překladač automaticky metodu přeloží tak, aby vracela objekt typu Task, který bude označen jako dokončený až po provedení celé metody. Všimněte si ještě řádku číslo 11. Zde se pomocí return vrací přímo ID, což může být třeba int. Obalení do objektu Task opět zajistí automaticky překladač.

Jako poslední k vysvětlení nám zbývá operátor await. Můžeme ho chápat jako unární operátor, který se umisťuje před objekt typu Task nebo Task<T>. Tento operátor zajistí čekání na dokončení operace reprezentované daným objektem. Později uvidíme, že await lze aplikovat i na jiné typy než Task, ale zatím si vystačíme pouze s Taskem.

Někdy se chybně uvádí, že await způsobí asynchronní volání. To ale není pravda. Podívejme se na řádek 4. Tento řádek se provede v následujícím pořadí:

  1. Zavolá se metoda ReadDataAsyncnad objektu databaseRepository. Tato metoda vrátí Task<T>, který představuje nedokončenou asynchronní operaci. Volání této metody probíhá synchronně až do prvního použití operátoru async uvnitř ReadDataAsync. Metoda ReadDataAsync tedy nevrátí načtená data, ale pouze objekt reprezentující běžící operaci, která data vrátí v budoucnu.
  2. Operátor await nyní zajistí, že program asynchronně počká na dokončení operace, kterou vrátilo volání metody ReadDataAsync.
  3. Po dokončení asynchronní operace přiřadí načtená data do proměnné data.

Jinými slovy operátor await počká na dokončení asynchronní operace, vybalí z objektu typu Task návratovou hodnotu, a pokud Task obsahuje výjimku, tak ji znovu vyhodí. Tento způsob práce s výjimkami zajistí, že i když výjimku vyhodí CPU bound asynchronní operace na jiném vlákně, bude možné ji zachytit i přímo v metodě, která asynchronní operaci spustila. Velkou výhodou této konstrukce je, že asynchronní volání lze obalovat try/catch nebo using bloky tak, jak jste zvyklí.

Možná si teď kladete otázku, co je na volání asynchronního, když hned po vytvoření asynchronní operace čekáte pomocí await na její výsledek. Odpověď je: await provádí čekání asynchronně. Zjednodušeně řečeno, v okamžiku, kdy je na Task použit operátor await, provádění metody se na vlákně ukončí a vlákno je k dispozici k vykonávání čehokoliv jiného (vykreslení UI, jiný HTTP požadavek atd.). Až když je operace dokončena, pokračuje se ve vykonávání zbývajícího kódu metody. V našem případě se provede přiřazení na řádku 4 a následně transformace na řádku 6 atd.

To, že await není přímo vázáno na volání metody, nám přináší další možnosti použití. Například můžeme operátor await použít později a provádět během čekání na dokončení asynchronní operace nějaký další kód v metodě. Podívejte se na následující příklad:

public async Task<int> DoSomethingAsync(int argument)
{
    Task<Data> dataTask = databaseRepository.ReadDataAsync(argument);
    DoSomethingElse();
    Data data = await dataTask;
    Data transformedData = TransformData(data);
    await databaseRepository.SaveDataAsync(transformedData);

    return data.Id;
}

 

Na řádku 3 zahájíme asynchronní operaci čtení z databáze. V tento okamžik nás ale nezajímá výsledek, protože ho ještě nepotřebujeme. Během doby, kdy databáze načítá data, vlákno provádí metodu DoSomethingElse. Až tuto metodu dokončí, podívá se díky operátoru await na stav asynchronní operace dataTask. Pokud již doběhla, vyzvedne její výsledek a přiřadí ho do proměnné data. Pokud operace ještě běží, asynchronně počká a provede přiřazení až po jejím dokončení.

Zde si ale musíme uvědomit jedno riziko. Pokud by metoda DoSomethingElse vyhodila výjimku, nikdy nedojde na await dataTask. Metoda doběhne, ale nikdo její výjimku nezachytí. Pokud už se do psaní takového kódu budete pouštět, zvažte například použití try/finally bloku, který by zajistil await i v případě výjimky.

Pro bližší pochopení, jak async/await funguje, si ukážeme předchozí příklad vytvořený pomocí TPL bez těchto klíčových slov. Příklad není pro jednoduchost zcela ekvivalentní, ale dělá zhruba to stejné. Liší se zejména ve zpracování výjimek, jejichž správné zpracování by kód velmi znepřehlednilo. Pro úplnost se ještě hodí upozornit, že překladač C# pro překlad metod s klíčovým slovem async používá jinou techniku, kterou si naznačíme až v příštím článku.

public Task<int> DoSomethingAsync(int argument)
{
    // proměnná pro uložení výsledku čtení z DB
    Data data = null;

    // získáme Task objekt reprezentující operaci čtení z DB
    Task<Data> dataTask = databaseRepository.ReadDataAsync(argument);

    // provedeme další činnosti, které chceme provést, než doběhne dataTask
    DoSomethingElse();

    // nastavíme, co se má stát, až dataTask doběhne
    Task<int> task = dataTask.ContinueWith(t =>
    {
        // načteme výsledek asynchronní operace
        data = t.Result;
        Data transformedData = TransformData(data);

        // vrátíme Task reprezentující uložení dat
        Task saveDataTask = return databaseRepository.SaveDataAsync(transformedData);
        return saveDataTask;
    })
    .Unwrap().ContinueWith(_ =>
    {
        // nastaví, co se má stát, až doběhne uložení dat
        return data.Id;
    });

    // vrátíme Task reprezentující celou asynchronní operaci
    return task;
}

 

Na příkladu snadno vidíme, že metoda DoSomethingAsync synchronně začne volání ReadDataAsync a následně provede DoSomethingElse. Poté už jen zkonstruuje objekt typu Task, který popisuje, co se má stát po dokončení asynchronní operace, a tento objekt vrátí volajícímu. Volající nad tímto objektem může použít operátor await nebo například metodu ContinueWith. Tyto přístupy lze v případě potřeby bez problému kombinovat, což je další pěkná vlastnost TPL a async/await. Nicméně, jistě uznáte, že ve většině případů je použití async/await výrazně čitelnější a méně náchylné na chyby.

Asynchronní volání synchronně

Na dokončení operace reprezentované objektem třídy Task lze čekat i synchronně. Někdy se to může hodit jako dočasné řešení při ladění. Nebo také pokud potřebujete použít asynchronní metodu v metodě, která není označena jako async a jeho zavedení by bylo příliš složité. Obecně to ale není dobrá praxe, protože tím ztratíte hlavní výhodu neblokující operace. Takové volání je výkonově náročnější, protože synchronní čekání na Task vyžaduje jistou režii se synchronizací. Navíc v některých případech může nastat deadlock – o tom si povíme více později. Většina dobrých knihoven nabízí jak synchronní, tak i asynchronní API (většinou mívají v názvu příponu Async). Pokud se ale synchronnímu čekání na dokončení Tasku nemůžete vyhnout, existuje několik možností, jak to udělat.

// pro Task s i návratovou hodnotou i bez ní
SomeOperationAsync().Wait();

// pro Tasky s návratovou hodnotou
var result = SomeOperationAsync().Result;

// pro Task návratovou hodnotou i bez ní
SomeOperationAsync().GetAwaiter().GetResult(); 

 

Jak je vidět z příkladu, nad každým objektem typu Task lze zavolat metodu Wait. Pokud Task ještě není dokončený, tato metoda zablokuje vlákno až do doby, než Task doběhne. Pokud bychom chtěli z instance třídy Task<T> získat návratovou hodnotu, nalezneme ji v property Result. Čtení z této property před doběhnutím asynchronní operace také způsobí blokování až do dokončení operace. Tyto dvě možnosti ale mají jednu nepříjemnou vlastnost. Pokud je v objektu Task zachycena výjimka, tak se jak při volání Wait, tak i při čtení z Result automaticky znovu vyhodí. Nicméně typ výjimky nebude ten, který byl vyhozen v asynchronní metodě, ale bude obalen v AggregateException. Je to proto, že třída Task je navržena velmi robustně i pro paralelní zpracování. Lze například vytvořit Task, který doběhne až v okamžiku, kdy doběhnou dvě paralelní asynchronní operace. Zájemci si mohou pro více podrobností najít metodu Task.WhenAll. Díky této vlastnosti může být v Tasku uloženo více výjimek z jednotlivých paralelních operací. Proto jsou obaleny speciálním typem, který obsahuje všechny tyto výjimky. Pokud na tento fakt v kódu zapomenete, může se vám snadno stát, že nějakou specifickou výjimku nezachytíte, protože byl vyhozen jiný typ, než jste očekávali.

Třetí možnost tento problém do jisté míry řeší. Pokud je v Tasku pouze jedna výjimka, je rozbalena z AggregateException a vyhozena. Důvodem je, že metoda GetAwaiter je navržena speciálně pro práci s async/await, kde se ve většině případů nestává, že by se slučovalo více Tasků. Async/await se tak chová z pohledu programátora logičtěji a používá AggregateException jen tam, kde musí. Více o awaiterech si povíme později.

Znovu ale apeluji: vyvarujte se blokování asynchronního kódu. V ideálním případě mějte asynchronní celou aplikaci. Vyhnete se tak spoustě nečekaných a zákeřných problémů.

A to je pro tento díl všechno. Příště se podíváme, jak to celé funguje pod kapotou.

 

Autor: Petr Haljuk

Fullstack developer