Pułapki asynchroniczności w C#

Author
Rafał Świrk
blog_date_icon
12 września 2022

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 skrócie oznacza to, że 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ę, że 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ę, że 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 niewiele 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 za pomocą 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 za pomocą 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śli 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ł. Zapewne 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:

1Delay1 started
2LongBackgroundJob started
3Delay2 started
4LongBackgroundJob finished
5Hello, 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 że 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

Wychodzi na to, że 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ć, że 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.<ThrowAsync>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śli 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 nawet w tym przypadku bardzo łatwo o napisanie kodu generującego dość niespodziewane zachowania.