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

Asynchronní programování v C# a .NET je velmi mocný nástroj. A jako každý jiný nástroj se i tento dá špatně použít. Díky tomu si můžete do programu zanést těžko odladitelné chyby. V poslední části série článků se tedy podíváme na nejčastější chyby, které lze s async/await udělat.

Zapomenuté await

Co se stane, pokud zapomenete před volání asynchronní metody napsat operátor await? Metoda se normálně spustí a vrátí nedokončený Task. Nikdo ale nebude čekat, až se Task dokončí, a program poběží dál. Nikdo také nebude reagovat na případné výjimky v asynchronní operaci. Visual Studio a většina dalších IDE vás na tuto chybu upozorní pomocí varování, ale i tak vás tato chyba může pěkně potrápit. Zejména pokud provádíte rozsáhlejší refaktoring kódu, při kterém zavádíte asynchronní metody na místa, která byla dosud synchronní.

public void SomeMethod()
{
    DoSomething();
    DoSomethingAsync(); // tato metoda vrací Task, ale nikdo se o něj nestará
    DoSomethingElse();
}

 

Await na nevhodném místě

Někdy se stává, že programátor chce ušetřit zbytečné klíčové slovo async a s tím spojenou zbytečnou režii okolo překladu asynchronní metody. To udělá tak, že vrací přímo Task jiné asynchronní metody. To ještě nemusí být špatně, protože pokud metoda nepotřebuje čekat na výsledek asynchronní metody, lze to tímto způsobem napsat.

Problém nastává v okamžiku, kdy volání obalíte using nebo try/catch blokem. Podívejte se na následující příklad:

// špatně
public Task DoSomethingAsync()
{
    try
    {
        return DoSomethingElseAsync();
    }
    catch
    {
        // zpracování výjimky
    }
}

// dobře
public async Task DoSomethingAsync()
{
    try
    {
        await DoSomethingElseAsync();
    }
    catch
    {
        // zpracování výjimky
    }
}

 

V tomto případě se dobře míněná snaha o optimalizaci nepovedla. V try bloku je totiž pouze začátek volání asynchronní metody, a blok tak končí dříve, než skončí celá asynchronní operace DoSomethingElseAsync. Pokud by tedy DoSomethingElseAsync vyhodila výjimku, nemusí ji try zachytit. Výjimka totiž mohla být vyhozena až v další části metody, která byla spuštěna dávno po tom, co program vyskočil z try/catch bloku.

Sami zvažte, jestli má ve vašem projektu smysl takovou optimalizaci provádět. Ve většině případů příliš neušetříte. Navíc riskujete, že pokud bude někdo metodu rozšiřovat, přehlédne chybějící async/await, a nastane problémová situace jako v příkladu. To může způsobit zbytečné vážnější problémy.

Deadlock

Deadlock je strašákem každého kódu, který provádí synchronizaci mezi více vlákny. Jak jsme si ukázali, i asynchronní volání pomocí async/await může takovou synchronizaci provádět, pokud je pro dané vlákno definován SynchronizationContext. Pokud smícháme SynchronizationContext a blokující volání asynchronní metody, můžeme snadno způsobit deadlock tak, jak ukazuje následující příklad.

// špatně
private void button1_Click(object sender, EventArgs e)
{
    SomeMethodAsync().Wait(); // zablokuje UI vlákno
}

private async Task SomeMethodAsync()
{
    await Task.Delay(500);
    // pokračování metody se díky SynchronizationContextu spouští na UI vláknu, které se odblokuje až po skončení AsyncMethod = Deadlock
    MessageBox.Show("Message from SomeMethodAsync");
}

// méně špatně
private void button1_Click(object sender, EventArgs e)
{
    SomeMethodAsync().Wait(); // zablokuje UI vlákno
}

private async Task SomeMethodAsync()
{
    await Task.Delay(500).ConfigureAwait(false);
    // pokračování metody ignoruje SynchronizationContext a naplánuje se na vlákno z ThreadPoolu = nevznikne deadlock
    MessageBox.Show("Message from SomeMethodAsync");
}

