Tételezzük fel, hogy van egy grafikus alkalmazásunk, amiben van egy hosszú ideig futó kódrészlet. Ebben az esetben, amíg a kód fut, a programunk fő szála blokkolt állapotban van, arra sem jut ideje az alkalmazásnak, hogy érzékelje a felhasználói beavatkozásokat.
Ebben az esetben látszólag lefagyott állapotba kerül a program. A megoldás itt az lenne, hogy a műveletet elindítjuk külön szálon, majd ha végzett, akkor kiírjuk az eredményeket.
A szál indítása, majd a végén az eredmények feldolgozása az eddig tanultak alapján tetemes mennyiségű plusz kódot jelentene az alkalmazásunkban. A problémát felismerték a nyelv és a keretrendszer készítői is. Ennek kapcsán született meg az async, await kulcsszó páros.
Az aszinkron fejlesztés is egy olyan téma, amiről nagyon hosszú oldalakon keresztül lehetne beszélni. Valójában sokan nem értették a működését és a lényegét a megjelenésekor és a keretrendszer, illetve a nyelv is rendelkezett "fura" szabályokkal. Például a Main metódus nem lehetett async és nem lehetett await utasítást alkalmazni catch ágban. Ezek a limitációk a nyelv újabb verizóival megszüntek és végre, az async/await remekül használható.
Egy másik példa, ahol hasznos lehet az aszinkronitás kihasználása az az úgynevezett I/O bound műveletek. Itt az adott szálunknak egy lassú eszközre, például HDD-re vagy hálózatra kellene várnia. Ha az adott szálat blokkoljuk és sokáig várunk, addig a ThreadPool-nak esetleg elfogynak a szabad szálai és a kérések feltorlódnak, vagy költséges új thread-ek jönnek létre, amik esetleg ugyanígy nagymértékben csak blokkolódnának.
Erre adnak megoldást az aszinkron I/O műveletek. Ilyenkor az adott processzorszál felszabadul, csinálhat teljesen más feladatot, majd amikor az adott I/O művelet befejeződik, egy úgynevezett megszakítás váltódik ki hardveres szinten, aminek a hatására az operációs rendszer és a .NET környezet tudja, hogy a munka folytatódhat például egy másik ThreadPool szálon. Így egységnyi számítógép kapacitás akár nagyságrendekkel több feladatot tud elvégezni, hisz’ nincsenek feleslegesen létrejövő, várakozó szálak.
A async/await kulcsszavak a Task-okkal való munka egyszerűsítésére használhatóak. Az aszinkron kódot ebben az esetben nem callback eseményekkel/metódusokkal, vagy egymás után láncolt Task-okkal oldjuk meg. A kódot szinte ugyan úgy írjuk meg mintha az szekvenciális lenne, azzal a különbséggel, hogy ahol egy Task eredményére lenne szükségünk az await kulcsszót használjuk, míg a Task-kal visszatérő metódusban az async kulcsszót vesszük igénybe.
A keretrendszer is szállít metódusokat, amik használhatóak ilyen módon, ezek nevében konvenció szerint a metódus végén szerepel az Async kifejezés.
Amennyiben async metódussal dolgozunk, de nincs await a törzsben, az elemző szólni fog, hogy felesleges, de lefordul a kódunk. Fordított esetben nincs ilyen szerencsénk, az async nélküli await hívások fordítási hibát eredményeznek.
Nézzünk egy példát az async await kulcsszavak használatára.
using System;
using System.Threading.Tasks;
namespace PeldaAsyncawait
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Async előtt");
DoThingsAsync();
Console.WriteLine("Async után");
Console.ReadKey();
}
private static async Task PrintCurrentTimeAsync()
{
Console.WriteLine(DateTime.Now);
await Task.Delay(2000);
Console.WriteLine(DateTime.Now);
}
private async static void DoThingsAsync()
{
await PrintCurrentTimeAsync();
}
}
}
A program kimenete:
Async elott
1. 01. 07. 10:06:32
Async után
1. 01. 07. 10:06:34
A programkóból látható, hogy az async kulcsszó a hívó félhez megy, az await pedig a hívotthoz. A kimenet alapján viszont nem teljesen azt kapjuk amire számítanánk. Az Async után sor beékelődik a vége dátum kiírás elé. Ez annak köszönhető, hogy a DoThingsAsync(); metódus void visszatérési értékkel rendelkezik, amire nem tudunk várni és nem is tudjuk vele használni az await kulcsszót.
Ha megpróbáljuk await kulcsszóval használni a void típusú metódust, akkor a fordító azt fogja mondani, hogy azt márpedig nem tehetjük meg, ami logikus: hogyan is várakozhatnánk a visszatérési értékre visszatérési érték nélkül?
Az async kulcsszó használata azonban megengedett. Az async void metódusokban elindított Task elindul és majd egyszer végez. Az ilyen metódusok használata azért kerülendő, mert arról, hogy mikor végeznek nem kapunk alap esetben értesítést az await hiányában. Éppen ezért a javaslat, hogy az async void metódusok használata a végsőkig kerülendő.
Felmerülhet a kérdés, hogy ha ennyire káros az async használata a void kulcsszóval, akkor egyáltalán miért lehetséges? Vannak speciális esetek, amikor másként nem tudjuk megoldani a problémát. Ilyen például egy eseménykezelő metódusa és C# 7.1 előtt konzol alkalmazások esetén a Main metódus.
A fenti példában a Main metódus miatt szükségünk van a DoThingsAsync() metódusra, mivel az async használata a Main előtt nem engedélyezett kulcsszó, ezért fordítási hibát eredményez. A C# 7.1 bővítette a Main metódus elfogadott, fordítható szignatúráit. Ez lehetővé teszi azt, hogy a Main metódus egy Task legyen.
C# 7.1 óta alkalmazható Main metódus szignatúrák:
public static void Main();
public static int Main();
public static void Main(string[] args);
public static int Main(string[] args);
public static Task Main();
public static Task<int> Main();
public static Task Main(string[] args);
public static Task<int> Main(string[] args);
A Korábbi példakód C# 7.1 utáni változata:
using System;
using System.Threading.Tasks;
namespace PeldaAsyncawaitTaskMain
{
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine("Async előtt");
await PrintCurrentTimeAsync();
Console.WriteLine("Async után");
Console.ReadKey();
}
private static async Task PrintCurrentTimeAsync()
{
Console.WriteLine(DateTime.Now);
await Task.Delay(2000);
Console.WriteLine(DateTime.Now);
}
}
}
A program kimenete:
Async elott
2021. 01. 07. 10:24:56
2021. 01. 07. 10:24:58
Async után
Mint látható, ebben az esetben a program a megfelelő és elvárt sorrendben írja ki az üzeneteket. Ha C# 7.1 előtti változatot használunk, akkor is lehetőségünk van arra, hogy megfelelően működjön a programunk. Az alábbi kód funkcionálisan megegyezik a Task-ot használó Main metódusos megoldással:
using System;
using System.Threading.Tasks;
namespace PeldaAsyncawaitAsycAction
{
class Program
{
static void Main(string[] args)
{
//A task magja így egy async Action delegate.
Task.Run(async () =>
{
Console.WriteLine("Async előtt");
await PrintCurrentTimeAsync();
Console.WriteLine("Async után");
});
Console.ReadKey();
}
private static async Task PrintCurrentTimeAsync()
{
Console.WriteLine(DateTime.Now);
await Task.Delay(2000);
Console.WriteLine(DateTime.Now);
}
}
}
Aszinkron tippek és trükkök
Az aszinkron metódusosok használatánál van néhány ajánlás, amit érdemes betartani és van pár trükk, amit alkalmazhatunk.
Az első ajánlás a Task típusú metódusokra vonatkozna. Az ilyen műveleteket ajánlás szerint Async végződéssel kellene ellátni. Például, ha a megvalósított művelet a DownloadFiles, akkor a helyes neve DownloadFilesAsync lenne.
Az ilyen metódusok esetén a másik fontos ajánlás, hogy a metódusok utolsó paramétere egy opcionális CancellationToken legyen, amit ha megad a programozó, akkor a metódus meg tudja szakítani a futását. Fontos, hogy ne csak a szignatúrában helyezkedjen el a token, időközönként ellenőrizzük a metódusban az eredményét (főleg ciklusokban), hogy ténylegesen meg tudjon szakadni egy folyamatban lévő művelet.
public async Task DoSomething(CancellationToken token = default)
{
//hosszú ideig futó ciklus
for (int i=0; i<200000; i++)
{
token.ThrowIfCancellationRequested(); // token ellenőrzése
await Task.Delay(100, token); //token továbbadása, ha van rá lehetőség
}
}
Kerüljük az async void használatát
Az async void nyelvi elem egy nagyon specifikus probléma megoldására lett bevezetve a nyelvbe. Ez a specifikus probléma pedig az eseménykezelő metódus.
Előfordulhat, hogy eseménykezelőből kell egy Task-ot elindítanunk. Ebben az esetben teljesen jó megoldás, hogy async void módon deklaráljuk az eseménykezelő metódust.
Ettől eltekintve azonban minden más esetben az async void használata kerülendő. Ennek az oka az, hogy a void a típus nélküli típus, ami esetén nincs lehetőség async-al együtt használva a keletkezett kivételek rendes kezelésére.
Nézzünk egy példát:
private async void ThrowExceptionAsync()
{
throw new InvalidOperationException();
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
try
{
ThrowExceptionAsync();
}
catch (Exception)
{
// A kivétel nem kerül elkapásra
throw;
}
}
A fenti kódrészletben azt várnánk, hogy a kivételkezelő elkapja a kivételt. Azonban, mivel async void metódusban dobódik, a kivételt nem tudja becsomagolni egy Task osztály sem, hogy majd az eredeti hívási helyen lekezelhető legyen.
Ez azt eredményezi, hogy a nem kezelt kivétel miatt az alkalmazásunk össze fog omlani. Az ilyen jellegű hibák megtalálása nem triviális egy nagyobb alkalmazás esetén, ezért mindenképpen kerüljük a használatát.
Async végig
Aszinkron és szinkron kódot ne keverjünk együtt, mert könnyen deadlock lehet a dolog vége. Ennek az az oka, hogy amikor egy Task-ot elindítunk, akkor a háttérben az eredeti hívási kontext elkapásra kerül és majd csak akkor folytatódik ott a futás, ha a Task végzett. Azonban ez az elkapás nem azonnal történik, valamennyi késleltetése mindig lesz, ami elég arra, hogy összeakadások legyenek.
Tipikusan ilyen összeakadást az async Task-ban alkalmazott Task.Wait és Task.Result hívások tudnak eredményezni. Lehetőleg ne keverjük a szinkron és aszinkron várakozási módokat. Az alábbi kód WPF esetén egy deadlock-ot eredményez:
private async Task DelayAsync()
{
await Task.Delay(1000);
}
public void Test()
{
//deadlock, mert nem awit-tel van bevárva a vége
var delayTask = DelayAsync();
delayTask.Wait();
}
A ConfigureAwait metódus
A Task és az async/await kulcsszó párok esetén előbb-utóbb találkozunk a ConfigureAwait() metódussal, aminek a használatát erősen ajánlják a különböző statikus kódanalizátorok. De mikor is kell ezt ténylegesen használni és mit csinál?
A ConfigureAwait() használata akkor releváns, ha azawait kulcsszóval várunk egy Task eredményére és ez a kód egy osztálykönyvtárban helyezkedik el.
A Task-ok esetén van egy SynchronizationContext [^1] példány alapértelmezetten beállítva, ami a szálak közötti szinkronizációt és ütemezést befolyásolja.
Az alapértelmezett implementáció arra a szálra téríti vissza az elkészült Task-ok eredményét, mint ami indította. Ez a viselkedés alapértelmezett WPF, ASP.NET és WinRT esetén is, mivel bizonyos részei ezeknek a keretrendszereknek csak a központi fő szálról érhetőek el. Ha egy szál nem ide térne vissza, akkor az kivételhez vagy akár deadlock kialakulásához is vezethet.
Ezt a viselkedést befolyásolja a ConfigureAwait() metódus, amit az await kulcsszóval indított Task-on tudunk meghívni. Ha ezt false értékkel hívjuk meg, akkor az azt jelzi a keretrendszernek, hogy a hívásnak nem kell visszatérnie a fő szálra a feladat végeztével, folytatódhat a vezérlés további Task-on is.
Itt felmerülhet, hogy ennek mégis mi értelme, ha UI alkalmazások esetén mindig a fő szálra kell visszatérni? A helyzet az, hogy nem mindig kell oda visszatérni. Nézzünk pár példát. Az alábbi példa tipikusan előfordulhat egy UI alkalmazásban:
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
// Itt nem szabad ConfigureAwait(false)-ot hívni
// mivel a finally blokkban kell folytatódnia a vezérlésnek mindenképp
await Task.Delay(1000);
}
finally
{
button1.Enabled = true;
}
}
A fenti kódban nem alkalmazható a ConfigureAwait, mert a Task futtatása után a gomb állapotának a módosítása csak a fő szálon történhet, ezért mindenképpen oda kell visszatérnünk.
De ha az await-el várt Task további Task-okat hoz létre, akkor abban már alkalmazhatjuk, sőt előnyös is, mert a fő szálra visszatérés szinkronizációja mindig költséges művelet.
private async Task HandleClickAsync()
{
// Itt használható
await Task.Delay(1000).ConfigureAwait(false);
await Task.Delay(1400).ConfigureAwait(false);
}
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
// Itt még mindig nem alkalmazható
await HandleClickAsync();
}
finally
{
button1.Enabled = true;
}
}
Lényegében ennek a költségnek a megspórolására találták ki ezt a metódust. Ha osztálykönyvtárat készítünk, ami Task formájában publikál ki metódusokat, akkor a kipublikált metódusaink belsejében alkalmazzuk a ConfigureAwait metódust false paraméterrel, abban az esetben, ha nincs kontextusfüggő műveletünk. Ezzel gyorsítani tudjuk a műveletek futásának sebességét.