JavaRush /Курси /C# SELF /Обробка винятків у асинхронному коді

Обробка винятків у асинхронному коді

C# SELF
Рівень 59 , Лекція 4
Відкрита

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}");
    }
};
1
Опитування
Асинхронне програмування, рівень 59, лекція 4
Недоступний
Асинхронне програмування
Асинхронність vs. Мультипотоковість
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