CodeGym /Kursy /C# SELF /Obsługa wyjątków w kodzie asynchronicznym

Obsługa wyjątków w kodzie asynchronicznym

C# SELF
Poziom 59 , Lekcja 4
Dostępny

1. Metody asynchroniczne i wyjątki

Jesteśmy przyzwyczajeni do tego, że jeśli w kodzie dzieje się coś złego (np. dzielenie przez zero albo próba odczytu nieistniejącego pliku), rzucany jest wyjątek, który możemy złapać za pomocą try-catch. Wszystko proste, dopóki kod wykonuje się sekwencyjnie i w jednym wątku. Ale gdy pojawia się asynchroniczność, świat staje się jak otwarta przestrzeń kosmiczna: wyjątek może "pojawić się" daleko poza miejscem, gdzie go oczekiwaliśmy, albo w ogóle pozostać niezauważony.

Powód jest taki, że metoda asynchroniczna często zwraca zadanie (Task), którego wykonanie trwa PO opuszczeniu metody. Wyjątek może wystąpić już po tym, jak główny wątek "puścił" wykonanie i idzie dalej swoim torem. Dlatego znana konstrukcja try-catch wokół wywołania metody asynchronicznej nie zawsze działa tak jak w kodzie synchronicznym.

Wyjaśnijmy to na prostym przykładzie. Załóżmy, że w naszej mini-aplikacji jest taka metoda asynchroniczna:

// Fragment naszej aplikacji: asynchroniczne obliczenie "wysyłki raportu"
public async Task SendReportAsync()
{
    // Tutaj mogłyby być wywołania sieciowe lub dostęp do plików
    await Task.Delay(100);
    throw new InvalidOperationException("Błąd podczas wysyłania raportu!");
}

A tak możemy ją wywołać:

SendReportAsync();
Console.WriteLine("Kontynuujemy pracę...");

Wizualizacja

flowchart TD
    Start["Główny wątek"]
    Call[/"Wywołanie SendReportAsync()"/]
    Continue["Praca trwa dalej..."]
    Exception["Wyjątek występuje w Task"]
    Unhandled["Błąd nieobsłużony!"]
    Start --> Call --> Continue
    Call -.- Exception --> Unhandled

Wniosek: jeśli metoda asynchroniczna zwraca Task i nie czekasz na zakończenie zadania (await lub .Wait()), wyjątek pozostanie "niezauważony". W najlepszym wypadku środowisko uruchomieniowe zapisze do loga coś w stylu "Nieobsłużony wyjątek w zadaniu". W najgorszym — w ogóle stracisz błąd i długo będziesz szukać źródła "mistycznych bugów".

2. Jak prawidłowo łapać wyjątki w kodzie asynchronicznym?

Używaj await + try-catch

Spójrzmy na poprawny sposób:

try
{
    await SendReportAsync(); // Czekamy na zakończenie Task
    Console.WriteLine("Raport wysłany pomyślnie!");
}
catch (Exception ex)
{
    Console.WriteLine($"Ups! Coś poszło nie tak: {ex.Message}");
}

Jak to działa? Kiedy stawiasz await przed wywołaniem metody asynchronicznej, C# rozdziela twój metodę na dwie części: przed i po await. Jeśli w asynchronicznej części wystąpi wyjątek, "wyskoczy" on dokładnie tam, gdzie był await, i można go złapać klasycznym try-catch.

Przykład dla aplikacji

Dodajmy obsługę błędów wysyłki raportu w naszym demo:

public async Task StartReportProcessAsync()
{
    try
    {
        await SendReportAsync();
        Console.WriteLine("Raport wysłany pomyślnie!");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Błąd podczas wysyłania raportu: {ex.Message}");
    }
}

I wywołać:

await StartReportProcessAsync();

.Wait(), .Result — nie najlepsza, ale działająca taktyka dla konsoli

