19. Вопросы и ответы для собеседования¶
О главе
Цель: Отвечать вслух на вопросы собеседования.
Статус: ✅ ответы сверены с главами 1–18 (имена полей, пул, ValueTask, runtime async, диагностика); после каждого ответа в скобках — глава, где это показано опытом.
19.1. Вопросы с ответами¶
Сначала прочитайте все вопросы и ответьте вслух, потом разворачивайте ответы.
- Что делает компилятор с
async-методом? Что остаётся от исходного метода? - Что значит состояние
-1,-2и0..Nв<>1__state? - Почему машина состояний — структура, и когда она оказывается в куче?
- Что такое «поднятая» переменная и как понять, какие переменные поднимутся?
- Что должен иметь тип, чтобы его можно было
await? Можно ли ожидатьint? - Что делает
AwaitUnsafeOnCompletedи чем он отличается отAwaitOnCompleted? - Выполняется ли
async-метод в отдельном потоке? - На каком потоке выполнится код после
await? От чего это зависит? - Что делает
ConfigureAwait(false)и чего не делает? - Нужен ли
ConfigureAwait(false)в ASP.NET Core? - Как возникает дедлок с
.Result? Воспроизведите. - Как выглядит голодание пула потоков и чем оно отличается от дедлока?
- Чем
SynchronizationContextотличается отExecutionContext? - Течёт ли
AsyncLocalчерезawait? Видны ли изменения вызывающему? - Куда попадает исключение, брошенное в
async Task-методе? Что с исключением до первогоawait? - Что происходит с исключением в
async void? - Что вернёт
await Task.WhenAll(...)при двух упавших задачах? - Чем
await tотличается отt.Resultв обработке исключений? - Чем
Task.Runотличается отTask.Factory.StartNew? - Почему
ContinueWithсчитается устаревшей практикой? - Что такое
TaskCompletionSourceи зачемRunContinuationsAsynchronously? - В каких случаях брать
ValueTask? Какие у него правила использования? - Что такое
IAsyncEnumerableи как работаетawait foreach? - Как устроена кооперативная отмена? Чем
Canceledотличается отFaulted? - Как правильно сделать таймаут для операции?
- Что такое
Channel<T>и чем он лучшеBlockingCollection<T>в async-коде? - Как ограничить параллелизм при массовых асинхронных вызовах?
- Почему нельзя
awaitвнутриlockи что использовать? - Как работает пул потоков? Почему нагрузка «скачком» может вызвать задержки?
- Всегда ли
asyncI/O освобождает поток? Приведите контрпример. - Что такое элизия
async(return task) и когда она вредит? - Как безопасно запустить фоновую работу из HTTP-запроса?
- Как проверить, что код действительно асинхронный, а не «Task.Run над блокирующим»?
- Что такое «sync-over-async» и «async-over-sync»? Почему оба плохи?
- Что нового в async в последних версиях .NET?
- Как диагностировать зависший async-код в живом процессе?
- Что такое Runtime Async и что он меняет?
- Чем
Thread.SleepиTask.Waitотличаются для пула потоков? - Что будет, если дважды
awaitодин и тот жеValueTask? - Почему очередь на
ChannelвBackgroundServiceне гарантирует доставку?
Ответы (сначала ответьте сами)
-
Метод превращается в «заглушку»: создаёт машину состояний (структуру в Release),
AsyncTaskMethodBuilder, ставит состояние-1, вызываетbuilder.Start(ref machine)(синхронно запускаетMoveNext) и возвращаетbuilder.Task. Вся логика уходит вMoveNextвложенного типа<Имя>d__N, помеченного[AsyncStateMachine]на заглушке. -
-1выполняется или не начат,-2завершён,0..Nприостановлен наawaitс таким номером, при возобновленииMoveNextпереходит к коду после него. -
Структура, чтобы синхронный путь не аллоцировал: пока ни один
awaitне приостановился, машина живёт на стеке. При первой реальной приостановке builder упаковывает её в объект в куче (в современных версиях бокс сам являетсяTask<T>). Отсюда: синхронное завершение дёшево, реальная приостановка стоит аллокации. -
Локальная переменная, значение которой нужно после
await, превращается в поле машины. Переменные, не живущие черезawait, остаются локальными вMoveNext. Видно в декомпиляции по полям вида<x>5__N. Чем больше таких переменных, тем больше машина. -
Тип с методом
GetAwaiter()(может быть методом расширения), awaiter сIsCompleted,GetResult()иINotifyCompletion.OnCompleted(обычно ещёICriticalNotifyCompletion).intпо умолчанию нельзя, но можно написать метод расширенияGetAwaiterдля любого типа (TimeSpan, например). -
Оба регистрируют продолжение (
MoveNext) на awaiter'е.AwaitOnCompletedидёт через делегат и вызываетOnCompletedawaiter'а, который сам захватывает и восстанавливаетExecutionContext, хотя бокс уже хранит свой, то есть работа делается дважды.AwaitUnsafeOnCompletedвызываетUnsafeOnCompletedи контекст не захватывает (это уже сделал бокс), быстрее. Компилятор берётUnsafe-вариант, когда awaiter реализуетICriticalNotifyCompletion(глава 3). -
Нет.
asyncпотоков не создаёт. Метод синхронно работает до первого незавершённогоawait. Потоки появляются потому, что операции подawait(таймеры, I/O) завершаются на потоках пула, и продолжение исполняет какой-то из них. -
Зависит от наличия контекста. С
SynchronizationContext(UI, старый ASP.NET) продолжение возвращается в него. Без контекста (консоль, ASP.NET Core) продолжение выполняется на потоке, завершившем операцию (обычно пула), или inline. Еслиawaitпопал на уже завершённую задачу, продолжение идёт синхронно в том же потоке. -
Говорит: не захватывать контекст и планировщик для продолжения именно этого
await. Не создаёт новый поток и не переключает его сам. Если задача уже завершена, продолжение идёт синхронно, как и без него. Не отключаетExecutionContext(AsyncLocalтечёт). Относится только к одномуawait, поэтому в библиотеках его ставят на каждый. -
Нет, там нет
SynchronizationContext. Но в библиотечном коде, который могут вызвать из UI, ставят всегда (анализатор CA2007). -
Код в контексте с одним потоком (UI) вызывает
.Result/.Wait(), поток заблокирован.awaitвнутри захватил контекст и хочет вернуть продолжение в тот же заблокированный поток, а задача ждёт этого продолжения. Взаимная блокировка. Воспроизведение: §6.1 с самодельным контекстом. -
Голодание: все потоки пула заняты синхронным ожиданием, новая работа стоит в очереди, пул добавляет потоки медленно. Признаки: рост латентности при низком CPU, длинная очередь пула, таймауты. Не является классическим дедлоком (нет циклического ожидания контекста), но выглядит похоже. Лечение то же: убрать блокирующие вызовы. На стенде 32 задачи по 1 с с
Thread.SleepприMinThreads=4заняли 6–8 с (глава 13). -
SynchronizationContextопределяет, где выполнится продолжение (в каком потоке или очереди).ExecutionContextопределяет, какие данные текут за логическим потоком (AsyncLocal,Activity).ConfigureAwait(false)отключает первое, не второе. -
Течёт вниз по цепочке
await,Task.Run. Изменения внутриasync-метода вызывающему не видны (builder восстанавливает контекст после первогоMoveNext). Для данных запроса (корреляция, tenant) подходит;ThreadLocalдля этого не годится. -
В возвращаемый
Task(Faulted); вызов не бросает, даже если исключение до первогоawait. Увидите приawait/.Wait()/.Result/task.Exception. Поэтому проверку аргументов публичных методов делают в обычном методе с локальнойasync-функцией. -
async voidне возвращаетTask; исключение публикуется в захваченныйSynchronizationContext, а без него бросается на потоке пула и, как правило, роняет процесс. Допустим только для обработчиков событий. -
awaitбросает только первое исключение. Все лежат вwhenAllTask.Exception.InnerExceptions, поэтому держите ссылку на задачуWhenAll. -
awaitперебрасывает исходное исключение с сохранением стека..Result/.Wait()оборачивают его вAggregateException.GetAwaiter().GetResult()ведёт себя какawait(без обёртки). -
Task.Runиспользует пул (TaskScheduler.Default) и разворачиваетasync-лямбды.StartNewиспользуетTaskScheduler.Current(ловушка с чужим планировщиком), дляasync-лямбды возвращаетTask<Task>, нуженUnwrap. БерутTask.Run. -
ContinueWithиспользуетTaskScheduler.Current, не восстанавливает контекст, не раскручивает вложенные задачи безUnwrap, требует вручную обработки исключений и отмены.awaitпроще и безопаснее. -
TaskCompletionSource<T>создаётTask, который завершается вручную (TrySetResult), мост от callback/событий к async.RunContinuationsAsynchronouslyзаставляет продолжения выполняться в пуле, а не inline в потоке, вызвавшемSetResult, иначе чужой код выполнится внутри вашего вызова и возможны блокировки и дедлоки. -
Когда метод часто завершается синхронно (кеш, буфер) и важны аллокации. Правила:
awaitодин раз, не ждать параллельно, не брать.Resultдо завершения, для повторного использованияAsTask(). Нарушение читает чужие данные из переиспользованного источника. Структура 16 байт:_obj(null/Task/источник), результат, токен (глава 12). -
Асинхронная последовательность:
MoveNextAsyncвозвращаетValueTask<bool>. Метод сasyncиyield returnкомпилируется в класс, реализующийIAsyncEnumerable/Enumerator,IAsyncStateMachine,IValueTaskSource.await foreachразворачивается вwhile (await e.MoveNextAsync())сtry/finallyиawait e.DisposeAsync(). Отмена:[EnumeratorCancellation]иWithCancellation. -
Кооперативная:
CancellationTokenSourceвыдаёт токен, код сам проверяет или передаёт токен API.OperationCanceledExceptionс токеном операции переводитTaskвCanceled, остальные исключения вFaulted. Принудительно прервать работу нельзя; уже сделанное не откатывается. -
Токен:
CancellationTokenSource(timeout)илиCancelAfter, пробросить в операцию. Для чужой задачи без токена:task.WaitAsync(timeout)(перестаёт ждать, но не отменяет). НеWhenAnyсDelay(таймер и задача остаются). -
Асинхронная очередь «производитель — потребитель» с ограничением ёмкости и обратным давлением. В отличие от
BlockingCollection<T>не блокирует потоки:WriteAsync/ReadAsync. Использование: фоновая обработка вBackgroundService. -
Parallel.ForEachAsyncсMaxDegreeOfParallelism, либоSemaphoreSlimсWaitAsync/Releaseвfinally, либоChannelс фиксированным числом читателей. -
lock(Monitor) привязан к потоку, а продолжение послеawaitможет выполняться на другом, поэтому компилятор запрещает (CS1996). ИспользоватьSemaphoreSlim(1,1)сWaitAsync. Он не реентерабельный. -
Глобальная очередь и локальные очереди потоков с кражей работы; число потоков адаптивное: до
MinThreadsсоздаются сразу, выше добавляются медленно. Нагрузка скачком (особенно с блокирующим кодом) упирается в медленное добавление потоков: голодание обнаруживается примерно раз в 500 мс, +1 поток. Свободный поток берёт сначала свою локальную очередь (с конца, LIFO), потом общую (FIFO), потом крадёт у соседей (глава 13). -
Нет. Если драйвер внутри синхронный, «асинхронный» вызов занимает поток на всё время. Контрпример:
Task.Run(() => File.ReadAllText(...)). Ещё тоньше: ожидание бесплатно, а завершение требует потока пула. При занятом пуле не завершились ни чтение из сокета, ни чтение файла сuseAsync: true, и на Linux, и на Windows (глава 13). На Windows ядро читает честно (IOCP), но события завершения всё равно обрабатывает рабочий поток пула. -
return task;вместоreturn await task;экономит машину состояний. Вредит, если внутриusing/try: ресурс освободится раньше завершения задачи; исключения проходят иначе; меняется стек. Использовать только в простых «проводящих» методах. -
Не
_ = Task.RunсHttpContext/scoped-сервисами. Положить задание вChannelи обрабатывать вBackgroundServiceсо своим scope, с логированием ошибок и токеном остановки. Для надёжности (должно пережить рестарт) отдельная очередь (брокер, outbox). -
Смотреть на количество потоков пула во время ожидания (
dotnet-counters), на стек (нет блокирующего вызова внутри), на нагрузочный тест: сколько одновременных операций выдерживает при малом числе потоков. Для чужой библиотеки: проверить документацию и исходники, нет лиTask.Runвнутри. -
Sync-over-async: блокировка на
async(.Result) — дедлок или голодание пула. Async-over-sync:Task.Run(синхронный_вызов)для видимости асинхронности — тратит поток, нет выигрыша на сервере. Оба скрывают реальную стоимость. -
В 10.0.12 всё перечисленное есть и использовано в лабораторных:
ConfigureAwaitOptions,Task.WaitAsync,PeriodicTimer,Parallel.ForEachAsync,CancellationTokenSource.CancelAsync,Task.WhenEach,RendezvousChannel. LINQ дляIAsyncEnumerable<T>входит в BCL (System.Linq.AsyncEnumerable, не нужен пакет; глава 11). Номера версий появления (.NET 6/8/9) взяты из документации и не проверялись. Runtime async: на .NET 10 превью, на .NET 11 RC1 доступен по флагу компилятора (вопрос 37).Spanиref-локали в async-методах разрешены, пока не живут черезawait(CS4007 иначе; глава 16). -
Порядок (глава 15):
dotnet-counters(очередь и число потоков пула, CPU),dotnet-stack report -p PID(у кого висят потоки:Wait,Result,Sleep), при простаивающем пуле и потоке вWaitснять дампdotnet-dump collectи выполнитьdumpasync --tasks --completed --fields. Признак дедлока на контексте: машина состояний ждёт уже завершённую задачу (продолжение запланировано, но контекст занят). Признак голодания: задержка пула растёт,threadpoolпоказываетIdle: 0, в журнале Hill Climbing причинаStarvation. -
Компилятор не генерирует машину состояний, а помечает метод флагом
Asyncи вместоawaitвызываетAsyncHelpers.Await. JIT при реальной приостановке сам сохраняет живые переменные в цепочку объектовContinuation. Синхронный путь заметно дешевле (в наших замерах в 3–6 раз на .NET 10), приостановка на .NET 10 была дороже, на .NET 11 RC1 дешевле (160 против ~286 байт). На .NET 10 нужны флаг компилятора иDOTNET_RuntimeAsync=1, библиотека с флагом без переменной не загружается, а трасса стека теряет промежуточные кадры; на .NET 11 RC1 нужен только флаг, трасса полная (глава 14). -
Thread.Sleepпулу ничего не сообщает: добавление потоков идёт по одному примерно раз в 500 мс.Task.Wait()/.Resultна потоке пула вызываетThreadPool.NotifyThreadBlocked(кооперативная блокировка), и пул быстро добавляет потоки: в нашем опыте 32 ожидания по секунде — 5,3 с и 33 потока, а с выключенной кооперативной блокировкой — 25,5 с. Это смягчает sync-over-async, но не лечит: потоки дороги (глава 13). -
Зависит от содержимого, которое снаружи не видно. Если внутри готовое значение или
Task, второйawait«работает». Если внутри переиспользуемый источник (в том числеPoolingAsyncValueTaskMethodBuilder), второйawaitбросаетInvalidOperationException(токен устарел) или, для источника без проверки, вернёт чужой результат. Контракт: ждать один раз; для повторного ожидания один раз вызватьAsTask()(глава 12). -
Очередь живёт в памяти процесса. В опыте главы 17: 40 заказов поставлены в очередь, хост остановлен сразу — отправлено 0 из 40. Плюс один воркер обрабатывает задания последовательно. Если нельзя терять работу, нужна внешняя очередь или outbox, а
Channelслужит буфером перед ней.
19.2. Что сказать на интервью про async/await¶
Короткая версия. «async компилятор превращает в конечный автомат: метод режется по await, локальные переменные поднимаются в поля структуры, состояние хранится в <>1__state. Первый вызов идёт синхронно, пока нет незавершённого await. На нём машина упаковывается в кучу, подписывается на awaiter и возвращает управление. Когда операция завершится, MoveNext вызывается снова и идёт с нужного места. Потока на время ожидания нет. Где выполнится продолжение, определяет SynchronizationContext: в UI это UI-поток, в ASP.NET Core контекста нет, продолжение идёт на потоке пула».
Про дедлок. «Блокирующий .Result в контексте с одним потоком вешает сам поток, а продолжение ждёт именно его. В ASP.NET Core контекста нет, зато блокировка забирает потоки пула, а он добавляет их медленно: сервис деградирует. Правило: асинхронность до конца».
Про производительность. «Дёшево, когда await не приостанавливается: ни аллокаций, ни переключения. Дорого, когда приостановка реальна: бокс машины и работа с пулом. Для горячих путей с частым синхронным завершением использую ValueTask, соблюдая правила его использования».
Про отмену. «Кооперативная: токен нужно проверять и пробрасывать. OperationCanceledException даёт состояние Canceled, фильтрую по ct.IsCancellationRequested. Таймауты делаю токеном или WaitAsync».