C# и .NET

Асинхронность в C#: async/await на практике

Асинхронность в C#: async/await на практике

Асинхронность в C# давно перестала быть «фишкой для серверов» — это базовый навык, без которого не написать ни отзывчивый desktop-интерфейс, ни масштабируемый веб-API. Ключевые слова async и await выглядят обманчиво просто, и именно из-за этой простоты вокруг них накопилось больше всего мифов и опасных ошибок: дедлоки на ровном месте, проглоченные исключения, «асинхронность», которая на деле блокирует поток. В этой статье разберём, как писать асинхронный код правильно, какие грабли встречаются чаще всего и как использовать CancellationToken и Task.WhenAll на практике.

Что на самом деле делает async/await

async не запускает код в отдельном потоке. Это распространённое заблуждение. Метод с модификатором async выполняется синхронно ровно до первого await над незавершённой задачей. В этой точке компилятор «разрезает» метод: текущий вызов возвращает управление вызывающему коду, а продолжение (всё, что после await) выполнится позже, когда задача завершится. Поток при этом не блокируется и возвращается в пул — он может обслуживать другие запросы.

Главная цель асинхронности — не «ускорить» вычисления, а освободить поток на время ожидания ввода-вывода: запроса к БД, HTTP-вызова, чтения файла. Для CPU-bound нагрузки нужен другой инструмент — Task.Run.

// I/O-bound: правильно — await над асинхронным API
public async Task<string> GetUserNameAsync(int id)
{
    using var http = new HttpClient();
    string json = await http.GetStringAsync($"https://api.example.com/users/{id}");
    return ParseName(json);
}

// CPU-bound: тяжёлый расчёт выносим в пул потоков
public Task<long> ComputeAsync(int[] data)
{
    return Task.Run(() => HeavyCalculation(data));
}

Возвращаемые типы

  • Task — асинхронный метод без результата (аналог void).
  • Task<T> — асинхронный метод, возвращающий значение типа T.
  • ValueTask<T> — оптимизация для горячих путей, где результат часто готов синхронно (например, кеш). Не злоупотребляйте: его нельзя дважды await-ить и хранить.
  • async void — допустим только для обработчиков событий. Об этом ниже.

Дедлок на .Result и .Wait()

Самая болезненная ошибка — блокирующее ожидание асинхронной задачи через .Result, .Wait() или GetAwaiter().GetResult() в контексте с синхронизацией (классический ASP.NET, WinForms, WPF). Механизм дедлока такой:

  1. Вы блокируете текущий поток вызовом .Result — поток ждёт завершения задачи.
  2. Задача внутри делает await и по умолчанию пытается вернуть продолжение в тот же захваченный SynchronizationContext (UI-поток или контекст запроса).
  3. Но этот поток уже занят — он заблокирован на шаге 1. Продолжение никогда не выполнится. Взаимная блокировка.
// ОПАСНО: гарантированный дедлок в WPF/WinForms/классическом ASP.NET
public void OnButtonClick(object sender, EventArgs e)
{
    var data = LoadDataAsync().Result; // поток UI блокируется навсегда
    Show(data);
}

// ПРАВИЛЬНО: async «насквозь» (async all the way)
public async void OnButtonClick(object sender, EventArgs e)
{
    var data = await LoadDataAsync();
    Show(data);
}
Правило простое: не смешивайте блокирующий и асинхронный код. Если метод вызывает асинхронный API — он сам должен быть асинхронным. «Async all the way» — это не стиль, а способ не словить дедлок.

В ASP.NET Core SynchronizationContext отсутствует, поэтому .Result там реже приводит к дедлоку — но всё равно вреден: вы занимаете поток пула впустую, снижая пропускную способность. Блокирующее ожидание остаётся антипаттерном.

async void — тихий убийца

Метод async void нельзя await-ить, а значит вызывающий код не знает, когда он завершился и завершился ли успешно. Хуже того, исключение из async void не ловится обычным try/catch вокруг вызова — оно «всплывает» прямо в SynchronizationContext и обычно роняет процесс.

// ПЛОХО: исключение улетит мимо catch и уронит приложение
async void ProcessAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("boom");
}

try
{
    ProcessAsync(); // try НЕ поймает это исключение
}
catch (Exception)
{
    // сюда мы никогда не попадём
}

// ХОРОШО: возвращаем Task — вызывающий может await и поймать ошибку
async Task ProcessAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("boom");
}

Единственное оправданное применение async void — обработчики событий (их сигнатура диктуется делегатом EventHandler). Даже там оборачивайте тело в try/catch, чтобы необработанное исключение не убило приложение.

ConfigureAwait(false): где и зачем

