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["Main поток"]
    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#, а следствие логики работы тасков.

Как делать правильно?

  • В идеале: никогда не используйте "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}");
    }
};
2
Задача
C# SELF, 59 уровень, 4 лекция
Недоступна
Асинхронная обработка исключений в обработчике
Асинхронная обработка исключений в обработчике
1
Опрос
Асинхронное программирование, 59 уровень, 4 лекция
Недоступен
Асинхронное программирование
Асинхронность vs. Многопоточность
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