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 проверяет задачу:
- если задача уже завершена, выполнение продолжается синхронно;
- если задача ещё работает, метод приостанавливается, управление возвращается вызывающему коду;
- после завершения задачи метод продолжает выполнение с точки
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:
- 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, "Сетевая ошибка");
}
Для 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 handler | async void |
| Обычный метод сервиса/библиотеки | async Task / Task<T> |
Официальные материалы: Асинхронное программирование в C#, Task-based asynchronous pattern.
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). C# как язык платформы .NET - устройство проекта, роль `.cs`-файлов и базовые принципы организации кода. C# — это современный, типизированный язык программирования общего назначения, разработанный корпорацией Microsoft. Справочник-шпаргалка по конфигурациям в C — типы, синтаксис, стандартная библиотека, типовые паттерны. Не заменяет пошаговое обучение. Учебный курс — раздел. Набор советов, правил, принципов и обычаев в разработке на этом языке. Кавычки, точки, запятые, скобки и прочие знаки препинания. Ключевые слова C# - назначение базовых конструкций языка и примеры их применения в типичном коде. Набор функций, которые включены в стандартную библиотеку языка. Пространства имён в C# - организация модулей, `file-scoped namespace` и поддержание чистой структуры кода. манипулировать данными (арифметические, логические, сравнительные операторы). Самый базовый способ ветвления — оператор if. Он проверяет условие и, если оно истинно (true), выполняет блок кода. Обработка исключений в C# - типы исключений, `try/catch/finally` и практики надежного кода. Платформо-зависимые исключения — например, PlatformNotSupportedException используется в кроссплатформенных API, когда функция недоступна на текущей ОС.C# - язык программирования платформы .NET
Что требуется знать перед началом изучения языка программирования C#
Справочник по конфигурациям в C#
Рекомендации по разработке на C#
Синтаксис и пунктуация в C#
Ключевые слова языка C#
Встроенные функции и методы C#
Пространства имён в C#
Управляющие конструкции и логические операторы
Условные выражения и ветвления
Обработка исключений в C#
Иерархия классов исключений в C#