Pułapki asynchroniczności w C#

sages.pl 2 lat temu
Asynchroniczność nie jest nowym tematem. Istnieje z nami od lat. Z czasem wiele się zmieniło w podejściu do niej. Współczesne języki programowania znacząco ułatwiają posługiwanie się nią. Dla C# jedną z najważniejszych zmian było wprowadzenie słów kluczowych async i await. Od tego momentu asynchroniczność jest bardzo powszechnie wykorzystywana w projektach .Net. Jednak ta łatwość bywa zwodnicza. Można napisać kod działający przez większość czasu, ale sporadycznie generujący trudne do zdiagnozowania błędy. Dzięki lekturze tego tekstu dowiesz się, dlaczego warto korzystać z async/await. Przyjrzymy się też jednemu z częściej popełnianych błędów przy pisaniu asynchronicznego kodu. Co pozwoli Ci uniknąć przykrych niespodzianek przy pracy i żmudnego debugowania aplikacji. A zaoszczędzony czas zawsze można spożytkować na coś przyjemniejszego.


# Po co stosować async/await?
Większość programistów zaczyna przygodę z kodem, pisząc aplikacje synchroniczne. w uproszczeniu oznacza to, iż każda kolejna linijka kodu jest wykonywana po zakończeniu poprzedniej. Niewątpliwą zaletą tego rozwiązania jest prostota. Taki kod jest dużo łatwiej czytać i analizować, ale jak to w życiu bywa, zawsze jest coś, za coś. W tym przypadku po drugiej stronie wagi znajduje się wydajność aplikacji. Pisząc kod asynchroniczny, można dużo zyskać w tym temacie. Sprowadzając to do prostego życiowego przykładu - chcemy zrobić śniadanie. W synchronicznym świecie zaczęlibyśmy od zaparzenia kawy, następnie przygotowania kanapek. Przy bliższym spojrzeniu na proces okazuje się, iż sporo czasu zajęło oczekiwanie, aż zagotuje się woda na małą czarną. Dzięki asynchroniczności można wykorzystać ten czas na wykonanie innych zadań. W powyższym przykładzie: na robienie kanapek. W świecie programowania analogicznie można wykorzystać oczekiwania na pobranie pliku, czy wykonanie skomplikowanego zapytania do bazy danych. Bardzo prosty kod z wykorzystaniem wspomnianych narzędzi wygląda tak:


```
public static async Task Main(string[] args)
{
await Task.Delay(500);
System.Console.WriteLine("Hello, async world!");
}
```


Dwie najważniejsze rzeczy do zaobserwowania to:
1. *Async* w sygnaturze metody Main. W ten sposób kompilator dostaje informację, iż metoda może być wykonywana asynchronicznie.
2. *Await*. Od tego momentu dzieje się magia. Kod może być wykonany równolegle.
Oczywiście w powyższym przykładzie kilka się dzieje, jest tylko oczekiwanie 500 ms. A co za tym idzie, czas wykonania kodu to około 500 ms. Póki co żadnej magii nie widać, chociaż kryje się pod spodem. Dlatego warto przejść do trochę bardziej rozbudowanego przykładu.


```
public static async Task Main(string[] args)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var delay1 = Task.Delay(500);
var delay2 = Task.Delay(500);
await Task.WhenAll(delay1, delay2);
stopWatch.Stop();
System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
}
```


Na pierwszy rzut oka nic wielkiego. *Task.Delay(500)* jest wywoływany dwa razy. Wniosek jest prosty. Czas wykonania całego kodu powinien wynosić nieco ponad sekundę. Cytując klasyka - nic bardziej mylnego. Wątpliwości może rozwiać uruchomienie kodu i odczytanie czasu zarejestrowanego dzięki klasy *Stopwatch*. Dlaczego *Stopwatch*, a nie zwykły *DateTime.Now*? To już temat na inny artykuł. Wracając do tematu:


```
Hello, async world! Time: 588 ms
```


Wynik to 588 ms. Polecenia *Task.Delay(500)* zostały wykonane równolegle. A dzięki *await Task.WhenAll(delay1, delay2)* program oczekuje, aż te zadania się zakończą. W powyższych kodach występuje cichy bohater skrzętnie do tej pory pomijany. Jest to klasa *Task*. To właśnie połączenie klasy *Task* oraz słów kluczowych *async*, *await* daje kontrolę nad asynchronicznością. Tutaj też pojawia się pierwsza pułapka. Od czasu do czasu można spotkać w kodach aplikacji funkcję o następującej sygnaturze:
*async void DoSomethingAsync(...)*


## Dlaczego async void to zło konieczne?


