C# / .NET Конкурентность в C#: async/await, Task, Channels и как не словить дедлок
0%

Конкурентность в C#: async/await, Task, Channels и как не словить дедлок

Конкурентность в C#: async/await и всё вокруг

Это самая важная и одновременно самая коварная тема в C#. Здесь новички теряют недели на дедлоках и «странных зависаниях», а сеньоров отличает именно понимание того, что на самом деле делает await. Разберёмся с первых принципов.

Зачем вообще нужна асинхронность

Представьте веб-сервер, обрабатывающий 10 000 одновременных запросов. Каждый запрос идёт в базу данных и ждёт ответа ~50 мс. Если на каждый запрос выделить поток, который блокируется в ожидании, вам нужно 10 000 потоков. Поток в .NET — это ~1 МБ стека плюс накладные расходы ОС на переключение контекста. 10 000 потоков = 10 ГБ только на стеки и коллапс планировщика.

Ключевое наблюдение: пока поток ждёт ответа от БД, он ничего не делает — просто занимает память. Асинхронность решает именно это: освободить поток на время ожидания ввода-вывода, чтобы он обслуживал другие запросы, а когда ответ придёт — продолжить.

Важно различать два вида работы:

  • I/O-bound — ожидание внешнего ресурса (сеть, диск, БД). Здесь поток не нужен — нужен async/await. Это «настоящая» асинхронность без потоков.
  • CPU-bound — тяжёлые вычисления. Здесь нужен другой потокTask.Run, параллелизм. Асинхронность сама по себе тут не ускорит.

Путаница между этими двумя — источник половины ошибок. async не про «сделать быстрее», а про «не блокировать поток на ожидании».

TAP: Task-based Asynchronous Pattern

Современная модель называется TAP. Асинхронная операция — это метод, возвращающий Task (нет результата) или Task<T> (результат T), помеченный async, а точки ожидания — оператором await.

public async Task<int> GetContentLengthAsync(string url, CancellationToken ct)
{
    using var client = new HttpClient();
    // await освобождает поток на время сетевого запроса
    string content = await client.GetStringAsync(url, ct);
    return content.Length;   // продолжение выполнится, когда придёт ответ
}

Task — это обещание («future»/«promise»): объект, представляющий операцию, которая завершится в будущем успехом, ошибкой или отменой.

Что делает await на самом деле: машина состояний

Это ключ ко всему. async/await — не потоки и не магия. Компилятор переписывает ваш async-метод в машину состояний (state machine). Каждый await разбивает метод на «до» и «после» — это отдельные состояния.

Грубая иллюстрация того, во что превращается метод выше:

Что происходит по шагам:

  1. Вызывается GetContentLengthAsync. Выполняется синхронно до первого await.
  2. await проверяет: операция уже завершена? Если да — продолжаем синхронно (важная оптимизация!). Если нет — регистрируем «продолжение» (continuation) и возвращаем управление вызывающему, отдавая ему незавершённый Task. Поток свободен.
  3. Когда I/O завершается, продолжение планируется на выполнение (обычно на потоке из пула). Машина состояний вызывает MoveNext() и продолжает с места после await.

Практические следствия:

  • До первого await (и до первого неполного await) код выполняется синхронно на вызывающем потоке.
  • После await вы можете оказаться на другом потоке. Не полагайтесь на thread-local состояние через await без понимания контекста.
  • async-метод, где все await завершаются синхронно, вообще не покидает поток — тогда важна экономия аллокаций (см. ValueTask).

Глубочайший разбор — Stephen Toub, «How Async/Await Really Works in C#» (devblogs.microsoft.com/dotnet/how-async-await-really-works).

Task vs ValueTask

Task<T> — это класс, то есть каждый его экземпляр — аллокация в куче. Если метод асинхронный, но часто завершается синхронно (например, читает из кэша), вы платите за аллокацию Task впустую. Для таких горячих путей есть ValueTask<T> — структура, которая не аллоцирует, когда результат готов сразу.

public ValueTask<User> GetUserAsync(int id, CancellationToken ct)
{
    // Быстрый путь: значение в кэше — синхронно, без аллокации Task
    if (_cache.TryGetValue(id, out var user))
        return new ValueTask<User>(user);

    // Медленный путь: реальный async
    return new ValueTask<User>(LoadFromDbAsync(id, ct));
}

