1. Работа исключений в Parallel.For и Parallel.ForEach
В обычном for-цикле всё просто: если внутри тела цикла бросается исключение — выполнение цикла завершается, и исключение летит наружу. В параллельных циклах не так. Давайте разбираться.
Все исключения собираются в один "мешок"
Когда внутри одной из итераций параллельного цикла (Parallel.For/ForEach) возникает исключение, оно не сразу летит наружу, а запаковывается. Процесс продолжается: другие итерации либо дорабатывают, либо также выкидывают исключения. Итог: когда параллельный цикл завершает исполнение (или вынужденно прерывается), все "выброшенные" исключения собираются и выбрасываются наружу в виде одного объекта типа AggregateException.
AggregateException — это "контейнер", который внутри хранит коллекцию всех исключений, произошедших во время выполнения параллельных итераций. Это удобно: мы всегда получаем ВСЕ ошибки (или хотя бы все, что успели накопиться до завершения основных потоков).
Как это выглядит на практике
Пример: параллельная обработка, где иногда бросаем исключение
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 =>
{
// Мы намеренно делим на число, иногда оно равно нулю!
// Это вызовет DivideByZeroException
int result = 100 / number;
Console.WriteLine($"100 / {number} = {result}");
});
}
catch (AggregateException ex)
{
Console.WriteLine("Обнаружены ошибки в параллельном цикле!");
// Перебираем все исключения, которые случились
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine($"Тип: {inner.GetType().Name} — Сообщение: {inner.Message}");
}
}
}
}
Что произойдет:
- В основной коллекции есть нули, а деление на 0 — это табу в математике (и в C#): возникнут DivideByZeroException.
- Параллельный цикл начинает обработку. Как только где-то произойдет деление на ноль — цикл не остановится сразу, а продолжит все итерации, которые уже начали выполнение.
- Когда все потоки завершат работу (кто с ошибкой, кто без), наружу полетит AggregateException, содержащий все случившиеся исключения.
Визуализируем механику обработки исключений
flowchart LR
A[Поток 1]
B[Поток 2]
C[Поток 3]
D[Поток 4]
E[Parallel.ForEach]
F[Исключение 1]
G[Исключение 2]
H[AggregateException]
subgraph Итерации
A --> F
B --> G
C --> E
D --> E
F --> H
G --> H
E --> H
end
На схеме видно: разные потоки могут столкнуться с разными ошибками, и все они в итоге "пакуются" в единый AggregateException.
2. Практические особенности обработки ошибок
Что делать с AggregateException?
Когда ловим AggregateException, обычно есть два сценария:
- Вывести пользователю (или в лог) все ошибки, чтобы богатеть опытом.
- Понять, какая ошибка критична, а какие — ерунда: решить, считать ли всю операцию неудачной или игнорировать отдельные сбои.
Типичный паттерн: обработка через Handle
try
{
Parallel.For(0, 10, i =>
{
if (i == 3 || i == 7)
throw new InvalidOperationException($"Ошибка в итерации {i}");
Console.WriteLine($"Обработано: {i}");
});
}
catch (AggregateException ex)
{
ex.Handle(e =>
{
if (e is InvalidOperationException)
{
Console.WriteLine("Поймана ошибка: " + e.Message);
// true = ошибка считается обработанной
return true;
}
// false = не обработано, бросит снова
return false;
});
}
Такой подход позволяет обработать только те ошибки, которые вы считаете "нормой", а всё остальное — пропускать наверх, чтобы не пропустить критические сбои.
Интересные (и опасные) нюансы реализации
Когда цикл останавливается?
Когда в итерации возникает исключение, Parallel.For/ForEach не запускает новые итерации, но уже начатые продолжают выполняться. После завершения всех активных итераций выбрасывается AggregateException. Если потоков много, "хвост" работы всё равно дойдёт до завершения — поэтому ошибок может быть несколько.
Если не ловить исключение, приложение упадёт.
Если не обернуть Parallel.For/ForEach в блок try-catch, приложение завершится аварийно на первой же встреченной ошибке после завершения всех итераций — не очень вежливо по отношению к пользователю.
Передача исключения "внутрь" цикла.
Иногда нужен особый подход. Например, если вы хотите, чтобы отдельные итерации не портили всю картину, можно обработать исключения прямо в теле параллельного цикла:
Parallel.ForEach(numbers, number =>
{
try
{
int result = 100 / number;
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"Ошибка на числе {number}: {ex.Message}");
}
});
Такой способ хорош, если вам не нужны все исключения "оптом" — вы сразу обрабатываете каждую неудачу на месте (например, пишете в лог). Но будьте осторожны: если вы так делаете — никакая AggregateException не возникнет, и вы не сможете узнать, было ли всё в порядке в целом.
Если вызывается Break() или Stop().
Если итерация вызывает ParallelLoopState.Break() или ParallelLoopState.Stop(), цикл пытается остановить новые итерации: Break() завершает итерации после текущего индекса, а Stop() — все итерации. Однако, если одновременно возникает исключение, оно сохраняется и выбрасывается в виде AggregateException после завершения всех активных итераций.
3. Полезные нюансы
Исключения в обычных циклах vs параллельных
В обычном цикле любая ошибка приводит к немедленному завершению всей работы: исключение летит наружу, всё блокируется.
В параллельных циклах C# придерживается более компромиссного подхода: работа продолжается для уже стартовавших задач, и только по завершении всего процесса все ошибки "выходят" наружу одной порцией. Это позволяет собрать все ошибки, не потеряв ни одной, и принять решение после завершения цикла.
4. Типичные ошибки при работе с исключениями в Parallel.For и Parallel.ForEach
Ошибка №1: игнорирование AggregateException.
Если не поймать AggregateException, приложение завершится аварийно после завершения всех итераций, что приведёт к потере данных и сбоям в серверных или GUI-приложениях.
Ошибка №2: использование .Wait() без try-catch.
Вызов .Wait() для Parallel.For/ForEach без обработки AggregateException приведёт к необработанному исключению, что усложнит диагностику.
Ошибка №3: игнорирование повторяющихся ошибок.
Множественные одинаковые ошибки (например, деление на ноль) могут быть выброшены из-за повторяющихся данных. Без анализа InnerExceptions можно упустить корень проблемы.
Ошибка №4: глушение всех исключений.
Использование catch (Exception) { /* пусто */ } внутри цикла скрывает ошибки, что приводит к потере важной информации и "призрачным" багам.
Поведение ошибок в разных циклах
| Вариант | Обычный for/foreach | Parallel.For / ForEach |
|---|---|---|
| Исключение обрабатывается | Немедленно | После завершения всех итераций |
| Формат ошибки | Одиночный exception | AggregateException с коллекцией |
| Остальные итерации | Не выполняются | Уже запущенные дорабатывают |
| Ловля ошибок в теле | Да | Да |
| Ловля ошибок "снаружи" | Да | Да, через AggregateException |
"Фишки" и короткие вопросы для интервью:
- Что будет, если не обрабатывать AggregateException?
Приложение упадёт после завершения всех итераций — независимо от того, где и когда произошла ошибка. - Можно ли узнать, в какой именно итерации возникла ошибка?
Только если вы сами включите в исключение информацию об индексе или данных. - Может ли AggregateException быть пустым?
Нет, он создаётся только при наличии хотя бы одного внутреннего исключения. Если ошибок нет, он не выбрасывается. - Обрабатываются ли ошибки, если их поймать внутри цикла?
Да, но тогда "наружу" уже ничего не вылетит, и AggregateException не возникнет.
Теперь вы готовы не только запускать циклы в несколько потоков, но и ловко разруливать все их параллельные "аварии"! И, как всегда, — будьте осторожны с многопоточностью: она любит сюрпризы, особенно если их никто не ловит.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