Конкурентность в 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 разбивает метод
на «до» и «после» — это отдельные состояния.
Грубая иллюстрация того, во что превращается метод выше:
Что происходит по шагам:
- Вызывается
GetContentLengthAsync. Выполняется синхронно до первогоawait. awaitпроверяет: операция уже завершена? Если да — продолжаем синхронно (важная оптимизация!). Если нет — регистрируем «продолжение» (continuation) и возвращаем управление вызывающему, отдавая ему незавершённыйTask. Поток свободен.- Когда 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внутриlock—lockрассчитан на синхронный код. Для асинхронной взаимной блокировки используйте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).
bounded, cap=100)] P2[Producer 2] -->|WriteAsync| Ch Ch -->|ReadAllAsync| C1[Consumer 1] Ch -->|ReadAllAsync| C2[Consumer 2] Ch -.back-pressure.-> P1
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.