Правила использования ValueTask (нарушение = баги):

  • Не await-ьте дважды. ValueTask можно ожидать только один раз.
  • Не блокируйте через .Result/.GetAwaiter().GetResult().
  • Не запускайте параллельно несколько await над одним ValueTask.
  • Если нужно сохранить/переиспользовать — сначала .AsTask().

Совет: возвращайте Task по умолчанию. Берите ValueTask только когда профилировщик показал, что аллокации Task на этом пути реально дороги. Преждевременная оптимизация ValueTask добавляет сложности без выгоды.

Синхронизационный контекст и ConfigureAwait

Это тема №1 по числу дедлоков. По умолчанию await захватывает текущий контекст синхронизации (SynchronizationContext) и планирует продолжение обратно в него.

Зачем? В UI-приложениях (WPF, WinForms) UI можно трогать только из UI-потока. Контекст гарантирует, что после await вы вернётесь на UI-поток. Аналогично в старом ASP.NET был контекст запроса.

Но у этого есть цена и опасность. Рассмотрим классический дедлок:

// ❌ АНТИПАТТЕРН: sync-over-async в среде с контекстом (UI/старый ASP.NET)
public void Button_Click(object sender, EventArgs e)
{
    // Блокируем UI-поток, ожидая Task
    var data = GetDataAsync().Result;   // ДЕДЛОК!
}

public async Task<string> GetDataAsync()
{
    await Task.Delay(1000);             // захватывает UI-контекст
    return "done";                      // продолжение хочет вернуться на UI-поток...
    // ...но UI-поток заблокирован на .Result. Взаимная блокировка.
}

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

public async Task<string> GetDataAsync()
{
    await Task.Delay(1000).ConfigureAwait(false);   // не возвращаемся в контекст
    return "done";
}

Правила для продакшена:

  • В библиотечном коде (переиспользуемые пакеты) — всегда ConfigureAwait(false). Библиотека не должна зависеть от контекста вызывающего.
  • В коде приложения на ASP.NET Core — можно не писать: у ASP.NET Core нет SynchronizationContext, поэтому дедлока с контекстом там не будет, а ConfigureAwait ничего не меняет. Многие команды отключают анализатор CA2007 в приложениях.
  • Никогда не делайте sync-over-async: .Result, .Wait(), .GetAwaiter().GetResult() над незавершённым Task в потоке с контекстом. Это главная причина дедлоков.

Каноничный текст — Stephen Cleary, «Don’t Block on Async Code» (blog.stephencleary.com/2012/07/dont-block-on-async-code.html).

Отмена: CancellationToken

Асинхронная операция должна уметь отменяться — по таймауту, по отмене запроса пользователем, при остановке сервиса. Механизм — CancellationTokenSource (источник) и CancellationToken (передаётся вниз по цепочке вызовов).

public async Task<Report> BuildReportAsync(CancellationToken ct)
{
    // Комбинируем внешний токен с таймаутом
    using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
    var token = timeoutCts.Token;

    var data = await _db.QueryAsync(token);       // передаём токен вниз

    // Ручная проверка в длинных циклах
    foreach (var item in data)
    {
        token.ThrowIfCancellationRequested();     // бросит OperationCanceledException
        await ProcessAsync(item, token);
    }
    return new Report(data);
}

Правила:

  • Прокидывайте CancellationToken через всю цепочку до самого низа (до вызова БД, HTTP, файловой системы). Токен, который никуда не передаётся, бесполезен.
  • Отмена сигнализируется исключением OperationCanceledException (или наследником TaskCanceledException) — это нормальный, ожидаемый путь, не «ошибка».
  • В ASP.NET Core токен доступен как HttpContext.RequestAborted и инжектится в action-методы автоматически — используйте его, чтобы бросать работу, если клиент отключился.
  • CancellationTokenSource реализует IDisposable — освобождайте (using), особенно при использовании CancelAfter/таймеров.

Параллелизм: несколько операций сразу

await по одной операции — это последовательность. Чтобы запустить операции одновременно, сначала стартуйте их (получите Task-и), потом ждите вместе.

// ❌ Последовательно: суммарное время = сумма времён
var a = await FetchAsync("api/a", ct);
var b = await FetchAsync("api/b", ct);

// ✅ Параллельно: суммарное время = максимум из двух
Task<string> taskA = FetchAsync("api/a", ct);   // стартовали
Task<string> taskB = FetchAsync("api/b", ct);   // стартовали
string[] results = await Task.WhenAll(taskA, taskB);   // ждём оба