Jest takie popularne powiedzenie. jeżeli nie wiadomo, o co chodzi, to chodzi o pieniądze. W tym przypadku chodzi o kompatybilność wsteczną, ale koniec końców z biznesowego punktu widzenia ten temat w projektach informatycznych też sprowadza się do pieniędzy. Składni *async void* należy unikać, kiedy tylko się da. Jej użycie rodzi pewne konsekwencje. Tylko w przypadku, gdy znamy konsekwencje naszych czynów, możemy świadomie decydować, czy chcemy je podjąć. Dlatego każde zauważenie przez programistę *async void* w kodzie aplikacji powinno triggerować event „Czy to na pewno dobry pomysł?” oraz „Co autor kodu miał na myśli?”. A skoro mowa o eventach… To właśnie tam *async void* ma swoje naturalne środowisko do życia. Dokładniej, w event handler’ach. Event handler musi pasować do sygnatury delegatu danego event’a, np.:
*public delegate void EventHandler(object sender, EventArgs e);*


Sygnatura jest prosta i jasna. W tym miejscu nie można użyć wspomnianego wcześniej *Task* sprowadzając delegat do następującej formy:
*public delegate Task EventHandler(object sender, EventArgs e);*


Niestety ta sygnatura nie pasuje do eventów wykorzystywanych powszechnie w C#. Dla osób pracujących na co dzień z bibliotekami WinForms, czy WPF będzie to chleb powszedni. Wszelkie próby użycia *async Task* np. przy podpinaniu akcji kliknięcia button’a są skazane na niepowodzenie zwieńczone błędami kompilacji. Podsumowując *async void* ma swoje zastosowanie w event handlerach. Użycie tej składni w innych miejscach to bardzo kiepski pomysł. prawdopodobnie nasuwa się teraz pytanie dlaczego?


Odpowiedź wbrew pozorom jest dość logiczna. W celu wykonania *await* na danej metodzie potrzebujemy obiektu *Task*. EventHandler’y ze swojej natury nie zwracają nic. Używając składni *async void* w innych sytuacjach niż ta wcześniej wspomniana sami narzucamy sobie niepotrzebne ograniczenie. A brak możliwości wykonania *await* na metodzie niesie za sobą pewne konsekwencje. Pierwsza to rozpoczęcie wykonywania danej metody bez oczekiwania na jej zakończenie.


```
public static async Task Main(string[] args)
{
System.Console.WriteLine("App started");
var stopWatch = new Stopwatch();
stopWatch.Start();
System.Console.WriteLine("Delay1 started");
var delay1 = Task.Delay(500);
LongBackgroundJob();
System.Console.WriteLine("Delay2 started");
var delay2 = Task.Delay(500);
await Task.WhenAll(delay1, delay2);
stopWatch.Stop();
System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
}


public static async void LongBackgroundJob()
{
System.Console.WriteLine("LongBackgroundJob started");
await Task.Delay(500);
System.Console.WriteLine("LongBackgroundJob finished");
}
```


Efektem wykonania powyższego kodu jest następująca odpowiedź w konsoli:


```App started
Delay1 started
LongBackgroundJob started
Delay2 started
LongBackgroundJob finished
Hello, async world! Time: 525 ms
```


Metoda *LongBackgroundJob* została uruchomiona równolegle z pozostałym kodem. Na pierwszy rzut oka nie dzieje się nic strasznego. Podobnie jest z taskami *delay1* oraz *delay2*. Tylko iż w przypadku wspomnianych tasków można wykonać *await* - patrz linia nr 18, w powyższym fragmencie kodu. Próba wykonania *await* na metodzie *LongBackgroundJob* zakończy się błędem kompilacji:


![cs-bledy-blog.webp](/uploads/cs_bledy_blog_1366a8169a.webp)


Wychodzi na to, iż kończymy z wywołaniem typu “odpal i zapomnij”. Nie zawsze to musi być złe, ale takie zabiegi powinny być wykonywane w pełni świadomie. W innym przypadku mogą prowadzić do bardzo trudnych do zdiagnozowania błędów w logice aplikacji, np. korupcji danych, lub po prostu zawieszania aplikacji. W drugim przypadku *async void* będzia miał również swoje dwa grosze. W sytuacji wystąpienia exception’a proces po prostu zakończy swoje działanie. Takiego efektu nie zaobserwujemy w przypadku użycia *async Task*. Można powiedzieć wielka rzecz… Exception wyłożył aplikację. Poniższy kod generuje exception podczas wykonywania metody *LongBackgroundJob*:


