Перейти к содержанию

19. Вопросы и ответы для собеседования

О главе

Цель: Отвечать вслух на вопросы собеседования.

Статус: ✅ ответы сверены с главами 1–18 (имена полей, пул, ValueTask, runtime async, диагностика); после каждого ответа в скобках — глава, где это показано опытом.

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

Сначала прочитайте все вопросы и ответьте вслух, потом разворачивайте ответы.

  1. Что делает компилятор с async-методом? Что остаётся от исходного метода?
  2. Что значит состояние -1, -2 и 0..N в <>1__state?
  3. Почему машина состояний — структура, и когда она оказывается в куче?
  4. Что такое «поднятая» переменная и как понять, какие переменные поднимутся?
  5. Что должен иметь тип, чтобы его можно было await? Можно ли ожидать int?
  6. Что делает AwaitUnsafeOnCompleted и чем он отличается от AwaitOnCompleted?
  7. Выполняется ли async-метод в отдельном потоке?
  8. На каком потоке выполнится код после await? От чего это зависит?
  9. Что делает ConfigureAwait(false) и чего не делает?
  10. Нужен ли ConfigureAwait(false) в ASP.NET Core?
  11. Как возникает дедлок с .Result? Воспроизведите.
  12. Как выглядит голодание пула потоков и чем оно отличается от дедлока?
  13. Чем SynchronizationContext отличается от ExecutionContext?
  14. Течёт ли AsyncLocal через await? Видны ли изменения вызывающему?
  15. Куда попадает исключение, брошенное в async Task-методе? Что с исключением до первого await?
  16. Что происходит с исключением в async void?
  17. Что вернёт await Task.WhenAll(...) при двух упавших задачах?
  18. Чем await t отличается от t.Result в обработке исключений?
  19. Чем Task.Run отличается от Task.Factory.StartNew?
  20. Почему ContinueWith считается устаревшей практикой?
  21. Что такое TaskCompletionSource и зачем RunContinuationsAsynchronously?
  22. В каких случаях брать ValueTask? Какие у него правила использования?
  23. Что такое IAsyncEnumerable и как работает await foreach?
  24. Как устроена кооперативная отмена? Чем Canceled отличается от Faulted?
  25. Как правильно сделать таймаут для операции?
  26. Что такое Channel<T> и чем он лучше BlockingCollection<T> в async-коде?
  27. Как ограничить параллелизм при массовых асинхронных вызовах?
  28. Почему нельзя await внутри lock и что использовать?
  29. Как работает пул потоков? Почему нагрузка «скачком» может вызвать задержки?
  30. Всегда ли async I/O освобождает поток? Приведите контрпример.
  31. Что такое элизия async (return task) и когда она вредит?
  32. Как безопасно запустить фоновую работу из HTTP-запроса?
  33. Как проверить, что код действительно асинхронный, а не «Task.Run над блокирующим»?
  34. Что такое «sync-over-async» и «async-over-sync»? Почему оба плохи?
  35. Что нового в async в последних версиях .NET?
  36. Как диагностировать зависший async-код в живом процессе?
  37. Что такое Runtime Async и что он меняет?
  38. Чем Thread.Sleep и Task.Wait отличаются для пула потоков?
  39. Что будет, если дважды await один и тот же ValueTask?
  40. Почему очередь на Channel в BackgroundService не гарантирует доставку?