Инструменты:

  • Task.WhenAll(tasks) — дождаться всех. Если несколько упали, await бросит первое исключение; остальные лежат в Task.Exception (AggregateException).
  • Task.WhenAny(tasks) — дождаться первого завершившегося (гонки, таймауты, hedged requests).
  • Parallel.ForEachAsync (с .NET 6) — параллельная обработка коллекции с ограничением степени параллелизма. Идеально для «обработать N элементов пачками».
// Ограниченный параллелизм: не больше 8 одновременных операций
await Parallel.ForEachAsync(urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (url, token) =>
    {
        var length = await GetContentLengthAsync(url, token);
        _results.Add((url, length));   // осторожно: коллекция должна быть потокобезопасной!
    });
  • Parallel.For/Parallel.ForEach — для CPU-bound работы (data parallelism).
  • PLINQ (AsParallel()) — параллельный LINQ для CPU-bound агрегаций:
// CPU-bound: распараллелить тяжёлые вычисления по ядрам
var primes = numbers
    .AsParallel()
    .WithDegreeOfParallelism(Environment.ProcessorCount)
    .Where(IsPrime)
    .ToArray();

Не используйте PLINQ/Parallel для I/O — они рассчитаны на загрузку ядер CPU, а не на ожидание. Для I/O-параллелизма — Task.WhenAll/Parallel.ForEachAsync.

Осторожно с общим состоянием

Как только несколько операций работают одновременно, любое общее изменяемое состояние — источник гонок (race conditions). List<T> не потокобезопасен: два параллельных Add могут повредить внутренний массив. Решения — потокобезопасные коллекции (ConcurrentBag<T>, ConcurrentDictionary) или примитивы синхронизации (ниже).

Примитивы синхронизации

// 1. lock — взаимное исключение для КОРОТКИХ синхронных критических секций
private readonly Lock _gate = new();   // System.Threading.Lock (C# 13/.NET 9)
private int _counter;

public void Increment()
{
    lock (_gate) { _counter++; }       // только один поток внутри
}

// 2. Interlocked — атомарные операции без блокировки (быстрее lock для счётчиков)
private long _total;
public void Add(long v) => Interlocked.Add(ref _total, v);

// 3. SemaphoreSlim — асинхронная блокировка + ограничение параллелизма
private readonly SemaphoreSlim _semaphore = new(initialCount: 3); // max 3 одновременно

public async Task CallApiAsync(CancellationToken ct)
{
    await _semaphore.WaitAsync(ct);    // асинхронное ожидание слота
    try
    {
        await _httpClient.GetAsync("...", ct);
    }
    finally
    {
        _semaphore.Release();          // ВСЕГДА в finally
    }
}

Ключевые правила:

  • Нельзя await внутри locklock рассчитан на синхронный код. Для асинхронной взаимной блокировки используйте SemaphoreSlim(1, 1).
  • Interlocked — самый дешёвый способ для атомарных счётчиков/флагов.
  • Помечайте разделяемые поля, читаемые без блокировки, как volatile только если вы точно понимаете модель памяти — обычно правильнее Interlocked или lock.

System.Threading.Channels: producer/consumer

Когда нужно связать производителей и потребителей данных (очередь задач, конвейер обработки, буферизация), идиоматичный инструмент — Channel<T>. Это высокопроизводительная, потокобезопасная, async-совместимая очередь.

using System.Threading.Channels;

// Ограниченный канал: back-pressure — producer ждёт, если очередь полна
var channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(capacity: 100)
{
    FullMode = BoundedChannelFullMode.Wait
});

// Producer
async Task ProduceAsync(CancellationToken ct)
{
    foreach (var item in GetWork())
        await channel.Writer.WriteAsync(item, ct);   // ждёт, если канал полон

    channel.Writer.Complete();   // сигнал "больше данных не будет"
}

// Consumer(ы) — можно запустить несколько для параллельной обработки
async Task ConsumeAsync(CancellationToken ct)
{
    // await foreach аккуратно завершится, когда Writer.Complete() вызван
    await foreach (var item in channel.Reader.ReadAllAsync(ct))
        await ProcessAsync(item, ct);
}

