Async/await в .NET — золото или яд? Как лучше писать асинхронный код 💥
Давайте взглянем на достаточно простой метод:
public async Task ProcessCheckoutAsync(Guid orderId)
{
var order = await _orderService.GetAsync(orderId);
var payment = await _paymentService.ProcessAsync(order);
var shipping = await _warehouseService.ReserveAsync(payment);
await _analytics.FireAsync(order);
await _notifications.SendAsync(order);
return await _mapper.MapAsync(shipping);
}
Код читается легко, понятно:
🔵 получаем данные по заказу
🔵 проводим проводку
🔵 оформляем резерв на складе
🔵 на финальном этапе отправляем уведомление и скидываем данные в аналитику.
(Тут пока не будем обращать внимание на отсутствие обработки ошибок, ретраях, аутбоксах и тд)
Код проходит ревью, заливается в прод и все работает. Но, через некоторое время, он начинает
тайно тормозить — особенно в часы пик.
Почему? Потому что
все вызовы идут строго последовательно, хотя
часть из них могла выполняться параллельно.
Вот что происходило в реальности:
🔵 _orderService.GetAsync() → ~80 мс
🔵 _paymentService.ProcessAsync() → ~200 мс
🔵 _warehouseService.ReserveAsync() → ~100 мс
🔵 _analytics.FireAsync() → ~30 мс (и не зависит от payment!)
🔵 _notifications.SendAsync() → ~40 мс (тоже не зависит от payment!)
Но из-за линейной цепочки
общая latency = 80 + 200 + 100 + 30 + 40 = 450 мс.
А могло быть так:
🔵 Запускаем _orderService.GetAsync() → ждём 80 мс.
🔵 Как только получили order —
запускаем payment + analytics + notifications одновременно (они не зависят друг от друга).
🔵 Как только payment завершился → запускаем shipping.
Тогда:
➕ 80 мс (order)
➕ max(200 мс (payment), 30 мс (analytics), 40 мс (notifications)) =
200 мс➕ 100 мс (shipping)
🟰
итого ~380 мсЭкономия ~70 мс — это ~15%, но в реальном сервисе может быть
ещё 2–3 side-эффекта:
🔸 обновление кэша в Redis,
🔸 отправка события в Kafka,
🔸 запись в локальный audit-log.
Все они
не блокируют основной поток, но из-за линейного await’а —
тянут общее время.
С учетом этих зависимостей, можно переписать код следующим образом:
public async Task ProcessCheckoutAsync(Guid orderId)
{
// Шаг 1: получаем заказ
var order = await _orderService.GetAsync(orderId);
// Шаг 2: запускаем ВСЁ, что можно — параллельно
var paymentTask = _paymentService.ProcessAsync(order);
var analyticsTask = _analytics.FireAsync(order);
var notificationTask = _notifications.SendAsync(order);
var cacheUpdateTask = _cache.InvalidateUserCartAsync(order.UserId);
var auditLogTask = _audit.LogAsync($"Checkout initiated for {orderId}");
// Ждём payment — он критичен для shipping
var payment = await paymentTask;
// Шаг 3: резервируем склад
var shipping = await _warehouseService.ReserveAsync(payment);
// Шаг 4: дожидаемся остального (они уже почти завершены)
await Task.WhenAll(analyticsTask, notificationTask, cacheUpdateTask, auditLogTask);
return await _mapper.MapAsync(shipping);
}
Результат: 💥 Средняя latency упала с
450 мс до 270 мс.
💥 P95 — с
1.2 с до 650 мс.
💥 Это
~40% ускорение на практике, особенно под нагрузкой, где side-эффекты начинают «накладываться».
О чем может говорить этот пример?Async/await не даёт параллелизма сам по себе. Он лишь позволяет
не блокировать поток, но вызовы всё равно выполняются последовательно, если не используется Task.WhenAll или явный запуск задач.
Какое правило можно взять на вооружение?
Перед тем как писать цепочку await’ов — нарисуй граф зависимостей.
Всё, что не зависит от результата предыдущего вызова — запускай параллельно.