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(). Возможен deadlock и/или неожиданный AggregateException.
Ошибка №2: запуск fire‑and‑forget без внутреннего try-catch — задача может тихо упасть, диагностики не будет.
Ошибка №3: пропущенные ошибки в параллельных циклах — часть работы не выполнена, а вы этого не замечаете.
Ошибка №4: неразличение отмены (OperationCanceledException) и реальных ошибок.
Ошибка №5: логирование только первой ошибки среди нескольких задач — остальные остаются «в тени».
Ошибка №6: переиспользование одного объекта Exception для всех задач — у каждой ошибки должен быть свой экземпляр.
Ошибка №7: отсутствие обработки исключений в UI‑потоке — фоновая ошибка остаётся незамеченной, интерфейс ведёт себя «призрачно».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