Почему Channels, а не BlockingCollection или самописная очередь с lock? Channels дают асинхронный back-pressure (ограниченный канал притормаживает producer, вместо того чтобы съесть всю память), не блокируют потоки и оптимизированы. Это стандарт для конвейеров в современном .NET. Разбор — Stephen Toub (devblogs.microsoft.com/dotnet/an-introduction-to-system-threading-channels).

IAsyncEnumerable и await foreach

Что если данные приходят асинхронно по одному (страницы API, строки из БД, события)? Загружать всё в память — расточительно. IAsyncEnumerable<T> — это асинхронный стрим: элементы производятся лениво и асинхронно.

// Асинхронный итератор: yield + async
public async IAsyncEnumerable<Order> StreamOrdersAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    int page = 0;
    while (true)
    {
        var batch = await _api.GetPageAsync(page, ct);   // асинхронная загрузка страницы
        if (batch.Count == 0) yield break;

        foreach (var order in batch)
            yield return order;                          // отдаём по одному, лениво

        page++;
    }
}

// Потребление
await foreach (var order in StreamOrdersAsync(ct).WithCancellation(ct))
{
    await ProcessAsync(order, ct);
}

Атрибут [EnumeratorCancellation] пробрасывает токен из WithCancellation в итератор. IAsyncEnumerable — идеальный тип для стриминга из ASP.NET Core эндпоинтов (сервер отдаёт JSON по мере готовности) и для чтения больших выборок из EF Core (AsAsyncEnumerable()), не удерживая всё в памяти.

Типичные ловушки (то, на чём горят в проде)

1. async void. Метод async void нельзя await-ить, а исключение из него нельзя поймать — оно летит прямо в SynchronizationContext и роняет процесс. Единственное легитимное применение — обработчики событий UI. Везде иначе — async Task.

// ❌ исключение здесь уронит процесс, его негде поймать
public async void ProcessInBackground() { await DoWorkAsync(); }

// ✅
public async Task ProcessInBackgroundAsync() { await DoWorkAsync(); }

2. Дедлок от sync-over-async. .Result/.Wait() над Task в контексте — разобрали выше. Правило: async заражает всю цепочку — «async all the way». Не смешивайте.

3. Забытый (не-await-нутый) Task — «fire and forget». DoAsync(); без await запускает операцию и теряет её: исключения проглатываются, порядок не гарантирован. Если фон нужен намеренно — делайте это явно через BackgroundService/Channel, а не брошенным Task.

4. Task.Run для I/O. Оборачивать асинхронный I/O в Task.Run бессмысленно — вы просто занимаете поток пула, вместо того чтобы освободить его. Task.Run — только для CPU-bound работы, чтобы не блокировать вызывающий (например, UI) поток.

5. Отсутствие ConfigureAwait в библиотеке — потенциальные дедлоки у потребителей с контекстом.

6. Не проброшенный CancellationToken — операции невозможно отменить, ресурсы текут при таймаутах и отключениях клиентов.

7. Гонки на общем состоянии — параллельные Add в List<T>, инкремент без Interlocked. Тесты «иногда падают» — классический симптом.

Диагностика конкурентности в проде

  • Async-стектрейсы в .NET показывают логическую цепочку await, а не только физический стек — читайте их внимательно.
  • dotnet-counters покажет ThreadPool Queue Length — рост очереди пула = у вас где-то блокируются потоки (sync-over-async), пул голодает (thread starvation).
  • dotnet-trace и PerfView — для глубокого анализа.
  • Настройте анализаторы: включите правила про async void, потерянные Task (CS4014 warning на неожиданный Task без await), ConfigureAwait.

Обязательное чтение по всем граблям — David Fowler, AspNetCoreDiagnosticScenarios (AsyncGuidance.md).

Итог

  • Async — про освобождение потоков на I/O, не про скорость вычислений.
  • await = машина состояний, а не поток. До первого незавершённого await — синхронно.
  • ConfigureAwait(false) в библиотеках; никогда не блокируйте на async.
  • Прокидывайте CancellationToken до самого низа.
  • Параллелизм — через WhenAll/Parallel.ForEachAsync; CPU-bound — Parallel/PLINQ.
  • Producer/consumer — Channel<T>; стриминг — IAsyncEnumerable.
  • Избегайте async void, sync-over-async, брошенных Task и гонок на общем состоянии.

Что дальше

Тестирование — как покрывать всё это тестами: юнит, интеграционные и e2e, моки, property-based, Testcontainers и встраивание в CI.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов