Task и async/await в C#
Task и async/await дают управляемую модель асинхронного выполнения в .NET. Эта глава закрывает практические вопросы, которые постоянно всплывают в код-ревью и на собеседованиях — что именно представляет Task, почему await безопаснее блокирующих ожиданий, где уместен Task.Run, как обрабатывать ошибки и отмену.
Что такое Task и Task<T>
Task представляет операцию, которая завершится в будущем. Часто удобно описывать его как "обещание завершения".
Task<T> представляет операцию, которая вернёт значение типа T после завершения.
Task saveTask = SaveAsync(order, ct); // операция без результата
Task<int> countTask = GetCountAsync(ct); // операция с результатом int
Разбор:
Task saveTaskхранит асинхронную операцию, которая завершится без возвращаемого значения.Task<int> countTaskпредставляет операцию с результатом типаint.- Обе строки запускают или получают задачи без немедленного ожидания результата.
- Такой стиль удобен для параллельного старта нескольких независимых операций.
- Комментарии подчёркивают ключевое отличие между
TaskиTask<T>.
Task и Task<T> описывают состояние вычисления:
- Running / WaitingForActivation — операция в процессе;
- RanToCompletion — успешно завершена;
- Canceled — отменена;
- Faulted — завершена с исключением.
Статусы доступны через Status, быстрые проверки через IsCompleted, IsFaulted, IsCanceled.
Сколько Task можно создать на практике
В .NET количество Task определяется доступной памятью, пропускной способностью ThreadPool и количеством одновременно активной работы. Жёсткого числового лимита в стиле "ровно N задач" рантайм не задаёт.
Ключевой момент для собеседования: Task описывает состояние операции и её продолжения. Тысячи и десятки тысяч задач допустимы, когда:
- значительная часть операций ждёт I/O;
- операции короткие и корректно отменяются;
- система ограничивает параллелизм через очередь,
SemaphoreSlimилиChannel.
Практический ориентир в серверных сценариях:
- I/O-bound — обычно выдерживаются большие очереди задач;
- CPU-bound — эффективный параллелизм близок к числу ядер CPU, остальное даёт рост очереди и задержек.
Код ITЗагрузка примера кода…
Разбор:
SemaphoreSlim(32)ограничивает количество одновременно выполняемых операций.WaitAsync(ct)асинхронно ждёт слот, не блокируя поток.- Основная работа выполняется в
ProcessAsync(item, ct)после получения разрешения. - Блок
finallyсgate.Release()гарантирует возврат слота даже при исключении. Task.WhenAll(tasks)дожидается завершения всех задач из проекцииSelect.
Такой ответ показывает зрелость: фокус держится на модели нагрузки и управлении конкуренцией.
Откуда берётся Task в реальном коде
Асинхронный метод
Самый частый источник Task — метод с async:
public async Task<string> LoadPageAsync(HttpClient client, string url, CancellationToken ct)
{
return await client.GetStringAsync(url, ct);
}
Разбор:
public async Task<string>задаёт контракт асинхронного метода, возвращающего строку.HttpClient clientпередаётся как зависимость и может быть переиспользован.CancellationToken ctпередаётся в сетевой вызов для управляемой отмены.await client.GetStringAsync(...)выполняет I/O без блокировки потока.- Возврат строки происходит после фактического завершения HTTP-запроса.
Task.Run для CPU-работы
Task.Run уместен, когда нужно вынести тяжёлое вычисление в пул потоков:
var hash = await Task.Run(() => ComputeHash(largeBuffer), ct);
Разбор:
Task.Run(...)отправляет CPU-вычисление в пул потоков.- Лямбда
() => ComputeHash(largeBuffer)содержит синхронную тяжёлую операцию. awaitвозвращает управление вызывающему контексту до завершения расчёта.ctпозволяет отменить задачу до старта или во время поддержки отмены внутри операции.- Результат вычисления присваивается в
hashпосле успешного завершения.
Для I/O (HttpClient, БД, файловые async API) Task.Run обычно не нужен.
TaskCompletionSource<T>
TaskCompletionSource<T> используют для обёртки callback-сценариев в Task:
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
// где-то позже:
tcs.SetResult("ok");
return tcs.Task;
Разбор:
TaskCompletionSource<string>вручную управляет завершением задачи с результатом строки.- Опция
RunContinuationsAsynchronouslyпредотвращает синхронный запуск continuations в потокеSetResult. SetResult("ok")переводит задачу в успешное состояние и задаёт возвращаемое значение.tcs.Taskотдаёт наружуTask<string>, который можноawaitкак обычную асинхронную операцию.- При ошибках или отмене вместо
SetResultиспользуютSetExceptionиSetCanceled.
Что делает async
async маркирует метод как асинхронный для компилятора. Компилятор строит state machine и логику продолжений вокруг await.
async сам по себе не запускает код в другом потоке. Поток меняется только если это требуется scheduler-ом, пулом потоков или конкретной async-операцией.
Если внутри метода нет await, код выполняется синхронно до конца. Такой метод лучше переписать:
- убрать
asyncи вернуть готовыйTaskчерезTask.FromResult/Task.CompletedTask; - либо действительно сделать асинхронное ожидание.
Что делает await
await проверяет задачу:
- если задача уже завершена, выполнение продолжается синхронно;
- если задача ещё работает, метод приостанавливается, управление возвращается вызывающему коду;
- после завершения задачи метод продолжает выполнение с точки
await.
Это и есть ключевая польза: поток не простаивает в блокировке.
var userTask = client.GetUserAsync(id, ct);
var user = await userTask; // текущий поток не блокируется
Разбор:
GetUserAsync(id, ct)создаёт задачу получения пользователя.- Переменная
userTaskпозволяет отложенно ждать результат и, при необходимости, комбинировать ожидание. await userTaskприостанавливает метод до завершения операции.- Во время ожидания поток не блокируется и может обслуживать другую работу.
- После завершения результат типа пользователя попадает в
user.
Как ждать завершения задачи
Базовый и правильный путь
await task — основной способ ожидания в современном C#.
Цепочки через ContinueWith
ContinueWith применяют в редких сценариях ручного управления scheduler-ом и продолжениями. В прикладном коде await обычно читается лучше и проще в сопровождении.
Блокирующие ожидания
task.Wait(), task.Result, task.GetAwaiter().GetResult() блокируют текущий поток. Это допустимо в узких местах синхронного boundary-кода, где вы точно контролируете контекст выполнения.
В UI и legacy ASP.NET такие вызовы часто провоцируют дедлоки.
Почему Result и Wait могут привести к дедлоку
Типичный сценарий UI:
- UI-поток вызывает
var data = LoadAsync().Result;; LoadAsyncвнутри делаетawait, продолжение хочет вернуться в UI-контекст;- UI-поток заблокирован на
.Result; - продолжение ждёт UI-поток, UI ждёт продолжение.
Получается взаимная блокировка.
В ASP.NET Core риск ниже из-за отсутствия классического SynchronizationContext, но блокирующие ожидания всё равно вредят масштабированию и загрузке пула потоков.
Обработка ошибок
Исключения из задачи поднимаются в точке await:
try
{
await ProcessAsync(ct);
}
catch (HttpRequestException ex)
{
logger.LogWarning(ex, "Сетевая ошибка");
}
Разбор:
- Блок
tryохватываетawait, поэтому исключения из задачи обрабатываются вcatch. ProcessAsync(ct)может выброситьHttpRequestExceptionпри сетевых проблемах.- Типизированный
catchловит только нужный класс ошибок, не скрывая другие исключения. logger.LogWarning(...)сохраняет детали сбоя в логах без аварийного завершения процесса.- Такой шаблон формирует управляемую политику обработки transient-ошибок.
Для Task.WhenAll:
- при
await Task.WhenAll(...)вы получите исключение, если упала хотя бы одна задача; - для анализа всех причин смотрят
Task.Exception(агрегированные ошибки).
Отмена через CancellationToken
Отмена в .NET кооперативная:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await ImportAsync(cts.Token);
Разбор:
CancellationTokenSource(TimeSpan.FromSeconds(5))задаёт автоматический таймаут отмены через 5 секунд.using varгарантирует освобождение ресурсовctsпосле завершения блока.cts.Tokenпередаётся вImportAsyncкак механизм кооперативной отмены.- Если операция не уложится в лимит, будет инициирована отмена и метод должен корректно завершиться.
- Подход объединяет дедлайн выполнения и единый контракт отмены.
Рекомендации:
- передавайте
CancellationTokenв публичные async API; - прокидывайте токен глубже в
HttpClient, EF Core, файловые async методы; - при отмене выбрасывайте
OperationCanceledException(обычно черезtoken.ThrowIfCancellationRequested()).
async void и fire-and-forget
async void используют для обработчиков событий UI:
private async void SaveButton_Click(object? sender, EventArgs e)
{
await SaveAsync(CancellationToken.None);
}
Разбор:
async voidдопустим здесь, потому что это обработчик UI-события.- Параметры
object? sender, EventArgs eсоответствуют стандартной сигнатуре событий .NET. await SaveAsync(...)не замораживает UI-поток во время сохранения.CancellationToken.Noneотключает отмену в конкретном примере.- Для обычных сервисных методов выбирают
Task, чтобы их можно было ждать и тестировать.
В остальном коде выбирайте async Task. Это позволяет:
- дождаться завершения;
- обработать исключения;
- корректно тестировать метод.
Fire-and-forget допустим в инфраструктурных сценариях с явным логированием ошибок и контролем жизненного цикла операции (например, BackgroundService).
ConfigureAwait(false) и контекст
await по умолчанию продолжает выполнение в захваченном контексте, если он существует.
ConfigureAwait(false) отключает возврат в захваченный контекст. Это полезно в библиотеках, где UI-контекст не требуется:
await stream.WriteAsync(buffer, ct).ConfigureAwait(false);
Разбор:
WriteAsync(buffer, ct)запускает асинхронную запись в поток.ConfigureAwait(false)отключает требование вернуться в исходный контекст после ожидания.- Это снижает риск дедлоков в библиотечном коде и уменьшает накладные расходы на маршалинг контекста.
- Паттерн полезен там, где продолжение не зависит от UI-потока или специфичного
SynchronizationContext. - В ASP.NET Core эффект часто нейтральный, но в библиотеках приём остаётся актуальным.
В ASP.NET Core эффект обычно нейтральный, потому что классический контекст синхронизации не используется как в старых UI/ASP.NET моделях.
Практический чек-лист
- Возвращайте
Task/Task<T>из асинхронных методов. - Используйте
awaitдо конца цепочки вызовов. - Избегайте
.Resultи.Wait()в приложениях с контекстом. - Передавайте
CancellationToken. - Оборачивайте
awaitвtry/catchтам, где есть политика обработки. - Используйте
Task.Runдля CPU-bound задач, не для I/O. - Оставляйте
ContinueWithдля специальных low-level сценариев.
Краткая шпаргалка
| Что нужно | Что использовать |
|---|---|
| Асинхронно дождаться завершения | await task |
| Запустить CPU-вычисление в фоне | Task.Run |
| Сразу вернуть готовый результат | Task.FromResult(value) |
| Сразу вернуть успешное завершение без значения | Task.CompletedTask |
| Одновременный запуск нескольких операций | Task.WhenAll |
| Отмена операции | CancellationToken |
| UI event handler | async void |
| Обычный метод сервиса/библиотеки | async Task / Task<T> |
Официальные материалы: Асинхронное программирование в C#, Task-based asynchronous pattern.
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.