Czasem, szczególnie w aplikacjach konsolowych, nie możesz użyć await na najwyższym poziomie (starsze wersje C#, metoda Main). Wtedy trzeba sięgnąć po synchroniczne oczekiwanie zakończenia zadania za pomocą .Wait() albo .Result.

try
{
    SendReportAsync().Wait();
}
catch (AggregateException aggEx)
{
    foreach (var ex in aggEx.InnerExceptions)
        Console.WriteLine($"Błąd: {ex.Message}");
}

Dlaczego tak? Wywołania .Wait() i .Result zawsze opakowują oryginalny wyjątek w AggregateException. To kontener, który może zawierać jedno lub kilka wyjątków. Może się zdarzyć, że będzie tam jedno (lub kilka!) wyjątków wewnętrznych, więc trzeba je rozpakować pętlą. Więcej o AggregateException przeczytasz w oficjalnej dokumentacji.

Ważne!

W nowoczesnych wersjach .NET (od C# 7.1) możesz zadeklarować asynchroniczne Main i używać await bezpośrednio w punkcie wejścia:

static async Task Main(string[] args)
{
    await StartReportProcessAsync();
}

3. Wyjątki w zadaniach "fire-and-forget"

Co się stanie, jeśli uruchomisz metodę asynchroniczną, nie czekając na jej zakończenie i nie przechowując referencji do zadania?

SendReportAsync(); // "Zapomnieliśmy" o zadaniu

W takiej sytuacji pojawia się problem: wyjątek, który wystąpi w zadaniu, nie zostanie nigdzie obsłużony. Czasami (zależnie od środowiska i ustawień) aplikacja może nawet zakończyć się awaryjnie. A czasem po prostu zostanie zalogowane ostrzeżenie. To nie jest bug C#, a konsekwencja logiki działania tasków.

Jak robić to poprawnie?

  • W idealnym świecie: nigdy nie używaj "fire-and-forget", jeśli nie jesteś pewien, że zadanie nie może zakończyć się awaryjnie.
  • Jeśli metoda asynchroniczna faktycznie ma działać w trybie "fire-and-forget", użyj jawnej obsługi błędów wewnątrz metody.
public async Task SendReportSafeAsync()
{
    try
    {
        await Task.Delay(100);
        throw new InvalidOperationException("Błąd podczas wysyłania!");
    }
    catch (Exception ex)
    {
        // Logujemy lub obsługujemy błąd
        Console.WriteLine($"[Log] Wyjątek: {ex.Message}");
    }
}

// Wywołanie
SendReportSafeAsync();

Ogólna rekomendacja: Jeśli zadanie nie jest nigdzie obserwowane i nie używasz await, koniecznie opakuj ciało metody asynchronicznej w try-catch. W ten sposób nie zgubisz błędu i przynajmniej będziesz mógł go zalogować.

4. Wyjątki i zadania równoległe: Task.WhenAll i spółka

Często w prawdziwych aplikacjach trzeba uruchomić kilka niezależnych zadań asynchronicznych i poczekać na ich zakończenie. Na przykład, gdy wysyłasz raporty do kilku odbiorców równolegle:

var tasks = new List<Task>
{
    SendReportAsync(),
    SendReportAsync(),
    SendReportAsync()
};

await Task.WhenAll(tasks);

Co się stanie, jeśli jedno (albo kilka) z zadań rzuci wyjątek?

Jak łapać takie błędy?

Przy użyciu await Task.WhenAll(tasks) — jeśli przynajmniej jedno zadanie zakończyło się błędem, await wyrzuci wyjątek z pierwszego zadania, które zakończyło się z błędem (nie będzie on opakowany w AggregateException).
Ale jest niuans: jeśli wśród zadań było kilka awarii, wtedy może zostać rzucony AggregateException z zestawem wyjątków wewnętrznych.

try
{
    await Task.WhenAll(tasks);
}
catch (Exception ex)
{
    // Jeśli to AggregateException — rozpakujemy ją
    if (ex is AggregateException agg)
    {
        foreach (var inner in agg.InnerExceptions)
            Console.WriteLine($"Błąd w zadaniu: {inner.Message}");
    }
    else
    {
        Console.WriteLine($"Błąd: {ex.Message}");
    }
}

Dla await z pojedynczym zadaniem wyjątek zwykle nie jest opakowywany w AggregateException. Ale z WhenAll — jak najbardziej może być!

5. Asynchroniczne delegaty i obsługa błędów

W aplikacjach z interfejsem użytkownika (WPF, WinForms, ASP.NET) obsługiwacze zdarzeń często pisze się jako asynchroniczne lambdy. Jeśli wyjątek w takim handlerze "wyjdzie na zewnątrz", zachowanie zależy od frameworka UI: aplikacja może zakończyć się awaryjnie albo błąd zostanie "przełknięty".

Rekomendacja

Zawsze używaj try-catch wewnątrz asynchronicznych delegatów:

button.Click += async (sender, args) =>
{
    try
    {
        await SendReportAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show($"Błąd: {ex.Message}");
    }
};
2
Zadanie
C# SELF, poziom 59, lekcja 4
Niedostępne
Asynchroniczne obsługiwanie wyjątków w handlerze
Asynchroniczne obsługiwanie wyjątków w handlerze
1
Ankieta/quiz
Programowanie asynchroniczne, poziom 59, lekcja 4
Niedostępny
Programowanie asynchroniczne
Asynchroniczność vs. Wielowątkowość
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION