Nedávno jsem došel k názoru, že je dost lidí (čti vývojářů), kteří ještě úplně přesně nechápou princip a způsob vykonávání metod zapisovaných pomoci nové syntaxe async/await z jazyka C# 5.0. Tento článek je proto určen hlavně pro ně. Doufám, že se mi v něm podaří tuto problematiku alespoň trochu objasnit.
V tomto článku si na velmi jednoduchém příkladu vysvětlíme princip vykonávání kódu používající novou syntaxi jazyka C# 5.0 async/await.
Tuto syntaxi lze asi nejčastěji využít v aplikacích s grafickým rozhraním na některé ze XAML-based platformě:
- WPF a .NET Framework 4.5
- Silverlight 5.0 s Async Targeting Pack
- Windows Store Apps a WinRT (aplikace pro Windows 8 “Modern UI”)
- Windows Phone 8
Mějme metodu provádějící nějaký velmi náročný výpočet:
private int Compute()
{
System.Threading.Thread.Sleep(3000);
return 42;
}
A protože je tento výpočet tak náročný, možná by bylo dobré mít k dispozici také asynchronní verzi této metody:
private Task<int> ComputeAsync()
{
return Task.Run(() =>
{
System.Threading.Thread.Sleep(3000);
return 42;
});
}
Jakým způsobem metoda dokáže to, že je asynchronní, nám v mnoha případech může zůstat uvnitř metody skryto (1). Důležité je však to, že ze signatury metody resp. přímo toho, že její návratová hodnota je typu Task nebo Task<T> poznáme, že je jedná o asynchronní metodu (2).
(Datový typ Task je nyní v .NETu primárním datovým typem reprezentující asynchronní operace s tím, že všechny dřívější reprezentace asynchronních operací jsou na tuto reprezentaci principiálně převeditelné.)
Zde se v tomto konkrétním případě jedná o task/úlohu, která po svém dokončení vrací hodnotu typu int a dosahuje se toho obalením synchronního kódu pomoci Task.Run(() => … ) (3), což způsobí jeho vykonávání na threadpool.
Všimněte si také, že jsme vytvořili asynchronní metodu, aniž by bylo nutné nějaké async nebo await použít a to tak, že vracíme (nějakým způsobem sestrojený) task pomoci obyčejného return. Jak doufám hned uvidíme dále, async/await je “pouze” pomůcka pro umožnění jiného způsobu zápisu vlastní implementace těla asynchronní metody (4). Opět to jestli je tělo asynchronní metody zapsané pomoci async/await nebo ne je její implementační detail.
Dále mějme kód vlastní aplikace (aplikace má velmi jednoduché grafické rozhraní s prvky Button, ProgressBar a TextBlock):
private async void Button_Click(object sender, RoutedEventArgs e)
{
this.IsEnabled = false;
progress.IsIndeterminate = true;
try
{
await RunComputeAndShowResults();
}
finally
{
this.IsEnabled = true;
progress.IsIndeterminate = false;
}
}
private async Task RunComputeAndShowResults()
{
textBlock.Text = "Running...";
//Async computation
int result = await ComputeAsync();
//Update UI
textBlock.Text = result.ToString();
}
Funkčnost celé aplikace je velice jednoduchá. Tlačítko spouští výše uvedený asynchronní výpočet, po jehož dokončení je výsledek zobrazen v TextBlocku. Kromě toho jsou během provádění výpočtu prvky formuláře disablované, je zobrazena indikace průběhu a text “Running…”.
Ještě než přistoupíme k vysvětlení způsobu vykonávání kódu příkladu, ve stručnosti shrnu hlavní pravidla týkající se použitých nových klíčových slov:
- async – Tímto klíčovým slovem označíme v deklaraci metodu, v jejímž těle budeme chtít alespoň jednou použít klíčové slovo await čímž aktuální metodu budeme implementovat jako kompozici/orchestraci běhu asynchronního workflow sestavenou z volání jiných existujících asynchronních funkcí.
Tím změníme způsob jejího překladu do stavového automatu, který bude zajišťovat spouštění částí kódu za a mezi jednotlivými await jako callback (continuation) jednotlivých asynchronních volání.
Metoda označená klíčovým slovem async musí vracet typ Task, Task<T> nebo případně void.
V metodě označené klíčovým slovem async příkaz return určuje okamžik dokončení asynchronní úlohy a v případě návratového typu Task<T> se za něj uvádí pouze výraz typu T – výstup z asynchronní metody.
- await – Toto klíčové slovo uvedeme před asynchronní volání, kdy chceme, aby se další běh kódu umístěného za await naplánoval (jako continuation) a spustil až po dokončení volané operace. V důsledku toho jsou v kódu za await již k dispozici výsledky prováděného asynchronního volání (tj. v případě metody vracející Task<T> je k dispozici hodnota typu T).
Klíčové slovo await lze uvést pouze před výraz typu Task nebo Task<T> (5).
Pomocná metoda RunComputeAndShowResults je asynchronní, vrací typ Task, protože přestavuje úlohu, která nemá po svém dokončení žádnou návratovou hodnotu, ale přitom chceme umět na okamžik jejího dokončení reagovat. Metoda je implementována pomoci nového způsobu async/await, kde klíčové slovo await uvádíme před voláním vlastního výpočtu metodou ComputeAsync(), protože teprve po jeho dokončení chceme provést aktualizaci uživatelského rozhraní pro zobrazení výsledku výpočtu.
Metoda Button_Click (event handler prvku Button) sice také používá způsob implementace pomoci async/await, ale vrací pouze void, takže se nejedná o asynchronní metodu v tom smyslu, že by na její dokončení šlo nějak reagovat (také nejde). Toto je přesto naprosto validní postup v případě, kdy nám stačí pouze umět voláním metody spouštět asynchronního workflow, které je s výhodou implementované pomoci async/await v jejím těle (top level funkce). Zde opět pomoci await provádíme nějaké akce až po dokončení běhu asynchronní metody RunComputeAndShowResults (nikoliv po dokončení jejího volání – volání například
var t = RunComputeAndShowResults(); by skončilo ihned a vrátilo by v daném čase spuštěný, ale ještě nedokončený task).
Jaká je tedy v našem příkladu posloupnost a průběh jednotlivých akcí, které se dějí po stisku tlačítka na formuláři?
- Vyvolá se procedura event handleru Button_Click
- Synchronně se provedou akce před (prvním) await (prvky formuláře se disablují a zobrazí se indikace průběhu) a zavolá se metoda RunComputeAndShowResults.
- Synchronně se pokračuje prováděním akcí před await v těle metody RunComputeAndShowResults (zobrazí se nápis “Running…”) (6), potom se zavolá metoda ComputeAsync.
- Metoda ComputeAsync spustí výpočet na pomocném vlákně na threadpool a ihned vrátí objekt Task<int> reprezentující tento asynchronně spuštěný a běžící výpočet.
- Metoda RunComputeAndShowResults převezme objekt vrácený metodou ComputeAsync a nastaví spuštění kódu za await (to je realizováno pomoci stavového automatu) jako callback/continuation tohoto tasku (7). Dále vrátí objekt Task reprezentující asynchronně spuštěnou a běžící úlohu [výpočet + zpracování jeho výsledků].
- Metoda Button_Click převezme objekt vrácený metodou RunComputeAndShowResults a opět nastaví spuštění další části metody za await jako callback/continuation vracenému tasku.
- Běh metody Button_Click končí (a protože tato metoda je typu void, není objekt reprezentující celou úlohu nikam vrácen).
Všechny akce až do tohoto okamžiku proběhli najednou a synchronně. Hlavní vlákno není v tomto okamžiku ničím blokováno a může zpracovávat libovolné jiné události. Na threadpool stále běží spuštěný asynchronní výpočet.
- V nějakém čase později dojde v pomocném vláknu na threadpool k dokončení výpočtu.
- Výsledek výpočtu je nastaven do objektu Task<int> reprezentujícího úlohu tohoto výpočtu a je vyvolán callback, který byl této úloze dříve nastaven, tj. zbytek metody RunComputeAndShowResults. Tento callback je ve výchozím chování vyvolán na původním synchronizačním kontextu (na kterém bylo prováděno volání asynchronní metody) tj. v našem případě na UI (main) vlákně.
- Provede se zbytek metody RunComputeAndShowResults (zobrazení výsledku výpočtu). Přitom je k dispozici výsledek již dokončeného výpočtu a protože jsme na UI vlákně není aktualizace UI problém.
- Objektu Task reprezentující úlohu [výpočet + zpracování jeho výsledků] je signalizováno jeho dokončení a je opět vyvolán jemu dříve nastavený callback tj. zbytek metody Button_Click.
- Provede se zbytek metody Button_Click (enablování prvků formuláře a skrytí indikace průběhu).
Tím jsou všechny akce dokončeny.
Pokud by tělo některé metody používalo klíčové slovo await vícekrát, princip bude stejný, jen přerušení zpracování a vrácení kontroly hlavnímu vláknu se provede v celém běhu spuštěných akcí několikrát (pro každé volání dílčí asynchronní metody).
(1) Je to implementační detail dané metody. Obecně se může jednat například o procesorově náročný výpočet běžící na thread pool/worker threadu, paralelní výpočet běžící na několika threadech (procesorech), asynchronní volání WCF nebo jiné služby nebo cokoliv jiného.
(2) I když ve speciálních případech (v závislosti na vstupních parametrech, stavu objektu, výjimečně vždy) může taková metoda proběhnout celá synchronně a vrátit již dokončený Task nebo Task<T>.
(3) Helper metoda Run je zkratka pro Task.Factory.StartNew(() => … ) z TPL.
(4) Je to naprosto analogické jako u iterátorů z C# 2.0. Tam také implementaci metody vracející nějakou sekvenci (IEnumerable<T>) můžeme zapsat buď jako iterátor blok (případ async/await), ale také například jen vrátit vytvořený LINQ dotaz (případ vrácení objektu typu Task).
(5) Případně jiný tzv. “awaitable” objekt tj. objekt implementující metodu GetAwaiter().
(6) Pokud by jsme na tomto místě volali například metodu Compute():
textBlock.Text = "Running...";
//Sync computation
int result = Compute(); //<--Blocks UI (Main) thread
//Async computation
result = await ComputeAsync();
bude výpočet prováděn synchronně na UI (main) vlákně aplikace tj. hlavní vlákno bude po celou dobu provádění výpočtu zablokováno.
(7) Pokud by v tento moment (v našem případě tomu tak ale není) vracený objekt task signalizoval, že reprezentovaná úloha je již dokončena, nastavení callbacku by se neprovádělo, ale místo toho by se rovnou (synchronně s předcházejícími akcemi) pokračovalo zpracováváním kódu za await. Tato “optimalizace” se označuje jako tzv. “fast path”.