1. Wprowadzenie
Pisać programy, które nigdy nie padają – to tak, jakby wierzyć, że bugi boją się twojego IDE. W rzeczywistości im bardziej skomplikowany kod (zwłaszcza w środowisku wielowątkowym i asynchronicznym), tym bardziej pomysłowe są błędy i bardziej wyrafinowane wyjątki. Ignorując wyjątki w wątkach i zadaniach, łatwo dostać wycieki zasobów, zawieszenia, utratę danych lub niespodziewane awarie godzinę po uruchomieniu.
W tym wykładzie zbierzemy najlepsze praktyki obsługi błędów w programowaniu wielowątkowym i asynchronicznym w C#. Dowiesz się, jak poprawnie łapać wyjątki, jak rozbierać „kupę” jednoczesnych błędów (w stylu AggregateException), co robić z zadaniami uruchomionymi „na odwal” i dlaczego ignorowanie wyjątków — jest niebezpieczne i bezsensowne.
Dlaczego to nie jest proste
- Wątek, w którym występuje błąd, może być inny niż wątek, gdzie umieszczono try-catch.
- Asynchroniczne zadania nie wyrzucają wyjątków od razu — są „zapakowane” i czekają na obsłużenie przy await (lub przy synchronicznym oczekiwaniu).
- Przy jednoczesnej pracy z wieloma zadaniami (np. Task.WhenAll) może wystąpić kilka błędów — trzeba je uwzględnić.
- Operacje typu fire-and-forget mogą całkowicie „zgubić” wyjątek bez jawnego handlera.
Ta cecha to rozdzielenie kontekstu wykonania. Wyobraź sobie program jako cyrk z kilkoma arenami: jeśli na jednej wybuchnie pożar, nie od razu widać to na pozostałych. Ważne jest, by umieć prawidłowo śledzić i gasić takie „pożary”.
2. Wyjątki w zadaniach: Task i Task<TResult>
Jak zadania sygnalizują błędy
Kiedy w zadaniu wystąpi nieobsłużony wyjątek, on nie „wyleci” od razu na zewnątrz. Zadanie staje się Faulted, a wyjątek jest zachowany wewnątrz. Można go uzyskać:
- Oczekując zakończenia za pomocą await (albo przez task.Wait()/task.Result — ale lepiej tego nie robić);
- Sprawdzając właściwość task.Exception — tam będzie AggregateException.
Przykład
// Asynchroniczna metoda z błędem
async Task FailAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Coś poszło nie tak");
}
async Task MainAsync()
{
try
{
await FailAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Błąd: {ex.Message}");
}
}
Jeśli nie użyjesz try-catch, program upadnie. Jeśli użyjesz — wyjątek zostanie poprawnie złapany, nawet jeśli błąd nastąpił w innej task.
AggregateException: błędy hurtowo
Przy await Task.WhenAll(tasks) błędy z wielu zadań są zbierane do jednego AggregateException (w jego InnerExceptions).
async Task MultiFailAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("Błąd 1"));
Task t2 = Task.Run(() => throw new ArgumentException("Błąd 2"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
if (ex is AggregateException agg)
{
foreach (var e in agg.InnerExceptions)
Console.WriteLine($"Wyjątek: {e.Message}");
}
else
{
Console.WriteLine($"Pojedynczy błąd: {ex.Message}");
}
}
}
Uwaga: przy użyciu await Task.WhenAll(tasks) .NET „rozwija” AggregateException, i w catch dostaniesz „pierwszy” wyjątek. Pełna lista jest dostępna przez task.Exception.InnerExceptions, jeśli zadanie zakończyło się z błędem.
3. „Fire and Forget”: dlaczego nie można po prostu zapomnieć o zadaniu
„Uruchom i zapomnij” często prowadzi do ukrytych awarii. Przykład:
Task.Run(() => { throw new Exception("Bach!"); }); // Błąd "odleci" w pustkę.
Nowoczesny runtime przechowuje błąd w zadaniu i może zakończyć proces z powodu nieobsłużonego wyjątku. Najlepsze podejście — przechować referencję do zadania i/lub subskrybować zdarzenie TaskScheduler.UnobservedTaskException.
Jak poprawnie?
- Zachowuj zadanie, żeby poczekać na jego zakończenie i obsłużyć błąd;
- Dla fire‑and‑forget złóż handler bezpośrednio wewnątrz delegata.
Task.Run(() => {
try
{
// Kod, który może wyrzucić wyjątek
}
catch (Exception ex)
{
// Logujemy, powiadamiamy, ale nie wypuszczamy błędu na zewnątrz
Console.WriteLine($"Błąd w fire-and-forget: {ex.Message}");
}
});
4. Błędy w wątkach (Thread): „łapać nie ma czego?”
Wyjątek w nowym Thread nie można złapać try-catch z zewnątrz — tylko wewnątrz ciała wątku.
var thread = new Thread(() =>
{
try
{
throw new Exception("Błąd w wątku");
}
catch (Exception ex)
{
Console.WriteLine($"Złapano błąd wewnątrz wątku: {ex.Message}");
}
});
thread.Start();
Jeśli nie umieścisz handlera, wyjątek zakończy tylko ten wątek (jeśli jest on background: thread.IsBackground = true). Dla wątków niefonowych nieobsłużony wyjątek może zakończyć cały proces. Zawsze umieszczaj try-catch wewnątrz wątku.
Jak zwracać wynik i błędy z wątku?
- Używaj kolejek/kolekcji do przekazywania wyników/błędów;
- Model zdarzeniowy;
- Lepiej przejdź na zadania — z nimi wygodniej obsługiwać błędy.
5. Pętle równoległe: łapiemy błędy po‑inaczej
W pętlach równoległych błędy z różnych gałęzi agregują się w AggregateException.
try
{
Parallel.For(0, 5, i =>
{
if (i % 2 == 0)
throw new Exception($"Błąd w iteracji {i}");
});
}
catch (AggregateException ex)
{
foreach (var e in ex.InnerExceptions)
Console.WriteLine($"[Pętla równoległa] Błąd: {e.Message}");
}
Jeśli trzeba logować lokalne awarie i kontynuować pozostałe gałęzie, umieść wewnętrzne try-catch w każdej gałęzi.
6. Obsługa błędów przy anulowaniu zadań
Przy anulowaniu przez CancellationToken zgodnie z konwencją rzucany jest OperationCanceledException — to nie awaria, tylko normalne zatrzymanie.
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
}
// Gdzieś w kodzie:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // Około po 200 ms
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Operacja została anulowana!");
}
Poza ThrowIfCancellationRequested(), wiele metod wspierających token (np. Task.Delay, HttpClient.GetAsync) samo rzuci OperationCanceledException. Sprawdzaj wsparcie dla anulowania.
7. Przydatne niuanse
Podejścia dla wszystkich przypadków
- Umieszczaj try-catch na najwyższym poziomie asynchronicznych metod — tak złapiesz „uciekające” błędy.
- Nie ignoruj zadań: zachowuj referencje, czekaj na zakończenie, loguj błędy.
- Dla operacji równoległych (Task.WhenAll / Parallel.ForEach) uwzględniaj AggregateException.
- Oddziel anulowanie od awarii: łap OperationCanceledException osobno.
- Loguj błędy, szczególnie „ciche”, które nie zabijają aplikacji.
- Zachowuj szczegóły: loguj wszystkie zagnieżdżone wyjątki, nie tylko pierwszy.
- Obsługuj błędy „na miejscu”: jeśli nie wiesz co zrobić — przynajmniej zaloguj.
Gdzie łapać błąd w kodzie wielowątkowym i asynchronicznym
flowchart TD
A[Wątek główny / UI] -->|Uruchamia zadanie| B[Task/async]
B -->|Wewnątrz zadania| C[try/catch wewnątrz asynchronicznej metody]
B -->|await w wątku głównym| D[try/catch wokół await]
A -->|Uruchamia Thread| E[Thread]
E -->|Wewnątrz| F[try/catch wewnątrz wątku]
B -->|Wiele zadań| G[Task.WhenAll / Parallel.ForEach]
G -->|Błąd| H[AggregateException]
8. Typowe błędy i „pułapki”
Błąd nr 1: oczekiwanie na zadanie przez .Result lub .Wait(). Możliwy deadlock i/lub nieoczekiwany AggregateException.
Błąd nr 2: uruchomienie fire‑and‑forget bez wewnętrznego try-catch — zadanie może cicho upaść, diagnostyki nie będzie.
Błąd nr 3: pominięte błędy w pętlach równoległych — część pracy nie została wykonana, a ty tego nie zauważysz.
Błąd nr 4: nieoddzielenie anulowania (OperationCanceledException) od prawdziwych błędów.
Błąd nr 5: logowanie tylko pierwszego błędu spośród wielu zadań — pozostałe zostają „w cieniu”.
Błąd nr 6: ponowne używanie jednego obiektu Exception dla wszystkich zadań — każdy błąd powinien mieć swoją instancję.
Błąd nr 7: brak obsługi wyjątków w wątku UI — błąd w tle pozostaje niezauważony, interfejs zachowuje się „duchowo”.
GO TO FULL VERSION