async/await изнутри: state machine, SynchronizationContext, ConfigureAwait
↑ 2.3 Асинхронность и многопоточность · ← Предыдущая · → Следующая
Зачем это на собесе
«Что делает
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:
- Если операция уже завершена — выполнение идёт дальше синхронно (быстрый путь).
- Иначе состояние сохраняется в автомате, к задаче регистрируется продолжение, метод возвращает незавершённую
Taskвызывающему. Поток освобождается. - Когда операция завершается, продолжение планируется на подходящий планировщик, и
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.
Вопросы с ответами
Что происходит при
await?Если операция ещё не завершена, состояние метода сохраняется в конечном автомате, метод возвращает незавершённую
Task, поток освобождается. После завершения продолжение планируется и выполняется с сохранённого места.
Создаёт ли
asyncновый поток?Нет.
async/await— это преобразование кода в автомат, а не многопоточность. Потоки появляются только у CPU-работы (Task.Run) и при выполнении продолжений на пуле.
Для чего
ConfigureAwait(false)?Чтобы продолжение не возвращалось в захваченный
SynchronizationContext. В библиотеках это предотвращает дедлоки и накладные расходы. В ASP.NET Core контекста нет, эффекта практически нет.
Почему
.Resultможет вызвать дедлок?Вызывающий поток блокируется в ожидании задачи, а продолжение этой задачи требует того же контекста (UI-потока или контекста запроса), который занят ожиданием. Оба ждут друг друга.
Чем
async Taskотличается отasync void?
Taskможно ожидать и обработать исключение.async voidне ожидаем, исключение уходит в контекст и может уронить процесс. Допустим только для обработчиков событий.
Связанные темы
- Предыдущая: Task и TPL · Следующая: ValueTask и аллокации
- Thread и ThreadPool: Thread и ThreadPool
- Антипаттерны async: Антипаттерны: async void, .Result, sync over async
- ValueTask: ValueTask и аллокации
- Async/await в JS: Асинхронность: callbacks, Promise, async∕await