```
public static async Task Main(string[] args)
{
System.Console.WriteLine("App started");
var stopWatch = new Stopwatch();
stopWatch.Start();
System.Console.WriteLine("Delay1 started");
var delay1 = Task.Delay(500);
LongBackgroundJob();
System.Console.WriteLine("Delay2 started");
var delay2 = Task.Delay(500);
await Task.WhenAll(delay1, delay2);
stopWatch.Stop();
System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
}


public static async void LongBackgroundJob()
{
System.Console.WriteLine("LongBackgroundJob started");
await Task.Delay(100);
throw new Exception("Exception from LongBackgroundJob");
System.Console.WriteLine("LongBackgroundJob finished");
}
}
```


Magia zaczyna się, gdy postanowimy otoczyć kapryśną metodę przy użyciu bloku *try...catch*:


```
public static async Task Main(string[] args)
{
System.Console.WriteLine("App started");
var stopWatch = new Stopwatch();
stopWatch.Start();
System.Console.WriteLine("Delay1 started");
var delay1 = Task.Delay(500);
try
{
LongBackgroundJob();
}
catch(Exception)
{
System.Console.WriteLine("Exception from LongBackgroundJob catched");
}
System.Console.WriteLine("Delay2 started");
var delay2 = Task.Delay(500);
await Task.WhenAll(delay1, delay2);
stopWatch.Stop();
System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
}


public static async void LongBackgroundJob()
{
System.Console.WriteLine("LongBackgroundJob started");
await Task.Delay(100);
throw new Exception("Exception from LongBackgroundJob");
System.Console.WriteLine("LongBackgroundJob finished");
}
}
```


Można oczekiwać, iż *exception* zostanie wyłapany i aplikacja będzie kontynuowała wykonywanie. Nic bardziej mylnego:


```
App started
Delay1 started
LongBackgroundJob started
Delay2 started
Unhandled exception. System.Exception: Exception from LongBackgroundJob
at DemoConsoleApp.Program.LongBackgroundJob() in C:\Users\rafal\source\repos\rafalswirk\CommonAsyncMistakes\DemoConsoleApp\Program.cs:line 34
at System.Threading.Tasks.Task.c.b__128_1(Object state)
at System.Threading.QueueUserWorkItemCallbackDefaultContext.Execute()
```


Ponownie *exception* zabija aplikację. Powyższy przykład jest trywialny. To tylko parę linii kodu. Mamy zapięte środowisko programistyczne. W przypadku dużych aplikacji legacy, gdzie pojedyncza klasa potrafi mieć kilkanaście tysięcy linii kodu, dodatkowo działających na maszynach użytkownika końcowego, bez możliwości uruchomienia wszystkiego z VisualStudio - wtedy już nie jest wesoło. Zwłaszcza jeżeli błąd występuje od czasu do czasu i nie ma jasnych kroków jego odtworzenia. Jak zatem złapać taki *exception*? Blok try-catch wystarczy umieścić wewnątrz nieszczęsnej metody:


```
public static async Task Main(string[] args)
{
System.Console.WriteLine("App started");
var stopWatch = new Stopwatch();
stopWatch.Start();
System.Console.WriteLine("Delay1 started");
var delay1 = Task.Delay(500);
LongBackgroundJob();
System.Console.WriteLine("Delay2 started");
var delay2 = Task.Delay(500);
await Task.WhenAll(delay1, delay2);
stopWatch.Stop();
System.Console.WriteLine($"Hello, async world! Time: {stopWatch.ElapsedMilliseconds} ms");
}


public static async void LongBackgroundJob()
{
try
{
System.Console.WriteLine("LongBackgroundJob started");
await Task.Delay(100);
throw new Exception("Exception from LongBackgroundJob");
System.Console.WriteLine("LongBackgroundJob finished");
}
```


Wywołanie powyższego kodu będzie trochę bardziej przewidywalne:


```
Loaded 'C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.7\System.Text.Encoding.Extensions.dll'. Skipped loading symbols. Module is optimized and the debugger option 'Just My Code' is enabled.
App started
Delay1 started
LongBackgroundJob started
Delay2 started
Exception thrown: 'System.Exception' in DemoConsoleApp.dll
Exception from LongBackgroundJob catched
Hello, async world! Time: 543 ms
The program '[4024] DemoConsoleApp.dll' has exited with code 0 (0x0).
```


## Podsumowanie


*Async* i *await* znacząco ułatwiają pracę z kodem wielowątkowym. O ile w większości przypadków użycie tego mechanizmu jest jasne, to część programistów wpada w pułapkę użycia *async void*. Dobrą praktyką jest unikanie tej konstrukcji, kiedy tylko się da. Jedyne miejsce, gdzie znajduje zastosowanie to event handlery, ale choćby w tym przypadku bardzo łatwo o napisanie kodu generującego dość niespodziewane zachowania.
Idź do oryginalnego materiału