1. Вступ
Писати програми, які ніколи не падають, — це як вірити, що баґи бояться вашої IDE. Насправді, що складнішим стає код (особливо в багатопоточному та асинхронному середовищі), то винахідливішими стають баґи, а помилки — витонченішими. Ігноруючи винятки в потоках і задачах, легко дістати витоки ресурсів, зависання, втрату даних або неочікувані збої через години після запуску.
У цій лекції ми зберемо докупи найкращі практики обробки помилок у багатопоточному та асинхронному програмуванні на C#. Ви дізнаєтеся, як грамотно ловити винятки, як розбирати «купу» одночасних помилок (у стилі AggregateException), що робити із задачами, запущеними «в нікуди», і чому ігнорувати винятки — небезпечно й безглуздо.
Чому все не так просто
- Потік, у якому виникає помилка, може відрізнятися від потоку, де розміщено try-catch.
- Асинхронні задачі не викидають винятки одразу — їх «пакують», і вони чекають обробки під час await (або синхронного очікування).
- За одночасної роботи з кількома задачами (наприклад, Task.WhenAll) може виникнути кілька помилок — це треба враховувати.
- Операції типу fire-and-forget можуть повністю «загубити» виняток без явного обробника.
Ця особливість — розділення контексту виконання. Уявіть програму як цирк із кількома аренами: якщо на одній сталася пожежа, її не відразу видно на інших. Важливо вміти правильно відстежувати й гасити такі «пожежі».
2. Винятки в задачах: Task і Task<TResult>
Як задачі сигналізують про помилки
Коли в задачі трапляється необроблений виняток, він не «вилетить» одразу назовні. Задача переходить у стан Faulted, а виняток зберігається всередині. Отримати його можна:
- очікуючи завершення за допомогою await (або через task.Wait()/task.Result — але так краще не робити);
- перевіривши властивість task.Exception — там буде AggregateException.
Приклад
// Асинхронний метод із помилкою
async Task FailAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Щось пішло не так");
}
async Task MainAsync()
{
try
{
await FailAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Помилка: {ex.Message}");
}
}
Якщо не поставити try-catch, програма впаде. Якщо поставити — виняток коректно перехопиться, навіть якщо помилка виникла в іншій задачі.
AggregateException: помилки оптом
Під час await Task.WhenAll(tasks) помилки з кількох задач збираються в один AggregateException (у його InnerExceptions).
async Task MultiFailAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("Помилка 1"));
Task t2 = Task.Run(() => throw new ArgumentException("Помилка 2"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
if (ex is AggregateException agg)
{
foreach (var e in agg.InnerExceptions)
Console.WriteLine($"Виняток: {e.Message}");
}
else
{
Console.WriteLine($"Одиночна помилка: {ex.Message}");
}
}
}
Увага: під час використання await Task.WhenAll(tasks) .NET «розгортає» AggregateException, і в catch ви отримаєте «перший» виняток. Повний список доступний через task.Exception.InnerExceptions, якщо задача завершилась із помилкою.
3. «Fire and Forget»: чому не можна просто забути про задачу
Підхід «запусти й забудь» часто веде до прихованих збоїв. Приклад:
Task.Run(() => { throw new Exception("Бах!"); }); // Помилка "відлітає" в порожнечу.
Сучасне середовище виконання зберігає помилку в задачі й може завершити процес через необроблений виняток. Найкраще — зберігати посилання на задачу та/або підписуватися на подію TaskScheduler.UnobservedTaskException.
Як правильно?
- Зберігайте задачу, щоб дочекатися її завершення й обробити помилку;
- для fire‑and‑forget поставте обробник прямо всередині делегата.
Task.Run(() => {
try
{
// Код, який може кинути виняток
}
catch (Exception ex)
{
// Логуємо, сповіщаємо, але не пропускаємо виняток назовні
Console.WriteLine($"Помилка в fire-and-forget: {ex.Message}");
}
});
4. Помилки в потоках (Thread): «ловити нічого?»
Виняток у новому Thread неможливо спіймати try-catch зовні — лише всередині тіла потоку.
var thread = new Thread(() =>
{
try
{
throw new Exception("Помилка в потоці");
}
catch (Exception ex)
{
Console.WriteLine($"Спіймали помилку всередині потоку: {ex.Message}");
}
});
thread.Start();
Якщо не поставити обробник, виняток завершить лише цей потік (якщо він фоновий: thread.IsBackground = true). Для нефонових потоків необроблений виняток може завершити весь процес. Завжди ставте try-catch усередині потоку.
Як повертати результат і помилки з потоку?
- Використовуйте черги або колекції для передавання результатів і помилок;
- подійну модель;
- краще переходьте на задачі — з ними зручніше обробляти помилки.
5. Паралельні цикли: ловимо помилки по‑особливому
У паралельних циклах помилки з різних гілок агрегуються в AggregateException.
try
{
Parallel.For(0, 5, i =>
{
if (i % 2 == 0)
throw new Exception($"Помилка в ітерації {i}");
});
}
catch (AggregateException ex)
{
foreach (var e in ex.InnerExceptions)
Console.WriteLine($"[Паралельний цикл] Помилка: {e.Message}");
}
Якщо треба логувати локальні збої й продовжувати інші гілки, ставте внутрішні try-catch у кожній гілці.
6. Обробка помилок під час скасування задач
Під час скасування через CancellationToken за домовленістю кидається OperationCanceledException — це не збій, а штатна зупинка.
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
}
// Десь у коді:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // Приблизно через 200 мс
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Операцію скасовано!");
}
Окрім ThrowIfCancellationRequested(), багато методів, що підтримують токен (наприклад, Task.Delay, HttpClient.GetAsync), самі кинуть OperationCanceledException. Перевіряйте підтримку скасування.
7. Корисні нюанси
Підходи на всі випадки
- Ставте try-catch на верхньому рівні асинхронних методів — так спіймаєте «втеклі» помилки.
- Не ігноруйте задачі: зберігайте посилання, дочікуйтеся завершення, логуйте помилки.
- Для паралельних операцій (Task.WhenAll / Parallel.ForEach) враховуйте AggregateException.
- Відділяйте скасування від збоїв: ловіть OperationCanceledException окремо.
- Логуйте помилки, особливо «тихі», що не завершують застосунок аварійно.
- Зберігайте деталі: логуйте всі вкладені винятки, а не лише перший.
- Обробляйте помилки «на місці»: якщо не знаєте, що робити, — хоча б залогуйте.
Де ловити помилку в багатопоточному та асинхронному коді
flowchart TD
A[Головний потік / UI] -->|Запускає задачу| B[Task/async]
B -->|Всередині задачі| C[try/catch всередині асинхронного методу]
B -->|await у головному потоці| D[try/catch навколо await]
A -->|Запускає Thread| E[Thread]
E -->|Всередині| F[try/catch всередині потоку]
B -->|Багато задач| G[Task.WhenAll / Parallel.ForEach]
G -->|Помилка| H[AggregateException]
8. Типові помилки та «граблі»
Помилка № 1: очікування задачі через .Result або .Wait(). Можливе взаємоблокування та/або неочікуваний AggregateException.
Помилка № 2: запуск fire‑and‑forget без внутрішнього try-catch — задача може непомітно завершитися з помилкою, діагностики не буде.
Помилка № 3: пропущені помилки в паралельних циклах — частина роботи не виконана, а ви цього не помічаєте.
Помилка № 4: нерозрізнення скасування (OperationCanceledException) і реальних помилок.
Помилка № 5: логування лише першої помилки серед кількох задач — решта залишаються «в тіні».
Помилка № 6: повторне використання одного об’єкта Exception для всіх задач — у кожної помилки має бути свій екземпляр.
Помилка № 7: відсутність обробки винятків у UI‑потоці — фонову помилку не видно, інтерфейс поводиться непередбачувано.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