Асинхронность это самая частая тема .NET-собесов от middle и выше. Нужно понимать, что await не создаёт потоков, откуда берутся дедлоки и как работает машина состояний.
Зачем async
Пока запрос ждёт базу или сеть, поток ничего не делает. Синхронный код держит поток всё это время, асинхронный отпускает его в пул. Для сервера это означает больше одновременных запросов на тех же потоках. Для UI отзывчивый интерфейс.
public async Task<User?> GetAsync(int id, CancellationToken ct)
{
var user = await _db.Users.FindAsync(new object[] { id }, ct);
return user;
}
Что делает await
- Получает awaiter задачи. Если задача уже завершена, выполнение идёт дальше синхронно.
- Иначе метод запоминает своё состояние, подписывает продолжение и возвращает управление вызывающему (тот получает незавершённую
Task). - Когда операция завершится, продолжение планируется: в захваченный
SynchronizationContext(UI), иначе на пул потоков.
Метод выполняется синхронно до первого незавершённого await. Для ввода-вывода никакой поток не ждёт: ОС уведомляет о завершении через порты завершения.
Машина состояний
Компилятор превращает async-метод в структуру с методом MoveNext. Локальные переменные, живущие через await, становятся её полями, каждый await это номер состояния. При первом реальном ожидании структура переезжает в кучу.
Task, ValueTask, async void
Task/Task<T>: основной вариант.ValueTask<T>: экономит аллокацию, если результат часто готов синхронно. Ждать только один раз.async void: только для обработчиков событий. Его нельзя дождаться, исключение может уронить процесс.
Параллельно, а не последовательно
var a = LoadA(); // обе операции стартовали
var b = LoadB();
await Task.WhenAll(a, b); // ждём обе
Три await подряд выполняют операции по очереди. Для ограничения параллелизма: Parallel.ForEachAsync с MaxDegreeOfParallelism или SemaphoreSlim.
Дедлок и ConfigureAwait
В UI-приложении (или старом ASP.NET) GetAsync().Result блокирует поток, а продолжение await ждёт этот же поток: взаимная блокировка. Правило: асинхронность до конца, никаких .Result и .Wait().
ConfigureAwait(false) не возвращается в исходный контекст после ожидания. В библиотеках его ставят по-прежнему. В ASP.NET Core контекста синхронизации нет, поэтому классического дедлока не будет, но блокирующее ожидание всё равно ведёт к голоданию пула потоков.
Отмена
Отмена кооперативная: принимайте CancellationToken и передавайте его дальше во все async-вызовы. При отмене бросается OperationCanceledException. Таймаут: CancellationTokenSource.CancelAfter или task.WaitAsync(timeout) (.NET 6).
Task.Run
Нужен, чтобы унести CPU-работу с UI-потока. В ASP.NET Core оборачивать в Task.Run уже асинхронный ввод-вывод бессмысленно: запрос и так на пуле. Фоновую работу делают через BackgroundService и очередь, а не через «выстрелил и забыл».
Исключения
await пробрасывает исключение задачи с исходным стеком. Task.WhenAll хранит все ошибки в AggregateException, await отдаёт первую. Не дождались задачу, и её исключение потерялось.
IAsyncEnumerable
Асинхронный поток данных: async IAsyncEnumerable<T> с yield return, потребление через await foreach. EF Core и ASP.NET Core умеют стримить такие последовательности.
Частые ошибки
.Result/.Wait()в асинхронном коде.async voidвне обработчиков событий.- Последовательные
awaitдля независимых операций. - Забытый
awaitи потерянные исключения. - Принятый, но не переданный дальше
CancellationToken. - Общий
DbContextв параллельных задачах.
Что спросят на собесе
- Создаёт ли
awaitновый поток? Что происходит наawait? - Откуда берётся дедлок с
.Resultи как работаетConfigureAwait(false)? - Чем опасен
async void? - Когда
ValueTaskлучшеTask? - Как выполнить запросы параллельно и ограничить их число?