// dobře
private async void button1_Click(object sender, EventArgs e)
{
    try
    {
        await SomeMethodAsync(); // nebudeme blokovat UI vlákno
    }
    catch
    {
        // zalogování chyby
    }
}

private async Task SomeMethodAsync()
{
    // Pokud bychom chtěli, aby se pokračování metody naplánovalo na UI vlákno, vynecháme ConfigureAwait(false)
    // To se může hodit, pokud v pokračování pracujeme s nějakým UI prvkem, ke kterému může přistupovat pouze UI vlákno
    await Task.Delay(500).ConfigureAwait(false);
    MessageBox.Show("Message from SomeMethodAsync");
}

 

Dobré pravidlo, jak deadlocku předcházet, je nepoužívat blokující volání asynchronních metod alespoň tam, kde hrozí použití SynchronizationContextu. Nejlepší je samozřejmě blokující volání nepoužívat vůbec, ale ne vždy je to možné. Zmíněné pravidlo se velmi špatně dodržuje u knihoven, kdy netušíte, v jakém typu aplikace bude váš kód spuštěn. Proto se doporučuje využívat u knihoven vždy ConfigureAwaiter(false), který zabrání plánování částí asynchronních metod z vaší knihovny přes SynchronizationContext.

Žádný await pro asynchronní lambda metodu

Toto je poměrně závažná chyba, protože ji lze v kódu velmi snadno přehlédnout a může napáchat mnoho nečekaného. Jazyk C# umožnuje zapsat jako asynchronní i lambda metodu. Bohužel i lambda metodu bez návratové hodnoty (například Action). Taková lambda metoda je pak ekvivalentem asynchronní metody s návratovým typem void. To znamená žádný Task, na který by se dalo čekat a případně převzít vyhozenou výjimku. Na příkladu je demonstrována metoda ForEach, která pro každý prvek kolekce provede zadanou lambda metodu. Tato lambda je označena jako asynchronní a vše vypadá v pořádku. Bohužel, pro tyto asynchronní lambdy není nikde žádný await. Jednotlivá volání se tedy spustí paralelně a nikoho již nezajímá, jestli vůbec doběhnou.

// špatně
public async Task SomeMethodAsync()
{
    List<Data> data = await GetData();

    data.ForEach(async item =>
    {
        await DoSomethingAsync(item);
    });
}

// dobře
public async Task SomeMethodAsync()
{
    List<Data> data = await GetData();

    foreach (var item in data)
    {
        await DoSomethingAsync(item);
    }
}

// dobře s využitím vlastní extension metody
public async Task SomeMethodAsync()
{
    List<Data> data = await GetData();

    await data.ForEachAsync(async item =>
    {
        await DoSomethingAsync(item);
    });
}

// extension metoda pro třetí příklad
public static async Task ForEachAsync<T>(this IEnumerable<T> enumerable, Func<T, Task> asyncAction)
{
    foreach (var item in enumerable)
    {
        await asyncAction(item);
    }
}

 

Správnější řešení je v tomto případě použít cyklus foreach, a vyhnout se tak asynchronní lambda metodě. Existují ale případy, kdy zkrátka asynchronní lambdu potřebujete. Delegát pro lambda metodu by v tomto případě vždy měl mít návratový typ Task nebo Task<T>. K tomu můžete využít například lambdu typu Func<Task, T>. Pro každé volání tak lze použít operátor await a metoda vrací Task, který umožní čekat na provolání jednotlivých asynchronních lambda metod. Tento přístup je demonstrován na konci příkladu v extension metodě.

Nepoužívání ConfigureAwait v knihovnách

Jak již bylo uvedeno výše, v kódu knihoven se pro asynchronní operace doporučuje pro každý await použít ConfigureAwaiter(false). Tím zajistíme, že náš kód nezpůsobí deadlock, když je knihovna použita v aplikaci využívající SynchronizationContext.

