name: dotnet-async-patterns description: Ловушки .NET async/await — синхронные блокировки и deadlock, отмена через CancellationToken, ограничение параллелизма, fire-and-forget, декомпозиция pipeline, чтение вывода внешних процессов без deadlock на буфере. Активируется при async, await, Task, deadlock, .Result, .Wait(), CancellationToken, SemaphoreSlim, параллелизм, async void, fire-and-forget, thread pool starvation, внешний процесс Process, stdout/stderr
Async Patterns — ловушки и anti-patterns
Главные анти-паттерны
.Result / .Wait() — deadlock и starvation
Плохо: var result = GetDataAsync().Result или .GetAwaiter().GetResult()
Правильно: var result = await GetDataAsync(ct)
Почему: блокирует поток синхронно. В ASP.NET до .NET 6 — гарантированный deadlock (SynchronizationContext). В .NET 6+ — thread pool starvation под нагрузкой
async void — исключения теряются
Плохо: public async void ProcessAsync() { } — нельзя await, исключения уходят в UnobservedTaskException
Правильно: public async Task ProcessAsync(CancellationToken ct) { }
Почему: исключение в async void crashит процесс (ASP.NET), или тихо теряется. Вызывающий код не знает об ошибке. Единственный допустимый случай — event handlers в WPF/WinForms
Fire-and-forget без обработки ошибок
Плохо: _ = SendEmailAsync(); — исключение никто не увидит
Правильно: _ = Task.Run(async () => { try { await SendEmailAsync(); } catch (ex) { _logger.LogError(ex, "..."); } })
Почему: unobserved exception → в .NET 4 crashит процесс, в .NET 6+ тихо логируется в EventLog. В обоих случаях — ошибка потеряна для приложения
Task.Run для оборачивания sync в async
Плохо: return Task.Run(() => _repo.GetOrder(id)) — fake async, пустая трата потока из пула
Правильно: используй реально асинхронный метод (FirstOrDefaultAsync, ReadAsync)
Почему: Task.Run берёт поток из ThreadPool для выполнения sync-кода. Под нагрузкой — thread pool exhaustion. Не масштабируется
Ненужный async/await (overhead state machine)
Плохо: async Task<Order> Get(int id, CancellationToken ct) { return await _repo.GetByIdAsync(id, ct); }
Правильно: Task<Order> Get(int id, CancellationToken ct) => _repo.GetByIdAsync(id, ct) — прямой return Task
Почему: async/await создаёт state machine (~100 bytes alloc). Для простого проброса — overhead без выгоды. НО: оставь async/await если есть try/catch, using или несколько await
CancellationToken
Забытый CancellationToken
Плохо: public async Task<Order> GetOrderAsync(int id) — без CancellationToken
Правильно: пробрасывай CancellationToken ct до конца цепочки: Controller → Service → Repository → EF
Почему: клиент ушёл (закрыл вкладку), ASP.NET отменяет запрос, но без CancellationToken сервер продолжает тяжёлую работу вхолостую
Нет ThrowIfCancellationRequested в долгих циклах
Плохо: foreach (var item in 10000Items) await ProcessAsync(item) — без проверки отмены
Правильно: ct.ThrowIfCancellationRequested() в начале каждой итерации
Почему: цикл на 10000 элементов продолжает работать даже после отмены запроса. CancellationToken проверяется только в async-вызовах внутри, но между ними — нет
Linked token без timeout
Плохо: вызов внешнего API без timeout: await _httpClient.GetAsync(url, ct)
Правильно: using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(30));
Почему: внешний API завис → ваш запрос висит бесконечно (или до клиентского timeout). Linked token = собственный timeout + наследование отмены от родителя
Блокировки
lock + await = deadlock
Плохо: lock (_obj) { await DoSomethingAsync(); } — компилятор даже не позволит, но есть обходы через Monitor
Правильно: SemaphoreSlim(1, 1) как async lock
Почему: lock захватывает поток. await отпускает поток. Когда continuation возвращается — lock занят другим потоком → deadlock
SemaphoreSlim без try/finally
Плохо: await _semaphore.WaitAsync(ct); await DoWork(); _semaphore.Release();
Правильно: await _semaphore.WaitAsync(ct); try { await DoWork(); } finally { _semaphore.Release(); }
Почему: если DoWork бросит исключение — Release не вызовется, семафор навечно заблокирован, все последующие вызовы повиснут
Unbounded parallelism
Плохо: var tasks = urls.Select(url => httpClient.GetAsync(url)); await Task.WhenAll(tasks);
Правильно: SemaphoreSlim(maxConcurrency) или Parallel.ForEachAsync(MaxDegreeOfParallelism: 10)
Почему: 10000 URLs = 10000 одновременных HTTP запросов → socket exhaustion, target server DDoS, timeout cascades
Pipeline composition
Побочный эффект синхронно блокирует критичный путь
Плохо: handler делает обязательный шаг (save в БД) + опциональный внешний вызов (analytics / notification) в одной цепочке await, всё — внутри транзакции пользовательского сценария
Правильно: обязательные шаги — синхронно до SaveChangesAsync, побочные эффекты — через domain event / message bus / outbox после коммита
Почему: побочный эффект (медленный внешний клиент, flaky API) блокирует критичный путь и может его завалить. Если внешний вызов упадёт — пользовательский сценарий должен был завершиться, а не откатиться. Публикация события даёт независимую retry-политику для побочного эффекта и сохраняет ответ пользователю
Один handler делает >2 внешних вызовов последовательно
Плохо: handler ждёт VCS → ждёт analytics → ждёт notification service → возвращает ответ, latency = сумма всех
Правильно: декомпозиция через события / outbox (EntityCreated публикуется после save, каждый subscriber делает свой шаг независимо); параллельные независимые вызовы через Task.WhenAll (с ограничением concurrency)
Почему: последовательные внешние вызовы = накопленный timeout (3 сервиса × 10s = 30s). Падение одного ломает цепочку полностью. Декомпозиция изолирует failure domains + даёт независимый retry на каждый шаг
Падение побочного эффекта роняет основной сценарий
Плохо: try { await SaveAsync(); await NotifyAsync(); } catch { /* откат всего */ } — notification падает, транзакция откачена
Правильно: save в одной транзакции → commit → публикация события → subscriber делает notification со своим retry
Почему: notification не должна откатывать данные. Разделение «persist» и «side-effect» по транзакционной границе (с outbox для гарантии at-least-once) — единственный способ сохранить корректность при падении побочных шагов
Последовательный await в foreach для батча независимых вызовов
Плохо: foreach (var item in batch) await client.SendAsync(item); // latency = N × T
Правильно: Parallel.ForEachAsync с ограниченным MaxDegreeOfParallelism
Почему: независимые вызовы к одному endpoint выигрывают от параллелизма; DOP ограничивают по нагрузочной способности downstream (для HTTP стартовая точка 10-20, дальше — по нагрузочному тесту). Исключение: если отправка идёт через очередь — буферизация делает параллелизм бессмысленным
Асинхронный контекст
HttpContext в фоновом потоке
Плохо: Task.Run(async () => { var user = _httpContextAccessor.HttpContext?.User; }) — HttpContext = null
Правильно: извлеки данные ДО перехода в фон: var userId = HttpContext.User.FindFirst("sub")?.Value;
Почему: HttpContext привязан к HTTP-запросу. В Task.Run — другой поток, запрос может уже завершиться → null или disposed
Внешние процессы (System.Diagnostics.Process)
stdout и stderr читаются не одновременно → deadlock на буфере пайпа
Плохо: stdout читается до конца, а stderr — отдельно: после выхода (WaitForExitAsync(), затем StandardError.ReadToEndAsync()) или условно (if (ExitCode != 0) { await StandardError.ReadToEndAsync(); }). Асинхронный await от deadlock не спасает — блокировка на уровне ОС
Правильно: запустить чтение ОБОИХ потоков до ожидания выхода — var errTask = p.StandardError.ReadToEndAsync(ct); var output = await p.StandardOutput.ReadToEndAsync(ct); var err = await errTask; await p.WaitForExitAsync(ct); — либо подписка на OutputDataReceived + ErrorDataReceived с Begin*ReadLine()
Почему: буфер пайпа ОС ограничен (~64 KB на Windows/Linux). Пока родитель читает только stdout, дочерний процесс наполняет буфер stderr и блокируется на записи; stdout не завершится, WaitForExit не вернётся → взаимный deadlock. Зависит от объёма вывода: на коротком тесте незаметно, на реальных данных виснет. Чтение stderr «только при ошибке после WaitForExit» — тот же баг, ревью отклоняет его и без воспроизведения
WaitForExit раньше дочитывания вывода
Плохо: process.WaitForExit(); var output = process.StandardOutput.ReadToEnd(); — ожидание выхода до чтения
Правильно: сначала запустить чтение обоих потоков, затем await WaitForExitAsync(ct); при синхронном API с таймаутом после WaitForExit(timeout) вызвать WaitForExit() без таймаута, чтобы дождаться завершения async-хендлеров вывода
Почему: WaitForExit(timeout) не гарантирует, что асинхронные хендлеры *DataReceived завершились — последние строки stdout/stderr теряются. Вывод читать ДО ожидания выхода, не после
Чек-лист
- Нет .Result / .Wait() / .GetAwaiter().GetResult()
- Нет async void (кроме event handlers)
- CancellationToken пробрасывается до конца цепочки
- Долгие циклы: ThrowIfCancellationRequested()
- lock → SemaphoreSlim в async коде, обязательно с try/finally
- Параллелизм ограничен (SemaphoreSlim / MaxDegreeOfParallelism)
- HttpContext: извлекай данные ДО фоновой задачи
- Побочные эффекты вынесены из критичного пути через события / outbox
- Handler не делает >2 последовательных внешних вызовов без декомпозиции
- Process: stdout и stderr читать параллельно (async), не последовательно — иначе deadlock на буфере пайпа