1. Działanie wyjątków w Parallel.For i Parallel.ForEach
W zwykłej pętli for wszystko jest proste: jeśli w ciele pętli zostanie rzucony wyjątek — wykonanie pętli się kończy i wyjątek poleci na zewnątrz. W pętlach równoległych jest inaczej. Rozkmini my to.
Wszystkie wyjątki są zbierane do jednego "worka"
Kiedy w jednej z iteracji pętli równoległej (Parallel.For/ForEach) pojawia się wyjątek, nie leci on od razu na zewnątrz, tylko jest pakowany. Proces trwa dalej: inne iteracje albo dokańczają, albo też rzucają wyjątki. Efekt: gdy pętla równoległa kończy wykonanie (albo jest wymuszanie przerwana), wszystkie "rzucone" wyjątki są zbierane i wyrzucane na zewnątrz jako jeden obiekt typu AggregateException.
AggregateException — to "kontener", który trzyma wewnątrz kolekcję wszystkich wyjątków, które wystąpiły podczas wykonywania równoległych iteracji. To wygodne: zawsze dostajemy WSZYSTKIE błędy (albo przynajmniej te, które zdążyły się zebrać przed zakończeniem głównych wątków).
Jak to wygląda w praktyce
Przykład: przetwarzanie równoległe, gdzie czasem rzucamy wyjątek
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
int[] numbers = { 1, 2, 0, 4, 0, 6, 7, 8 };
try
{
Parallel.ForEach(numbers, number =>
{
// Celowo dzielimy przez liczbę, czasem jest równa zero!
// To spowoduje DivideByZeroException
int result = 100 / number;
Console.WriteLine($"100 / {number} = {result}");
});
}
catch (AggregateException ex)
{
Console.WriteLine("Wykryto błędy w pętli równoległej!");
// Iterujemy po wszystkich wyjątkach, które się zdarzyły
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine($"Typ: {inner.GetType().Name} — Komunikat: {inner.Message}");
}
}
}
}
Co się stanie:
- W głównej kolekcji są zera, a dzielenie przez 0 — to tabu w matematyce (i w C#): wystąpią DivideByZeroException.
- Pętla równoległa zaczyna przetwarzanie. Jak tylko gdzieś wystąpi dzielenie przez zero — pętla nie zatrzyma się od razu, a dokończy wszystkie iteracje, które już się zaczęły wykonywać.
- Kiedy wszystkie wątki zakończą pracę (kto z błędem, kto bez), na zewnątrz poleci AggregateException, zawierający wszystkie wystąpienia wyjątków.
Zobrazujmy mechanikę obsługi wyjątków
flowchart LR
A[Wątek 1]
B[Wątek 2]
C[Wątek 3]
D[Wątek 4]
E[Parallel.ForEach]
F[Wyjątek 1]
G[Wyjątek 2]
H[AggregateException]
subgraph Iteracje
A --> F
B --> G
C --> E
D --> E
F --> H
G --> H
E --> H
end
Na schemacie widać: różne wątki mogą natrafić na różne błędy i w końcu wszystkie są "pakowane" w jeden AggregateException.
2. Praktyczne cechy obsługi błędów
Co zrobić z AggregateException?
Kiedy łapiemy AggregateException, zwykle są dwa scenariusze:
- Wyświetlić użytkownikowi (albo do loga) wszystkie błędy, żeby się uczyć na błędach.
- Zrozumieć, który błąd jest krytyczny, a które to drobnostki: zdecydować, czy całą operację traktować jako nieudaną, czy zignorować pojedyncze awarie.
Typowy wzorzec: obsługa przez Handle
try
{
Parallel.For(0, 10, i =>
{
if (i == 3 || i == 7)
throw new InvalidOperationException($"Błąd w iteracji {i}");
Console.WriteLine($"Przetworzono: {i}");
});
}
catch (AggregateException ex)
{
ex.Handle(e =>
{
if (e is InvalidOperationException)
{
Console.WriteLine("Złapano błąd: " + e.Message);
// true = błąd uznany za obsłużony
return true;
}
// false = nieobsłużone, zostanie rzucone ponownie
return false;
});
}
Takie podejście pozwala obsłużyć tylko te błędy, które uważasz za "normalne", a resztę — przepuścić wyżej, żeby nie przegapić krytycznych awarii.
Ciekawe (i niebezpieczne) niuanse implementacji
Kiedy pętla się zatrzymuje?
Kiedy w iteracji pojawia się wyjątek, Parallel.For/ForEach nie uruchamia nowych iteracji, ale już rozpoczęte kontynuują wykonywanie. Po zakończeniu wszystkich aktywnych iteracji wyrzucany jest AggregateException. Jeśli wątków jest dużo, "ogon" pracy i tak dobiegnie końca — dlatego błędów może być kilka.
Jeśli nie złapiesz wyjątku, aplikacja się rozleci.
Jeśli nie owiniesz Parallel.For/ForEach w blok try-catch, aplikacja zakończy się awaryjnie przy pierwszym napotkanym błędzie po zakończeniu wszystkich iteracji — niezbyt grzeczne wobec użytkownika.
Przepuszczenie wyjątku "do środka" pętli.
Czasem potrzebne jest specjalne podejście. Na przykład, jeśli chcesz, żeby pojedyncze iteracje nie psuły całego obrazu, możesz obsłużyć wyjątki bezpośrednio w ciele pętli równoległej:
Parallel.ForEach(numbers, number =>
{
try
{
int result = 100 / number;
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"Błąd dla liczby {number}: {ex.Message}");
}
});
Ten sposób jest dobry, jeśli nie potrzebujesz wszystkich wyjątków "hurtowo" — od razu obsługujesz każdą porażkę na miejscu (np. logujesz). Ale uwaga: jeśli tak robisz — żadne AggregateException nie powstanie i nie będziesz mógł sprawdzić, czy wszystko poszło OK globalnie.
Jeśli wywołane zostaną Break() lub Stop().
Jeśli iteracja wywoła ParallelLoopState.Break() lub ParallelLoopState.Stop(), pętla próbuje zatrzymać nowe iteracje: Break() kończy iteracje po bieżącym indeksie, a Stop() — wszystkie iteracje. Jednak jeśli równocześnie wystąpi wyjątek, to zostanie on zachowany i wyrzucony jako AggregateException po zakończeniu wszystkich aktywnych iteracji.
3. Przydatne niuanse
Wyjątki w zwykłych pętlach vs równoległych
W zwykłej pętli każdy błąd prowadzi do natychmiastowego zakończenia całej pracy: wyjątek idzie na zewnątrz, wszystko się blokuje.
W pętlach równoległych C# stosuje bardziej kompromisowe podejście: praca trwa dalej dla już wystartowanych zadań, i dopiero po zakończeniu całego procesu wszystkie błędy "wychodzą" na zewnątrz jedną porcją. To pozwala zebrać wszystkie błędy, nie tracąc żadnego, i podjąć decyzję po zakończeniu pętli.
4. Typowe błędy przy pracy z wyjątkami w Parallel.For i Parallel.ForEach
Błąd nr 1: ignorowanie AggregateException.
Jeśli nie złapiesz AggregateException, aplikacja zakończy się awaryjnie po zakończeniu wszystkich iteracji, co doprowadzi do utraty danych i awarii w aplikacjach serwerowych lub GUI.
Błąd nr 2: używanie .Wait() bez try-catch.
Wywołanie .Wait() dla Parallel.For/ForEach bez obsługi AggregateException doprowadzi do nieobsłużonego wyjątku, co utrudni diagnostykę.
Błąd nr 3: ignorowanie powtarzających się błędów.
Wielokrotne identyczne błędy (np. dzielenie przez zero) mogą być rzucone z powodu powtarzających się danych. Bez analizy InnerExceptions można przeoczyć sedno problemu.
Błąd nr 4: tłumienie wszystkich wyjątków.
Używanie catch (Exception) { /* pusto */ } wewnątrz pętli ukrywa błędy, co prowadzi do utraty ważnej informacji i "duchowych" bugów.
Zachowanie błędów w różnych pętlach
| Wariant | Zwykły for/foreach | Parallel.For / ForEach |
|---|---|---|
| Wyjątek jest obsłużony | Natychmiast | Po zakończeniu wszystkich iteracji |
| Format błędu | Pojedynczy exception | AggregateException z kolekcją |
| Pozostałe iteracje | Nie wykonują się | Już uruchomione dokańczają |
| Łapanie błędów w ciele | Tak | Tak |
| Łapanie błędów "z zewnątrz" | Tak | Tak, przez AggregateException |
"Sztuczki" i krótkie pytania na rozmowę:
- Co się stanie, jeśli nie obsłużyć AggregateException?
Aplikacja padnie po zakończeniu wszystkich iteracji — niezależnie od tego, gdzie i kiedy wystąpił błąd. - Czy można dowiedzieć się, w której iteracji wystąpił błąd?
Tylko jeśli sam dodasz do wyjątku informację o indeksie albo danych. - Czy AggregateException może być pusty?
Nie, tworzy się tylko w obecności przynajmniej jednego wewnętrznego wyjątku. Jeśli błędów nie ma, nie jest rzucany. - Czy błędy są obsługiwane, jeśli je złapiemy wewnątrz pętli?
Tak, ale wtedy "na zewnątrz" nic nie poleci i AggregateException nie powstanie.
Teraz jesteś gotowy nie tylko odpalać pętle na wielu wątkach, ale też sprawnie radzić sobie z ich równoległymi "awariami"! I jak zwykle — uważaj na wielowątkowość: lubi niespodzianki, zwłaszcza jeśli nikt ich nie łapie.
GO TO FULL VERSION