async/await изнутри: state machine, SynchronizationContext, ConfigureAwait

↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая

Обязательно~6 мин чтенияУровень: middle

Зачем это на собесе

«Что делает await?» — вопрос, который отделяет тех, кто пишет async, от тех, кто его понимает. Ждут: компилятор строит конечный автомат, await не блокирует поток, продолжение возвращается в SynchronizationContext или в пул, ConfigureAwait(false) нужен в библиотеках.

Объяснение

Что делает async/await

async-метод компилируется в конечный автомат (state machine): структура (в Release) с состоянием, локальными переменными и методом MoveNext. Каждый await — точка приостановки.

public async Task<int> CountAsync(string url, CancellationToken ct)
{
    var text = await http.GetStringAsync(url, ct);   // приостановка: метод вернул управление вызывающему
    return text.Length;                                // продолжение после завершения I/O
}

Что происходит на await:

  1. Если операция уже завершена — выполнение идёт дальше синхронно (быстрый путь).
  2. Иначе состояние сохраняется в автомате, к задаче регистрируется продолжение, метод возвращает незавершённую Task вызывающему. Поток освобождается.
  3. Когда операция завершается, продолжение планируется на подходящий планировщик, и MoveNext продолжает с сохранённого места.

async сам по себе не создаёт поток. Асинхронность даёт неблокирующий I/O.

Что порождает компилятор (упрощённо)

  • Структура-автомат <CountAsync>d__0 с полями: локальные переменные, состояние, AsyncTaskMethodBuilder<int>, awaiter.
  • Локальные переменные, живущие через await, переезжают в поля автомата (в кучу, если метод реально приостановился).
  • Исключения запоминаются в Task и пробрасываются при await.

SynchronizationContext и TaskScheduler

После await продолжение возвращается «туда, где вы были», если есть контекст:

  • UI (WPF/WinForms/MAUI): продолжение в UI-поток: удобно, но блокировка UI-потока (.Result) приводит к дедлоку.
  • Классический ASP.NET (не Core): контекст запроса, тоже возможны дедлоки.
  • ASP.NET Core: нет SynchronizationContext, продолжение выполняется на потоке пула. Дедлока от .Result нет, но есть starvation.
  • Консоль, Task.Run, фоновые сервисы: контекста нет, поток пула.

ConfigureAwait(false)

var data = await stream.ReadAsync(buffer, ct).ConfigureAwait(false);   // не возвращаться в исходный контекст
  • Библиотечный код (NuGet-пакеты, общие сборки): используйте ConfigureAwait(false), чтобы не зависеть от контекста вызывающего и избежать дедлоков в UI.
  • Код приложения ASP.NET Core: контекста нет, ConfigureAwait(false) ничего не меняет; можно не писать.
  • UI-приложения: там, где после await нужен UI-поток, не используйте; во всём остальном коде — используйте.
  • ConfigureAwait(ConfigureAwaitOptions...) (.NET 8): SuppressThrowing, ForceYielding.

Пример дедлока (UI/классический ASP.NET)

// в обработчике UI-потока:
var result = GetDataAsync().Result;      // блокирует UI-поток в ожидании Task
 
async Task<string> GetDataAsync()
{
    var s = await http.GetStringAsync(url);   // продолжение хочет вернуться в UI-поток, а он заблокирован → дедлок
    return s;
}

Решение: await вместо .Result, либо ConfigureAwait(false) внутри GetDataAsync.

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

  • Task / Task<T> — стандарт.
  • ValueTask / ValueTask<T> — когда часто завершается синхронно (см. отдельную тему).
  • void — только для обработчиков событий (антипаттерн в остальных случаях).
  • IAsyncEnumerable<T>, IAsyncEnumerator<T> — асинхронные потоки.

Тонкости

  • await в цикле выполняет итерации последовательно. Для параллельности запустите задачи и дождитесь WhenAll.
  • Метод без await внутри async компилируется с предупреждением и всё равно строит автомат: для «просто вернуть Task» уберите async и верните Task напрямую, но тогда исключения в синхронной части бросятся сразу, а не попадут в Task.
  • await внутри lock запрещён (нельзя держать монитор поперёк приостановки): используйте SemaphoreSlim.WaitAsync.
  • async-лямбды: Func<Task> против Action (неявный async void).

Нюансы и подводные камни

  • .Result/.Wait()/GetAwaiter().GetResult() блокируют поток: starvation в ASP.NET Core, дедлок в UI.
  • Забытый await: задача не ожидается, исключение теряется, метод возвращается раньше времени. Включайте анализатор CS4014 и [DoesNotReturn]-аналоги.
  • Захват большого состояния в автомате: долгоживущая задача удерживает локальные переменные.
  • Стек вызовов при await не сохраняется: в логах видно MoveNext, используйте ExceptionDispatchInfo и стековые трейсы .NET, которые склеивают async-цепочки.
  • async не ускоряет один запрос: он увеличивает пропускную способность за счёт освобождения потоков.

Практика

  • Декомпилировать простой async-метод (sharplab.io) и найти структуру автомата и состояние.
  • Воспроизвести дедлок в консольном приложении с собственным SynchronizationContext.
  • Написать метод, который выполняется синхронно при попадании в кэш и асинхронно при промахе, и вернуть ValueTask.

Вопросы с ответами

Связанные темы