Перейти к основному содержимому

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 проверяет задачу:

  1. если задача уже завершена, выполнение продолжается синхронно;
  2. если задача ещё работает, метод приостанавливается, управление возвращается вызывающему коду;
  3. после завершения задачи метод продолжает выполнение с точки 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:

  1. UI-поток вызывает var data = LoadAsync().Result;;
  2. LoadAsync внутри делает await, продолжение хочет вернуться в UI-контекст;
  3. UI-поток заблокирован на .Result;
  4. продолжение ждёт 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 handlerasync void
Обычный метод сервиса/библиотекиasync Task / Task<T>

Официальные материалы: Асинхронное программирование в C#, Task-based asynchronous pattern.


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.