Асинхронность и async/await
Машина состояний async/await, Task и ValueTask, отмена через CancellationToken, IAsyncEnumerable, async void и распространение исключений.
15 вопросов
JuniorТеорияОчень частоЧто реально дают async и await по сравнению с блокирующим вызовом?
Что реально дают async и await по сравнению с блокирующим вызовом?
Блокирующий вызов удерживает поток до завершения операции. await вместо этого регистрирует продолжение и возвращает поток в пул, и тот обслуживает другую работу, пока операция выполняется. Это даёт пропускную способность и отзывчивость, но никогда не скорость.
Типичные ошибки
- ✗Думать, что
awaitускоряет саму ожидаемую операцию - ✗Считать, что
awaitвсегда запускает работу на отдельном потоке - ✗Блокироваться через
.Resultили.Wait()внутри async-кода, теряя всю выгоду
Уточняющие вопросы
- →Что происходит с вызывающим потоком между
awaitи завершением вызова? - →Почему блокировка через
.Resultна async-вызове приводит к дедлоку в UI-приложении?
MiddleТеорияОчень частоПочему async void не рекомендуют и где он всё же неизбежен?
Почему async void не рекомендуют и где он всё же неизбежен?
async void не возвращает Task, поэтому вызывающий не может его ожидать, не видит момента завершения и не может поймать его исключение — сбой перебрасывается на захваченный контекст и роняет процесс. Используйте async Task везде, кроме обработчиков событий, чья сигнатура вынуждает void.
Типичные ошибки
- ✗Использовать
async voidдля обычных fire-and-forget помощников вместоasync Task - ✗Считать, что
try/catchвокруг вызоваasync voidпоймает его исключение - ✗Думать, что вызывающий может ожидать
async void-метод, чтобы узнать о завершении
Уточняющие вопросы
- →Где на самом деле всплывает исключение, брошенное внутри
async void-метода? - →Как написать обработчик события так, чтобы его сбои оставались наблюдаемыми?
JuniorТеорияЧастоЧто такое CancellationToken, кто его создаёт и кто подаёт сигнал?
Что такое CancellationToken, кто его создаёт и кто подаёт сигнал?
CancellationToken — это сигнал только для чтения, который вызывающий передаёт вниз, чтобы вызываемый код увидел запрос на отмену. Взвести его может лишь владелец, CancellationTokenSource, вызовом Cancel(). Сам токен ничего не останавливает — он лишь сообщает IsCancellationRequested.
Типичные ошибки
- ✗Думать, что токен сам может отменять — на это способен только его
CancellationTokenSource - ✗Ожидать, что отмена прервёт работающий метод без всякого сотрудничества с его стороны
- ✗Не передавать токен вниз по цепочке вызовов, из-за чего внутренние вызовы не видят запрос
Уточняющие вопросы
- →Что должен делать метод, чтобы реально отработать взведённый токен?
- →Как объединить два токена так, чтобы отменить операцию мог любой из них?
JuniorТеорияЧастоЧто представляет собой объект Task и в каких состояниях он завершается?
Что представляет собой объект Task и в каких состояниях он завершается?
Task — это дескриптор уже идущей операции, обещание будущего результата, а не поток и не сама работа. Он завершается как RanToCompletion, Faulted с исключением или Canceled; Task<T> вдобавок несёт полученное значение.
Типичные ошибки
- ✗Приравнивать
Taskк потоку, а не к дескриптору операции - ✗Думать, что
Taskхолодный и стартует только при ожидании - ✗Упускать, что
Canceled— отдельное состояние, а не разновидностьFaulted
Уточняющие вопросы
- →Чем состояние
Canceledотличается отFaultedприawaitэтой задачи? - →Чем
Task, возвращённыйasync-методом, отличается отTaskизTask.Run?
JuniorТеорияЧастоЧем Task.WhenAll отличается от Task.WhenAny и что возвращает каждый из них?
Чем Task.WhenAll отличается от Task.WhenAny и что возвращает каждый из них?
Task.WhenAll возвращает задачу, которая завершается, когда закончилась каждая из входных, и отдаёт все результаты разом. Task.WhenAny завершается по первой закончившейся и отдаёт именно её; остальные продолжают работать. Обе нужно ожидать через await, а не блокировать.
Типичные ошибки
- ✗Думать, что
Task.WhenAllвыполняет задачи одну за другой, а не параллельно - ✗Считать, что
Task.WhenAnyотменяет или останавливает проигравшие задачи - ✗Путать
WhenAll/WhenAnyс блокирующимиWaitAll/WaitAny
Уточняющие вопросы
- →Если внутри
Task.WhenAllупало несколько задач, какое исключение броситawait? - →Как отменить проигравшие задачи после того, как
Task.WhenAnyвернул управление?
MiddleТеорияЧастоПочему await экономит поток на I/O-bound работе, но не на CPU-bound?
Почему await экономит поток на I/O-bound работе, но не на CPU-bound?
За вводом-выводом не стоит поток — работает устройство, а ОС держит колбэк завершения, поэтому await может полностью отпустить поток. Вычислительной работе всё время нужно исполнять инструкции, так что какой-то поток на ней сидит; Task.Run освобождает вызывающего, но всё равно тратит поток из пула.
Типичные ошибки
- ✗Оборачивать вычислительную работу в
asyncи ждать масштабирования как у ввода-вывода - ✗Думать, что
awaitна вводе-выводе держит поток припаркованным на дескрипторе устройства - ✗Считать, что
Task.Runснижает суммарную цену CPU-bound работы в потоках
Уточняющие вопросы
- →Почему вынос вычислений в
Task.Runвсё же помогает UI-потоку или потоку запроса? - →Что происходит с пропускной способностью сервера, когда каждый запрос блокирует поток пула?
MiddleТеорияЧастоЧем Task.Run(() => FooAsync()) отличается от простого await FooAsync()?
Чем Task.Run(() => FooAsync()) отличается от простого await FooAsync()?
await FooAsync() стартует метод на текущем потоке и выполняет его прямо здесь до первого настоящего await. Task.Run сначала отдаёт делегат потоку из пула, и синхронный пролог выполняется уже там. Для I/O-bound работы этот лишний прыжок не даёт ничего и лишь тратит поток.
Типичные ошибки
- ✗Оборачивать I/O-bound async-вызов в
Task.Run, добавляя прыжок между потоками впустую - ✗Думать, что
async-метод не начинает выполняться, пока его не ожидают - ✗Считать, что
Task.Runускоряет саму операцию ввода-вывода
Уточняющие вопросы
- →Когда вынос в
Task.Runдействительно оправдан внутри серверного запроса? - →Где возобновляется код после
await, если контекст планированияSynchronizationContextне захвачен?
MiddleТеорияИногдаКогда всплывает обёртка-исключение AggregateException и почему await обычно её не бросает?
Когда всплывает обёртка-исключение AggregateException и почему await обычно её не бросает?
Блокирующие API — .Wait(), .Result, Task.WaitAll — заворачивают сбои в AggregateException, ведь упасть могло сразу несколько задач. await же разворачивает её и перебрасывает только первое внутреннее исключение, поэтому упавший Task.WhenAll бросит одно; остальные останутся в Exception.InnerExceptions задачи.
Типичные ошибки
- ✗Ловить
AggregateExceptionвокругawait, куда исключение приходит уже развёрнутым - ✗Считать, что упавший
Task.WhenAllсообщит о всех сбоях через брошенное исключение - ✗Подмешивать блокирующий
.Resultв async-код и удивляться типу-обёртке
Уточняющие вопросы
- →Как достать все сбои из
Task.WhenAll, в котором упало несколько задач? - →Что делает
AggregateException.Flatten()с вложенными обёртками?
MiddleТеорияИногдаКак async-метод реально отрабатывает запрос от механизма отмены CancellationToken?
Как async-метод реально отрабатывает запрос от механизма отмены CancellationToken?
Отмена кооперативна — никто не прервёт метод за вас. Он обязан прокидывать токен в каждый вызов, который ожидает, и вызывать ThrowIfCancellationRequested() между порциями работы. Это бросает OperationCanceledException, и задача попадает в Canceled, а не в Faulted.
Типичные ошибки
- ✗Принять токен, но не передать его во внутренние ожидаемые вызовы
- ✗Ожидать, что среда выполнения сама прервёт метод, как только токен взведён
- ✗Проглатывать
OperationCanceledException, из-за чего задача отчитывается об успехе вместоCanceled
Уточняющие вопросы
- →Почему
awaitна отменённой задаче бросает исключение, а не возвращает значение по умолчанию? - →Как повесить таймаут на операцию с помощью
CancellationTokenSource?
MiddleТеорияИногдаЧто даёт IAsyncEnumerable<T> такого, чего не даёт Task<List<T>>?
Что даёт IAsyncEnumerable<T> такого, чего не даёт Task<List<T>>?
Task<List<T>> ожидается один раз и материализует всю пачку в памяти. IAsyncEnumerable<T> отдаёт элементы по одному и может делать await между ними, поэтому await foreach обрабатывает первый элемент, пока следующие ещё едут, — это поток без буферизации всего сразу.
Типичные ошибки
- ✗Материализовать асинхронный поток через
ToListAsync()и терять выгоду потоковой обработки - ✗Думать, что
await foreachвытягивает всю последовательность до первой итерации - ✗Забывать прокинуть
CancellationTokenчерезWithCancellation
Уточняющие вопросы
- →Что компилятор генерирует для итератора
async IAsyncEnumerable<T>? - →Как прокинуть
CancellationTokenв циклawait foreach?
MiddleТеорияИногдаКогда возвращать ValueTask<T> вместо Task<T> и в чём подвох?
Когда возвращать ValueTask<T> вместо Task<T> и в чём подвох?
Task<T> — класс, поэтому каждый вызов выделяет объект, даже когда результат уже закеширован. ValueTask<T> — структура, оборачивающая либо значение, либо Task<T>, поэтому синхронно завершившийся горячий путь не выделяет ничего. Подвох: ожидать его нужно ровно один раз, никогда не дважды.
Типичные ошибки
- ✗Ожидать один и тот же
ValueTask<T>дважды или читать у него.Result - ✗Ставить
ValueTask<T>везде по умолчанию вместо горячих, обычно синхронных путей - ✗Думать, что
ValueTask<T>убирает выделение даже когда путь реально уходит в асинхронность
Уточняющие вопросы
- →Что именно ломается, если ожидать один и тот же
ValueTask<T>дважды? - →Как интерфейс пулинга
IValueTaskSourceпозволяетValueTaskизбежать выделения на асинхронных путях?
MiddleДебаггингИногдаИсключение из асинхронного вызова не доходит до catch
Исключение из асинхронного вызова не доходит до catch
У вызова пропущен await, поэтому возвращённая Task просто отбрасывается. async-метод не бросает исключение в кадр вызывающего — он складывает сбой в свою Task и перебрасывает его только при ожидании этой задачи. Исправление: написать await _repository.SaveAsync(order);.
Типичные ошибки
- ✗Вызывать async-метод без
awaitи полагать, что сбои всё равно дойдут - ✗Считать, что
async-метод бросает синхронно вtry/catchвызывающего - ✗Относиться к возвращённой
Taskкак к необязательной и отбрасывать её
Уточняющие вопросы
- →Что происходит с исключением, лежащим в
Task, которую никто не ожидает? - →Как предупреждение компилятора или анализатор поймали бы эту отброшенную задачу?
SeniorТеорияРедкоКак исключение проходит через await и почему сбой async void нельзя перехватить?
Как исключение проходит через await и почему сбой async void нельзя перехватить?
Упавший async Task-метод ловит исключение внутри своей машины состояний и складывает его в задачу через SetException; awaiter затем перебрасывает его через ExceptionDispatchInfo, сохраняя исходный стек вместо его сброса. У async void задачи для хранения нет, поэтому его builder перебрасывает исключение на захваченный SynchronizationContext — или в пул потоков, — где никто не ожидает, и процесс падает.
Типичные ошибки
- ✗Ожидать, что сбой
async voidможно пойматьtry/catchвызывающего - ✗Думать, что переброс через
awaitсбрасывает исходный стек исключения - ✗Путать падение от
async voidс ненаблюдённым исключением задачи, которое процесс уже не роняет
Уточняющие вопросы
- →Почему ненаблюдённые исключения
Taskперестали убивать процесс после .NET 4.5? - →Чем builder у
async void-метода отличается от builder уasync Task?
SeniorТеорияРедкоЧто компилятор C# генерирует для async-метода и как await его возобновляет?
Что компилятор C# генерирует для async-метода и как await его возобновляет?
Компилятор переписывает метод в машину состояний, чей MoveNext() возобновляет работу на каждом await; локальные переменные становятся её полями. На await она спрашивает у awaiter, завершена ли операция: если да — исполнение продолжается прямо здесь; если нет — builder упаковывает машину в кучу, вешает MoveNext продолжением, и поток уходит. Завершение операции снова входит в MoveNext на сохранённом состоянии.
Типичные ошибки
- ✗Думать, что
awaitуступает планировщику даже когда ожидаемая задача уже завершена - ✗Считать, что машина состояний всегда живёт в куче, а не только после приостановки
- ✗Полагать, что исходный кадр стека метода сохраняется живым через приостановку
Уточняющие вопросы
- →Когда машина состояний уезжает в кучу, а когда остаётся на стеке?
- →Как
ConfigureAwait(false)меняет то, где вызываетсяMoveNextпосле завершения?
SeniorТеорияРедкоЗачем итератору async IAsyncEnumerable<T> нужен атрибут параметра [EnumeratorCancellation]?
Зачем итератору async IAsyncEnumerable<T> нужен атрибут параметра [EnumeratorCancellation]?
Потребитель отменяет поток через await foreach (... .WithCancellation(ct)), и этот токен уходит в GetAsyncEnumerator(ct) — а не в собственные параметры итератора, которые были связаны ещё при первом вызове метода. [EnumeratorCancellation] велит компилятору направить токен уровня перечислителя в помеченный параметр, связав его с тем токеном, что передали при вызове.
Типичные ошибки
- ✗Передать токен только при вызове и ждать, что
WithCancellationдойдёт до итератора - ✗Забыть
[EnumeratorCancellation]и молча получить внутри итератора токенdefault - ✗Думать, что
await foreachостанавливает производителя без прокидывания токена внутрь
Уточняющие вопросы
- →Что компилятор генерирует для
GetAsyncEnumerator(CancellationToken)у такого итератора? - →Как во время выполнения объединяются токен вызова и токен из
WithCancellation?