По умолчанию await захватывает текущий контекст синхронизации и возвращает продолжение в него. В UI-приложениях это удобно: после await вы снова на UI-потоке и можете трогать контролы. Но в библиотечном коде и на сервере захват контекста не нужен — он создаёт лишние накладные расходы и, как мы видели, способствует дедлокам.

ConfigureAwait(false) говорит: «продолжение можно выполнить на любом потоке пула, контекст возвращать не нужно».

// В библиотеке/общем коде — освобождаем от контекста
public async Task<byte[]> DownloadAsync(string url)
{
    using var http = new HttpClient();
    var resp = await http.GetAsync(url).ConfigureAwait(false);
    return await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
}
  • В библиотеках используйте ConfigureAwait(false) на каждом await — это защищает потребителей вашего кода от дедлоков.
  • В UI-обработчиках обычно НЕ ставьте: вам нужно вернуться на UI-поток.
  • В ASP.NET Core контекста синхронизации нет, поэтому практический эффект минимален; многие команды его просто не пишут в коде приложения.

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

Долгие операции должны быть отменяемыми. Стандартный механизм — CancellationToken. Вы создаёте CancellationTokenSource, передаёте его токен «насквозь» через цепочку асинхронных вызовов, а операция периодически проверяет токен или передаёт его в нижележащие API.

public async Task<Report> BuildReportAsync(CancellationToken ct)
{
    // Передаём токен в I/O-вызовы — они отменятся сами
    var rows = await _db.QueryAsync("SELECT ...", ct);

    foreach (var row in rows)
    {
        ct.ThrowIfCancellationRequested(); // проверка в цикле
        await ProcessRowAsync(row, ct);
    }
    return Aggregate(rows);
}

// Запуск с таймаутом 5 секунд
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
    var report = await BuildReportAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Операция отменена или превышен таймаут");
}

Ключевые моменты: отмена кооперативная — никто не «убивает» поток силой, код сам обязан реагировать на токен. Отменённая операция бросает OperationCanceledException (или его наследника TaskCanceledException) — это штатное поведение, а не баг. В ASP.NET Core токен запроса доступен прямо в параметре action-метода и срабатывает, когда клиент разорвал соединение.

Параллелизм через Task.WhenAll

Если несколько независимых операций можно выполнять одновременно — не ждите их последовательно. Запустите все задачи, затем дождитесь всех разом через Task.WhenAll. Это сокращает общее время до времени самой медленной операции, а не до их суммы.

// МЕДЛЕННО: 3 запроса по очереди ≈ сумма времён
var a = await GetAsync(1);
var b = await GetAsync(2);
var c = await GetAsync(3);

// БЫСТРО: запускаем сразу, ждём все ≈ время самого долгого
Task<Item>[] tasks =
{
    GetAsync(1),
    GetAsync(2),
    GetAsync(3),
};
Item[] items = await Task.WhenAll(tasks);

Ограничение степени параллелизма

Запустить 10 000 HTTP-запросов одновременно через WhenAll — отличный способ уронить и сервер, и себя. Для контролируемого параллелизма используйте SemaphoreSlim или, в современном .NET, Parallel.ForEachAsync.

// .NET 6+: параллельная обработка с лимитом
var options = new ParallelOptions { MaxDegreeOfParallelism = 8 };
await Parallel.ForEachAsync(urls, options, async (url, ct) =>
{
    var data = await _http.GetStringAsync(url, ct);
    await SaveAsync(data, ct);
});

Исключения в WhenAll

Если несколько задач упали, await Task.WhenAll(...) пробросит только первое исключение. Чтобы увидеть все ошибки, обратитесь к свойству Exception самой задачи WhenAll:

var whenAll = Task.WhenAll(tasks);
try
{
    await whenAll;
}
catch
{
    // в whenAll.Exception.InnerExceptions — ВСЕ исключения
    foreach (var ex in whenAll.Exception!.InnerExceptions)
        _logger.LogError(ex, "Задача упала");
    throw;
}

Итого

  • async/await освобождает поток на время ожидания I/O, а не создаёт новый поток; для CPU-нагрузки используйте Task.Run.
  • Пишите «async all the way» и никогда не блокируйте задачи через .Result / .Wait() — это путь к дедлоку.
  • async void только для обработчиков событий; везде остальное — async Task, иначе теряете обработку исключений.
  • ConfigureAwait(false) — обязательная привычка в библиотечном коде, чтобы не тащить контекст синхронизации и не провоцировать дедлоки.
  • Прокидывайте CancellationToken сквозь всю цепочку вызовов; отмена кооперативная и проявляется через OperationCanceledException.
  • Независимые операции запускайте параллельно через Task.WhenAll, но ограничивайте параллелизм (SemaphoreSlim, Parallel.ForEachAsync) и помните, что WhenAll при await бросает лишь первое исключение.