1. Робота винятків у Parallel.For і Parallel.ForEach
У звичайному for-циклі все просто: якщо всередині тіла циклу виникає виняток, виконання циклу завершується, і виняток вилітає назовні. У паралельних циклах — інакше. Розберімося.
Усі винятки збираються в один «мішок»
Коли всередині однієї з ітерацій паралельного циклу (Parallel.For/Parallel.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}");
}
}
}
}
Що відбудеться:
- У колекції є нулі, а ділення на нуль — табу в математиці (і в 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/Parallel.ForEach припиняє запускати нові ітерації, але вже розпочаті продовжують виконуватися. Після завершення всіх активних ітерацій викидають AggregateException. Якщо потоків багато, «хвіст» роботи все одно дійде до завершення — тому помилок може бути кілька.
Якщо не ловити виняток, застосунок впаде.
Якщо не обгорнути Parallel.For/Parallel.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 паралельних
У звичайному циклі будь-яка помилка призводить до негайного завершення всієї роботи: виняток вилітає назовні, усе блокується.
У паралельних циклах реалізовано компромісний підхід: робота триває для вже стартованих завдань, і тільки після завершення всього процесу всі помилки «виходять» назовні однією порцією. Це дозволяє зібрати усі помилки, не втративши жодної, і прийняти рішення після завершення циклу.
4. Типові помилки під час роботи з винятками в Parallel.For і Parallel.ForEach
Помилка № 1: ігнорування AggregateException.
Якщо не спіймати AggregateException, застосунок аварійно завершиться після завершення всіх ітерацій, що призведе до втрати даних і збоїв у серверних або GUI‑застосунках.
Помилка № 2: використання .Wait() без try-catch.
Виклик .Wait() для Parallel.For/Parallel.ForEach без обробки AggregateException призведе до необробленого винятку, що ускладнить діагностику.
Помилка № 3: ігнорування повторюваних помилок.
Багато однакових помилок (наприклад, ділення на нуль) можуть бути викинуті через повторювані дані. Без аналізу InnerExceptions можна пропустити корінь проблеми.
Помилка № 4: заглушення всіх винятків.
Використання catch (Exception) { /* порожньо */ } всередині циклу ховає помилки, що веде до втрати важливої інформації та «примарних» багів.
Поведінка помилок у різних циклах
| Варіант | Звичайний for/foreach | Parallel.For / ForEach |
|---|---|---|
| Виняток обробляється | Одразу | Після завершення всіх ітерацій |
| Формат помилки | Окремий виняток | AggregateException із колекцією |
| Решта ітерацій | Не виконуються | Вже запущені завершують роботу |
| Ловля помилок у тілі | Так | Так |
| Обробка помилок «ззовні» | Так | Так, через AggregateException |
Корисні деталі та короткі запитання для співбесіди:
- Що буде, якщо не обробляти AggregateException?
Застосунок впаде після завершення всіх ітерацій — незалежно від того, де й коли сталася помилка. - Чи можна дізнатися, в якій саме ітерації виникла помилка?
Лише якщо ви самі додасте до винятку інформацію про індекс або дані. - Чи може AggregateException бути порожнім?
Ні, його створюють лише за наявності бодай одного внутрішнього винятку. Якщо помилок немає, його не викидають. - Чи обробляються помилки, якщо їх ловити всередині циклу?
Так, але тоді «назовні» вже нічого не вилетить, і AggregateException не виникне.
Тепер ви готові не тільки запускати цикли в кілька потоків, а й вправно владнати всі їхні паралельні «аварії». І, як завжди, — будьте обережні з багатопоточністю: вона любить сюрпризи, особливо якщо їх ніхто не ловить.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