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}");
}
};
GO TO FULL VERSION