Mimo deadlock existuje ještě jeden důvod, proč v knihovnách SynchronizationContext ignorovat. Ukážeme si to na příkladu desktopové aplikace. Tento problém ale platí v jakékoli aplikaci používající synchronizační kontext. Pokud nebudeme kontext ignorovat, je každé pokračování po dokončení asynchronního volání synchronizováno zpět na UI vlákno. Jenže UI vlákno neprovede pokračování ihned, ale až dokončí to, co mělo aktuálně rozdělané. Kód vaší knihovny tak může po každém asynchronním voláním čekat na synchronizaci, kterou zpravidla nepotřebuje. Pokud ConfigureAwaiter(false) použijete, běží všechna pokračování vaší metody tam, kam to naplánoval TaskScheduler. To je ve většině případů jedno z vláken ThreadPoolu. Díky tomu není UI vlákno zbytečně zatěžováno.

Aplikace, která metodu vaší knihovny volala, ConfigureAwaiter(false) nepoužije, a tak se pokračování po celém dokončení asynchronní metody opět naplánuje na UI vlákno. Tam už je to ale chtěné chování.

volání metody knihovny
Zbytečně mnoho synchronizací na UI vlákno při volání asynchronní metody z knihovny

 

volání metody knihovny
Minimální počet synchronizací na UI vlákno při volání asynchronní metody z knihovny

Nepoužívání Task.WhenAll při paralelním volání

Toto není přímo chyba, ale stojí za to si ukázat, jaké důsledky má takový způsob zápisu. Na příkladu voláme dvě asynchronní metody paralelně. Obě se spustí a teprve potom čekáme na dokončení první a následně druhé. Co se ale stane, pokud DoSomethingAsync vyhodí výjimku? Výjimka se uloží do task1 a první await ji z Tasku vybalí a vyhodí ji dále. Tím vykonávání metody končí. Na await task2 nikdy nedojde. Pokud tedy task2 obsahuje výjimku, nebude nikdy vyhozena a zůstane nezachycena.

// horší varianta
public async Task SomeMethodAsync()
{
    var task1 = DoSomethingAsync();
    var task2 = DoSomethingElseAsync();

    await task1;
    await task2;
}

// lepší varianta
public async Task SomeMethodAsync()
{
    var task1 = DoSomethingAsync();
    var task2 = DoSomethingElseAsync();

    await Task.WhenAll(task1, task2);
}

 

Určitě se ptáte, co se stane, pokud výjimka v Task zůstane nezachycena, a jestli to znamená, že nebude ani zalogována. Nemusí to tak být. Pokud je v době mazání objektu třídy Task z paměti zjištěno, že výjimka nebyla vyzvednuta, je „odpálena“ událost TaskScheduler.UnobservedTaskException, kde máte poslední šanci výjimku zalogovat.

Je tedy dobrá praxe tuto událost používat – vyhnete se tím chybám aplikace bez zápisu do logu. Pro úplnost se ještě hodí dodat, že starší verze .NET Frameworku způsobovaly pád celé aplikace v případě, že výjimka nebyla zpracována ani přes TaskScheduler.UnobservedTaskException. U webové aplikace to mělo za následek nedostupnost celé aplikace, dokud IIS web znovu nespustil.

Závěr

Jak vidíte, asynchronní programování může přinést mnohé benefity. Pokud se ale ponoříme hlouběji, objevíme i jistá rizika jeho používání. Doufám, že i díky tomuto článku budete schopni těmto rizikům více předcházet a využijete výhody asynchronního programování na maximum. Jedná se zcela jistě o cestu, kterou se bude vývoj jazyka C# dále ubírat, a možnosti asynchronního programování budou dále rozšiřovány. C# a .NET obsahují spoustu dalších nástrojů pracujících s asyc/await, které stojí za to prozkoumat. Namátkou uveďme například rozhraní IAsyncDisposable nebo IAsyncEnumerable se související konstrukci awaitforeach. Zajímavé je také využití struktury CancellationToken nebo třídy Progress. To ale zase někdy příště. Šťastné asynchronní programování všem!

 

Autor: Petr Haljuk

Fullstack developer