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}");
}
};
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