Асинхронность в C#
Асинхронность в C# существует ради одного ресурса — потока. Пока синхронный вызов ждёт ответа от сети или диска, поток стоит на месте и ничего не делает, но продолжает занимать стек и место в пуле. await разрывает эту связь: он регистрирует продолжение, возвращает поток в пул, и тот обслуживает другие запросы, пока операция идёт своим ходом. Отсюда главный тезис, который проверяют на собеседованиях: асинхронность даёт пропускную способность и отзывчивость, но не скорость — сама операция быстрее не станет.
Специфика C# в том, что вся эта механика — работа компилятора, а не рантайма. Компилятор переписывает async-метод в машину состояний: локальные переменные становятся её полями, каждый await — точкой возобновления, а builder связывает машину с возвращаемой задачей. Отсюда и все ловушки темы, которые стоит назвать сразу: await не создаёт поток и не блокирует — но .Result блокирует и в UI-приложении даёт дедлок; ValueTask экономит выделение памяти, но ожидать его дважды нельзя; отмена кооперативна — CancellationToken не остановит ничего, пока вызываемый код сам его не проверит; async void не возвращает задачу, поэтому его исключение вызывающий поймать не может, и процесс падает. Разбор по слоям — ниже.
Карта темы
- async и await — что компилятор генерирует для
async-метода, какawaitприостанавливает и возобновляет машину состояний и почему при этом не создаётся ни одного потока. - Task и ValueTask —
Taskкак дескриптор идущей операции, три терминальных состояния и когда структураValueTaskэкономит выделение памяти. - CPU-bound против I/O-bound — почему за вводом-выводом не стоит поток, что на самом деле делает
Task.Runи как блокировка роняет пул потоков. - Комбинаторы задач —
Task.WhenAllиTask.WhenAny, чем они отличаются от блокирующихWaitAll/WaitAnyи что происходит с проигравшими задачами. - Отмена через CancellationToken —
CancellationTokenSourceкак владелец сигнала, кооперативная проверка токена и состояниеCanceled. - Асинхронные потоки —
IAsyncEnumerable<T>,await foreachи зачем итератору атрибут[EnumeratorCancellation]. - async void — почему метод без задачи нельзя ожидать, откуда берётся падение процесса и единственный законный сценарий.
- Исключения в async-коде — где живёт упавшее исключение, почему
awaitбросает одно, а.Result— обёрткуAggregateException, и как теряются сбои у неожидаемых задач.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Считать, что await запускает работу на новом потоке | await не создаёт потоков — он лишь вешает продолжение и отпускает текущий поток |
Ждать от await ускорения самой операции | Запрос к базе быстрее не станет — растут только пропускная способность и отзывчивость |
Блокироваться на .Result или .Wait() внутри async-кода | Поток пула занят ожиданием; в UI-приложении продолжение некуда вернуть — дедлок |
Оборачивать I/O-bound вызов в Task.Run | Лишний прыжок между потоками и лишний поток пула без всякой выгоды |
Ставить ValueTask<T> возвращаемым типом по умолчанию | Выгода есть только на горячем, обычно синхронном пути; в остальных случаях — лишние ограничения |
Ожидать один и тот же ValueTask<T> дважды | Объект-источник мог быть возвращён в пул и переиспользован — результат непредсказуем |
Принять CancellationToken и не передать его во внутренние вызовы | Отмена не сработает: токен ничего не отменяет сам, он лишь сообщает о запросе |
Проглатывать OperationCanceledException общим catch | Задача отчитается об успехе вместо Canceled — отмена станет невидимой |
Считать, что Task.WhenAny останавливает проигравшие задачи | Они продолжают работать; их нужно отменить через CancellationTokenSource и наблюдать |
Ловить AggregateException вокруг await | await уже развернул обёртку и бросил первое внутреннее исключение — catch не сработает |
Вызывать async-метод без await | Задача отброшена вместе с исключением: сбой не всплывёт нигде и молча потеряется |
Писать async void для fire-and-forget | Вызывающий не может ни ожидать, ни поймать сбой — исключение роняет процесс |
Материализовать IAsyncEnumerable<T> через ToList | Теряется вся выгода потоковой обработки — снова буферизуется весь результат |
Забыть [EnumeratorCancellation] в async-итераторе | Токен из WithCancellation молча не доедет: внутри итератора окажется default |
Значение для собеседований
Асинхронность — тема, на которой отсекают кандидатов на middle и senior. Проверяют не знание слов async и await, а модель исполнения: что происходит с потоком в момент приостановки, где живут локальные переменные после неё и кто вызывает продолжение. Кандидат, который говорит «await регистрирует продолжение и возвращает поток в пул, а машина состояний переезжает в кучу только при реальной приостановке», сразу отделяется от того, кто отвечает «await ждёт задачу».
Что обычно проверяют:
- Что делает компилятор с
async-методом и какawaitвозобновляет исполнение. - Чем
Taskотличается от потока и в каких состояниях завершается. - Когда
ValueTask<T>уместен и почему его нельзя ожидать дважды. - Почему
awaitэкономит поток на I/O и не экономит на вычислениях. - Разницу
WhenAll/WhenAnyи что броситawaitпри нескольких сбоях. - Кооперативную природу отмены и роль
CancellationTokenSource. - Почему
async void— ловушка и где он всё же неизбежен. - Как исключение попадает из async-метода в
catchвызывающего.
Типичный неверный ответ: «await запускает операцию в фоновом потоке и ждёт его». Это сразу открывает разговор о том, что за асинхронным вводом-выводом вообще не стоит поток: запрос уходит устройству, а завершение приходит колбэком от ОС — поэтому тысяча одновременных запросов на сервере не стоит тысячи потоков. Второй классический провал — catch (AggregateException) вокруг await: он никогда не сработает, потому что обёртку разворачивает сам awaiter.