Ответы (сначала ответьте сами)
  1. Метод превращается в «заглушку»: создаёт машину состояний (структуру в Release), AsyncTaskMethodBuilder, ставит состояние -1, вызывает builder.Start(ref machine) (синхронно запускает MoveNext) и возвращает builder.Task. Вся логика уходит в MoveNext вложенного типа <Имя>d__N, помеченного [AsyncStateMachine] на заглушке.

  2. -1 выполняется или не начат, -2 завершён, 0..N приостановлен на await с таким номером, при возобновлении MoveNext переходит к коду после него.

  3. Структура, чтобы синхронный путь не аллоцировал: пока ни один await не приостановился, машина живёт на стеке. При первой реальной приостановке builder упаковывает её в объект в куче (в современных версиях бокс сам является Task<T>). Отсюда: синхронное завершение дёшево, реальная приостановка стоит аллокации.

  4. Локальная переменная, значение которой нужно после await, превращается в поле машины. Переменные, не живущие через await, остаются локальными в MoveNext. Видно в декомпиляции по полям вида <x>5__N. Чем больше таких переменных, тем больше машина.

  5. Тип с методом GetAwaiter() (может быть методом расширения), awaiter с IsCompleted, GetResult() и INotifyCompletion.OnCompleted (обычно ещё ICriticalNotifyCompletion). int по умолчанию нельзя, но можно написать метод расширения GetAwaiter для любого типа (TimeSpan, например).

  6. Оба регистрируют продолжение (MoveNext) на awaiter'е. AwaitOnCompleted идёт через делегат и вызывает OnCompleted awaiter'а, который сам захватывает и восстанавливает ExecutionContext, хотя бокс уже хранит свой, то есть работа делается дважды. AwaitUnsafeOnCompleted вызывает UnsafeOnCompleted и контекст не захватывает (это уже сделал бокс), быстрее. Компилятор берёт Unsafe-вариант, когда awaiter реализует ICriticalNotifyCompletion (глава 3).

  7. Нет. async потоков не создаёт. Метод синхронно работает до первого незавершённого await. Потоки появляются потому, что операции под await (таймеры, I/O) завершаются на потоках пула, и продолжение исполняет какой-то из них.

  8. Зависит от наличия контекста. С SynchronizationContext (UI, старый ASP.NET) продолжение возвращается в него. Без контекста (консоль, ASP.NET Core) продолжение выполняется на потоке, завершившем операцию (обычно пула), или inline. Если await попал на уже завершённую задачу, продолжение идёт синхронно в том же потоке.

  9. Говорит: не захватывать контекст и планировщик для продолжения именно этого await. Не создаёт новый поток и не переключает его сам. Если задача уже завершена, продолжение идёт синхронно, как и без него. Не отключает ExecutionContext (AsyncLocal течёт). Относится только к одному await, поэтому в библиотеках его ставят на каждый.

  10. Нет, там нет SynchronizationContext. Но в библиотечном коде, который могут вызвать из UI, ставят всегда (анализатор CA2007).

  11. Код в контексте с одним потоком (UI) вызывает .Result/.Wait(), поток заблокирован. await внутри захватил контекст и хочет вернуть продолжение в тот же заблокированный поток, а задача ждёт этого продолжения. Взаимная блокировка. Воспроизведение: §6.1 с самодельным контекстом.

  12. Голодание: все потоки пула заняты синхронным ожиданием, новая работа стоит в очереди, пул добавляет потоки медленно. Признаки: рост латентности при низком CPU, длинная очередь пула, таймауты. Не является классическим дедлоком (нет циклического ожидания контекста), но выглядит похоже. Лечение то же: убрать блокирующие вызовы. На стенде 32 задачи по 1 с с Thread.Sleep при MinThreads=4 заняли 6–8 с (глава 13).

  13. SynchronizationContext определяет, где выполнится продолжение (в каком потоке или очереди). ExecutionContext определяет, какие данные текут за логическим потоком (AsyncLocal, Activity). ConfigureAwait(false) отключает первое, не второе.

  14. Течёт вниз по цепочке await, Task.Run. Изменения внутри async-метода вызывающему не видны (builder восстанавливает контекст после первого MoveNext). Для данных запроса (корреляция, tenant) подходит; ThreadLocal для этого не годится.

  15. В возвращаемый Task (Faulted); вызов не бросает, даже если исключение до первого await. Увидите при await/.Wait()/.Result/task.Exception. Поэтому проверку аргументов публичных методов делают в обычном методе с локальной async-функцией.

  16. async void не возвращает Task; исключение публикуется в захваченный SynchronizationContext, а без него бросается на потоке пула и, как правило, роняет процесс. Допустим только для обработчиков событий.

  17. await бросает только первое исключение. Все лежат в whenAllTask.Exception.InnerExceptions, поэтому держите ссылку на задачу WhenAll.

  18. await перебрасывает исходное исключение с сохранением стека. .Result/.Wait() оборачивают его в AggregateException. GetAwaiter().GetResult() ведёт себя как await (без обёртки).

  19. Task.Run использует пул (TaskScheduler.Default) и разворачивает async-лямбды. StartNew использует TaskScheduler.Current (ловушка с чужим планировщиком), для async-лямбды возвращает Task<Task>, нужен Unwrap. Берут Task.Run.

  20. ContinueWith использует TaskScheduler.Current, не восстанавливает контекст, не раскручивает вложенные задачи без Unwrap, требует вручную обработки исключений и отмены. await проще и безопаснее.

  21. TaskCompletionSource<T> создаёт Task, который завершается вручную (TrySetResult), мост от callback/событий к async. RunContinuationsAsynchronously заставляет продолжения выполняться в пуле, а не inline в потоке, вызвавшем SetResult, иначе чужой код выполнится внутри вашего вызова и возможны блокировки и дедлоки.

  22. Когда метод часто завершается синхронно (кеш, буфер) и важны аллокации. Правила: await один раз, не ждать параллельно, не брать .Result до завершения, для повторного использования AsTask(). Нарушение читает чужие данные из переиспользованного источника. Структура 16 байт: _obj (null/Task/источник), результат, токен (глава 12).

  23. Асинхронная последовательность: MoveNextAsync возвращает ValueTask<bool>. Метод с async и yield return компилируется в класс, реализующий IAsyncEnumerable/Enumerator, IAsyncStateMachine, IValueTaskSource. await foreach разворачивается в while (await e.MoveNextAsync()) с try/finally и await e.DisposeAsync(). Отмена: [EnumeratorCancellation] и WithCancellation.

  24. Кооперативная: CancellationTokenSource выдаёт токен, код сам проверяет или передаёт токен API. OperationCanceledException с токеном операции переводит Task в Canceled, остальные исключения в Faulted. Принудительно прервать работу нельзя; уже сделанное не откатывается.

  25. Токен: CancellationTokenSource(timeout) или CancelAfter, пробросить в операцию. Для чужой задачи без токена: task.WaitAsync(timeout) (перестаёт ждать, но не отменяет). Не WhenAny с Delay (таймер и задача остаются).

  26. Асинхронная очередь «производитель — потребитель» с ограничением ёмкости и обратным давлением. В отличие от BlockingCollection<T> не блокирует потоки: WriteAsync/ReadAsync. Использование: фоновая обработка в BackgroundService.

  27. Parallel.ForEachAsync с MaxDegreeOfParallelism, либо SemaphoreSlim с WaitAsync/Release в finally, либо Channel с фиксированным числом читателей.

  28. lock (Monitor) привязан к потоку, а продолжение после await может выполняться на другом, поэтому компилятор запрещает (CS1996). Использовать SemaphoreSlim(1,1) с WaitAsync. Он не реентерабельный.

  29. Глобальная очередь и локальные очереди потоков с кражей работы; число потоков адаптивное: до MinThreads создаются сразу, выше добавляются медленно. Нагрузка скачком (особенно с блокирующим кодом) упирается в медленное добавление потоков: голодание обнаруживается примерно раз в 500 мс, +1 поток. Свободный поток берёт сначала свою локальную очередь (с конца, LIFO), потом общую (FIFO), потом крадёт у соседей (глава 13).

  30. Нет. Если драйвер внутри синхронный, «асинхронный» вызов занимает поток на всё время. Контрпример: Task.Run(() => File.ReadAllText(...)). Ещё тоньше: ожидание бесплатно, а завершение требует потока пула. При занятом пуле не завершились ни чтение из сокета, ни чтение файла с useAsync: true, и на Linux, и на Windows (глава 13). На Windows ядро читает честно (IOCP), но события завершения всё равно обрабатывает рабочий поток пула.

  31. return task; вместо return await task; экономит машину состояний. Вредит, если внутри using/try: ресурс освободится раньше завершения задачи; исключения проходят иначе; меняется стек. Использовать только в простых «проводящих» методах.

  32. Не _ = Task.Run с HttpContext/scoped-сервисами. Положить задание в Channel и обрабатывать в BackgroundService со своим scope, с логированием ошибок и токеном остановки. Для надёжности (должно пережить рестарт) отдельная очередь (брокер, outbox).

  33. Смотреть на количество потоков пула во время ожидания (dotnet-counters), на стек (нет блокирующего вызова внутри), на нагрузочный тест: сколько одновременных операций выдерживает при малом числе потоков. Для чужой библиотеки: проверить документацию и исходники, нет ли Task.Run внутри.

  34. Sync-over-async: блокировка на async (.Result) — дедлок или голодание пула. Async-over-sync: Task.Run(синхронный_вызов) для видимости асинхронности — тратит поток, нет выигрыша на сервере. Оба скрывают реальную стоимость.

  35. В 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).

  36. Порядок (глава 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.

  37. Компилятор не генерирует машину состояний, а помечает метод флагом 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).

  38. Thread.Sleep пулу ничего не сообщает: добавление потоков идёт по одному примерно раз в 500 мс. Task.Wait()/.Result на потоке пула вызывает ThreadPool.NotifyThreadBlocked (кооперативная блокировка), и пул быстро добавляет потоки: в нашем опыте 32 ожидания по секунде — 5,3 с и 33 потока, а с выключенной кооперативной блокировкой — 25,5 с. Это смягчает sync-over-async, но не лечит: потоки дороги (глава 13).

  39. Зависит от содержимого, которое снаружи не видно. Если внутри готовое значение или Task, второй await «работает». Если внутри переиспользуемый источник (в том числе PoolingAsyncValueTaskMethodBuilder), второй await бросает InvalidOperationException (токен устарел) или, для источника без проверки, вернёт чужой результат. Контракт: ждать один раз; для повторного ожидания один раз вызвать AsTask() (глава 12).

  40. Очередь живёт в памяти процесса. В опыте главы 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».