1. Асинхронні методи і винятки
Ми звикли до того, що коли в коді стається щось погане (наприклад, ділення на нуль або спроба звернення до неіснуючого файлу), викидається виняток, який можна перехопити за допомогою try-catch. Усе просто, доки код виконується послідовно й в одному потоці. Та щойно з’являється асинхронність, світ стає схожим на відкритий космос: виняток може «з’явитися» далеко від місця, де ви його очікували, або взагалі залишитися непоміченим.
Причина в тому, що асинхронний метод часто повертає завдання (Task), виконання якого триває після виходу з методу. Виняток може статися вже після того, як основний потік «відпустив» виконання і продовжив жити своїм життям. Тому звична конструкція try-catch довкола виклику асинхронного методу не завжди працює так, як у синхронному коді.
Розберімося на простому прикладі. Нехай у нашому міні-застосунку є такий асинхронний метод:
// Фрагмент нашого застосунку: асинхронний процес "надсилання звіту"
public async Task SendReportAsync()
{
// Тут могли б бути мережеві виклики або доступ до файлів
await Task.Delay(100);
throw new InvalidOperationException("Помилка під час надсилання звіту!");
}
А ось як ми можемо його викликати:
SendReportAsync();
Console.WriteLine("Продовжуємо працювати...");
Візуалізація
flowchart TD
Start["Основний потік"]
Call[/"Виклик SendReportAsync()"/]
Continue["Робота триває…"]
Exception["Виняток виникає в Task"]
Unhandled["Помилка не оброблена!"]
Start --> Call --> Continue
Call -.- Exception --> Unhandled
Висновок: якщо асинхронний метод повертає Task і ви не очікуєте завершення завдання (await або .Wait()), виняток виявиться «непоміченим». У кращому разі середовище виконання запише до журналу щось на кшталт «Необроблений виняток у завданні». У гіршому — ви взагалі втратите помилку й довго шукатимете джерело «містичних помилок».
2. Як правильно ловити винятки в асинхронному коді?
Користуйтеся await + try-catch
Розгляньмо правильний спосіб:
try
{
await SendReportAsync(); // Чекаємо завершення Task
Console.WriteLine("Звіт успішно надіслано!");
}
catch (Exception ex)
{
Console.WriteLine($"Сталася помилка: {ex.Message}");
}
Як це працює? Коли ви ставите await перед викликом асинхронного методу, C# «розбиває» ваш метод на дві частини: до і після await. Якщо в асинхронній частині виникне виняток, він «вискочить» саме там, де стоїть await, і його можна зловити класичним try-catch.
Приклад для застосунку
Додаймо обробку помилок надсилання звіту до нашого прикладу:
public async Task StartReportProcessAsync()
{
try
{
await SendReportAsync();
Console.WriteLine("Звіт успішно надіслано!");
}
catch (Exception ex)
{
Console.WriteLine($"Помилка під час надсилання звіту: {ex.Message}");
}
}
І викликати:
await StartReportProcessAsync();
.Wait(), .Result — не найкраща, але робоча тактика для консолі
Іноді, особливо в консольних застосунках, ви не можете використовувати await на верхньому рівні (старі версії C#, метод Main). Тоді доводиться вдаватися до синхронного очікування завершення завдання за допомогою .Wait() або .Result.
try
{
SendReportAsync().Wait();
}
catch (AggregateException aggEx)
{
foreach (var ex in aggEx.InnerExceptions)
Console.WriteLine($"Помилка: {ex.Message}");
}
Чому так? Виклики .Wait() і .Result завжди загортають вихідний виняток у AggregateException. Це контейнер, який може містити один або кілька винятків. У ньому може бути одне (або кілька!) внутрішніх винятків, тож їх доводиться розбирати циклом. Докладніше про AggregateException дивіться в офіційній документації.
Важливо!
У сучасних версіях .NET (починаючи з C# 7.1) ви можете оголосити асинхронний Main і використовувати await прямо в точці входу:
static async Task Main(string[] args)
{
await StartReportProcessAsync();
}
3. Винятки у «fire-and-forget» завданнях
Що буде, якщо ви запустите асинхронний метод, не очікуватимете його завершення і не збережете посилання на завдання?
SendReportAsync(); // "Забули" про задачу
У такій ситуації виникає проблема: виняток, що стався в завданні, ніхто не обробить. Іноді (залежно від середовища та налаштувань) застосунок може навіть завершитися аварійно. А інколи просто з’являються попереджувальні записи в журналі. Це не помилка C#, а наслідок принципів роботи Task-ів.
Як робити правильно?
- В ідеалі — взагалі не використовуйте «fire-and-forget», якщо не впевнені, що завдання не впаде аварійно.
- Якщо асинхронний метод справді має працювати у «fire-and-forget», використовуйте явну обробку помилок усередині методу.
public async Task SendReportSafeAsync()
{
try
{
await Task.Delay(100);
throw new InvalidOperationException("Помилка під час надсилання!");
}
catch (Exception ex)
{
// Записуємо помилку до журналу або обробляємо її
Console.WriteLine($"[Журнал] Виняток: {ex.Message}");
}
}
// Виклик
SendReportSafeAsync();
Узагальнена рекомендація: якщо завдання ніхто не відстежує і ви не використовуєте await, обов’язково обгорніть тіло асинхронного методу в try-catch. Так ви не загубите помилку і зможете принаймні зафіксувати її в журналі.
4. Винятки та паралельні завдання: Task.WhenAll та інші
Часто в реальних застосунках потрібно запускати відразу кілька незалежних асинхронних завдань і чекати на їх завершення. Наприклад, коли ви надсилаєте звіти кільком адресатам паралельно:
var tasks = new List<Task>
{
SendReportAsync(),
SendReportAsync(),
SendReportAsync()
};
await Task.WhenAll(tasks);
Що станеться, якщо одне (або кілька) завдань викине виняток?
Як ловити такі помилки?
Під час використання await з Task.WhenAll(tasks) — якщо хоча б одне завдання завершилося з помилкою, await прокине виняток від першого завдання, що впало (він не буде загорнутий у AggregateException).
Але є нюанс: якщо серед завдань було кілька збоїв, тоді буде кинуто AggregateException із набором внутрішніх винятків.
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
// Якщо це AggregateException — розберемо її
if (ex is AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
Console.WriteLine($"Помилка у завданні: {inner.Message}");
}
else
{
Console.WriteLine($"Помилка: {ex.Message}");
}
}
Для await з однією задачею виняток зазвичай не загортається в AggregateException. А от із WhenAll — цілком може бути!
5. Асинхронні делегати та обробка помилок
У застосунках із користувацьким інтерфейсом (WPF, WinForms, ASP.NET) обробники подій часто оформлюють як асинхронні лямбди. Якщо виняток у такому обробнику «вийде назовні», результат залежатиме від UI‑фреймворку: застосунок може завершитися аварійно або «проковтнути» помилку.
Рекомендація
Завжди використовуйте try-catch усередині асинхронних делегатів:
button.Click += async (sender, args) =>
{
try
{
await SendReportAsync();
}
catch (Exception ex)
{
MessageBox.Show($"Помилка: {ex.Message}");
}
};
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