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

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 и 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, остальное даёт рост очереди и задержек.
// Ограничение параллелизма: одновременно работает до 32 задач
var gate = new SemaphoreSlim(32);
var tasks = items.Select(async item =>
{
await gate.WaitAsync(ct);
try
{
await ProcessAsync(item, ct);
}
finally
{
gate.Release();
}
});

await Task.WhenAll(tasks);

Такой ответ показывает зрелость: фокус держится на модели нагрузки и управлении конкуренцией.


Откуда берётся Task в реальном коде

Асинхронный метод

Самый частый источник Task — метод с async:

public async Task<string> LoadPageAsync(HttpClient client, string url, CancellationToken ct)
{
return await client.GetStringAsync(url, ct);
}

Task.Run для CPU-работы

Task.Run уместен, когда нужно вынести тяжёлое вычисление в пул потоков:

var hash = await Task.Run(() => ComputeHash(largeBuffer), ct);

Для 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;

Что делает 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; // текущий поток не блокируется

Как ждать завершения задачи

Базовый и правильный путь

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, "Сетевая ошибка");
}

Для Task.WhenAll:

  • при await Task.WhenAll(...) вы получите исключение, если упала хотя бы одна задача;
  • для анализа всех причин смотрят Task.Exception (агрегированные ошибки).

Отмена через CancellationToken

Отмена в .NET кооперативная:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await ImportAsync(cts.Token);

Рекомендации:

  • передавайте 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 Task. Это позволяет:

  • дождаться завершения;
  • обработать исключения;
  • корректно тестировать метод.

Fire-and-forget допустим в инфраструктурных сценариях с явным логированием ошибок и контролем жизненного цикла операции (например, BackgroundService).


ConfigureAwait(false) и контекст

await по умолчанию продолжает выполнение в захваченном контексте, если он существует.

ConfigureAwait(false) отключает возврат в захваченный контекст. Это полезно в библиотеках, где UI-контекст не требуется:

await stream.WriteAsync(buffer, ct).ConfigureAwait(false);

В 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 как основа веб-интеграций.

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").