Асинхронность
Как устроены async/await и Task: чем асинхронность отличается от параллелизма, где выполняется код после await, почему зависает .Result, как отменять операции и ограничивать их число. Без этой темы не обходится ни одно собеседование на .NET-разработчика.
Перед этой темой: Типы и память, Многопоточность.
Чем асинхронность отличается от многопоточности и параллелизма?
Заголовок раздела «Чем асинхронность отличается от многопоточности и параллелизма?»Что это
- Асинхронность — поток не простаивает, пока операция ждёт внешний ресурс.
awaitвозвращает управление, а когда результат готов, метод продолжается. - Многопоточность — код выполняется в нескольких потоках.
- Параллелизм — несколько потоков реально работают одновременно на разных ядрах.
Зачем
Асинхронность сохраняет отзывчивость интерфейса и экономит потоки сервера. Параллелизм ускоряет вычисления.
Как пользоваться
| Задача | Инструмент |
|---|---|
| Ждём сеть, диск, базу (I/O-bound) | async/await без Task.Run |
| Тяжёлый расчёт в UI-приложении | await Task.Run(...) |
| Нужно посчитать быстрее | Parallel.For, PLINQ |
Частая ошибка
Думать, что async сам создаёт поток. Async-метод выполняется синхронно до первого await незавершённой задачи, поэтому тяжёлый цикл в нём блокирует вызывающий поток.
Как устроено
- Асинхронный API передаёт I/O-операцию ОС и возвращает незавершённую
Task. - Метод на
awaitвыходит, поток возвращается в пул и берёт другую работу. - ОС сообщает о завершении, и оставшаяся часть метода ставится в пул потоков или в UI-поток.
CPU-bound работе нужен процессор. «Асинхронно посчитать» нельзя — можно только перенести вычисление на другой поток.
Когда что выбирать
| Ситуация | Решение |
|---|---|
| Несколько независимых I/O-вызовов | запустить все и await Task.WhenAll |
| Тяжёлый расчёт в UI-приложении | await Task.Run(...) |
| Большой объём вычислений | Parallel.For, Parallel.ForEach, PLINQ |
Ловушки
- Синхронный вызов внутри async-метода (
Thread.Sleep, синхронныйRead) блокирует поток так же, как без async. - Метод с
async, но безawaitвыполняется полностью синхронно — компилятор выдаёт предупреждение CS1998. - Для мелкой CPU-работы
Task.Runи переключение потоков могут стоить дороже самой работы.
Правило
I/O — async/await по всей цепочке вызовов. CPU — явный перенос на другой поток, и только если замер показывает выигрыш.
Под капотом
- Синхронный сервер держит поток на каждый ожидающий запрос. Поток — это память под стек и переключения контекста.
- Асинхронный сервер на время ожидания хранит в куче только небольшой объект с состоянием метода.
- Сетевые операции завершаются через IOCP (Windows) или epoll/kqueue (Linux/macOS), после чего работа уходит в пул.
- Async-код в UI-потоке или в Blazor конкурентен, но не параллелен: всё выполняется по очереди в одном логическом потоке.
Цена
- Async не ускоряет одиночный запрос и даже добавляет накладные расходы на сохранение состояния и планирование.
- Выигрыш — пропускная способность: больше одновременных запросов на том же числе потоков.
Компромиссы
await Task.Run(...)в обработчике ASP.NET Core ничего не даёт: запрос и так в пуле, а вы платите за лишний переход.- Тяжёлые вычисления выносят в фоновую обработку (
Channel<T>иBackgroundService) с ограниченным параллелизмом, чтобы они не отнимали потоки у запросов.
Как проверить в проде
Число потоков пула растёт, а CPU недогружен — значит, потоки не работают, а блокируются в ожидании. В dotnet-counters это видно по метрике dotnet.thread_pool.thread.count (.NET 9+).
Итог: async масштабирует ожидание, параллелизм масштабирует вычисления.
// I/O-bound: пока идёт запрос, поток свободенasync Task<int> GetPageLengthAsync(HttpClient http, string url){ string html = await http.GetStringAsync(url); return html.Length;}
// CPU-bound в UI-приложении: уводим расчёт с UI-потокаasync Task ShowResultAsync(int n){ long result = await Task.Run(() => SlowFibonacci(n)); resultLabel.Text = result.ToString(); // снова в UI-потоке}
// CPU-bound и нужно быстрее: делим работу между ядрамиvar squares = new long[1_000];Parallel.For(0, squares.Length, i => squares[i] = (long)i * i);Console.WriteLine(squares[999]); // 998001- Создаёт ли
asyncновый поток? — Нет: метод выполняется синхронно до первогоawaitнезавершённой задачи. - Зачем
Task.Runв action контроллера ASP.NET Core? — Обычно незачем: запрос уже в пуле, это лишний переход. - Чем
Task.Runотличается отTask.Factory.StartNew? —Task.Runразворачивает async-делегат и всегда берёт пул;StartNewвернётTask<Task>и по умолчанию использует текущий планировщик. - Ускорит ли async одиночный запрос? — Нет, он повышает пропускную способность, а не снижает задержку.
Почему async void опасен и где он всё-таки допустим?
Заголовок раздела «Почему async void опасен и где он всё-таки допустим?»Что это
async void — асинхронный метод, который возвращает void вместо Task. Вызывающий код получает управление после первого await внутри метода и больше ничего о нём не знает.
Зачем
Сигнатуру обработчика события задаёт делегат события, и там обязательно void. Чтобы обработчик мог использовать await, язык разрешает async void.
Как пользоваться
- Асинхронный метод возвращает
TaskилиTask<T>. async void— только для обработчиков событий вродеButton_Click.- Логику выносите в
async Task-метод, а в обработчике оставляйтеawaitиtry/catch.
Частая ошибка
Обернуть вызов async void-метода в try/catch и ждать, что исключение поймается. Оно вылетит позже и в другом месте.
Как устроено
async Task |
async void |
|
|---|---|---|
| Можно дождаться | да, через await |
нет |
| Куда попадает исключение | в задачу, выбрасывается при await |
в SynchronizationContext или в пул потоков |
Task.WhenAll, тесты |
работают | невозможны |
Без контекста (консоль, ASP.NET Core, worker-сервис) исключение в пуле потоков завершает процесс. В WinForms и WPF оно попадает в Application.ThreadException и Application.DispatcherUnhandledException.
Когда что выбирать
- Обработчик события или
ICommand.Execute—async voidсtry/catchвнутри. - Всё остальное —
async Task.
Ловушки
- Скрытый
async void. Async-лямбда, переданная в параметр типаAction, становитсяasync void. Поэтомуlist.ForEach(async x => await SendAsync(x))не ждёт отправок. - Реализация
void-метода. Асинхронная реализация обычногоvoid-метода интерфейса или базового класса почти всегда ошибочна: вызывающий считает, что работа закончена. - Ресурсы. Вызывающий код может освободить ресурс раньше, чем
async void-метод закончит с ним работать.
Правило
Async-лямбды передавайте только в параметры типа Func<Task>.
Под капотом
AsyncVoidMethodBuilderпри создании вызываетSynchronizationContext.Current?.OperationStarted().- При завершении метода он вызывает
OperationCompleted(). Так контекст, например тестового фреймворка, может дождаться всехasync void. - Необработанное исключение builder отправляет через
Postв захваченный контекст, а без контекста бросает в пуле потоков.
Цена
- Исключение в потоке пула завершает процесс:
AppDomain.UnhandledExceptionуспеет только залогировать. - Для ASP.NET Core это перезапуск приложения и обрыв всех текущих запросов.
TaskScheduler.UnobservedTaskExceptionне поможет: он про ненаблюдённые исключения задач, а здесь задачи нет.
Компромиссы
_ = DoAsync();процесс не уронит, но исключение останется в задаче и потеряется.- Отброшенная задача в веб-приложении переживает запрос: scoped-зависимости вроде
DbContextуже освобождены, а при остановке приложения её никто не дождётся. - Надёжный fire-and-forget — фоновая очередь:
Channel<T>иBackgroundServiceсо своим scope,try/catch, логированием и корректной остановкой.
Как проверить в проде
На ревью ищут async void вне обработчиков и async-лямбды в Action-параметрах: List<T>.ForEach, Parallel.ForEach, колбэки таймеров. Внезапные падения процесса ловят логированием в AppDomain.UnhandledException.
Итог: там, где нет UI-контекста, любое исключение в async void — это падение процесса.
// Плохо: завершения не дождаться, исключение не пойматьasync void SaveBad() => await File.WriteAllTextAsync("data.txt", "...");
// Хорошо: логика в async Taskasync Task SaveAsync() => await File.WriteAllTextAsync("data.txt", "...");
// Обработчик события — единственное законное место для async voidprivate async void SaveButton_Click(object sender, EventArgs e){ try { await SaveAsync(); } catch (Exception ex) { MessageBox.Show(ex.Message); // иначе исключение уйдёт в UI-контекст }}
// Скрытый async void: ForEach принимает Action<T>items.ForEach(async x => await SendAsync(x)); // никто не ждёт отправокawait Task.WhenAll(items.Select(SendAsync)); // правильно- Что будет с исключением в
async voidв ASP.NET Core? — Контекста нет, исключение уйдёт в пул потоков и завершит процесс. - Почему
list.ForEach(async x => ...)— ошибка? — Лямбда приводится кAction<T>, то есть этоasync void. - Чем
_ = DoAsync()отличается отasync void? — Исключение останется в задаче и не уронит процесс, но потеряется, если его не наблюдать. - Как сделать fire-and-forget безопасно? — Свой
try/catchс логированием или фоновая очередь (Channel<T>иBackgroundService).
Как async/await работает под капотом?
Заголовок раздела «Как async/await работает под капотом?»Что это
Компилятор переписывает async-метод в машину состояний: номер текущего шага плюс локальные переменные.
Как это работает
- Код до первого
awaitвыполняется сразу, синхронно, в вызывающем потоке. - На
awaitпроверяется, завершена ли задача. Если да, выполнение идёт дальше. - Если нет, метод запоминает шаг и возвращает вызывающему коду незавершённую
Task. - Когда задача завершится, метод продолжит работу с места остановки.
returnзавершаетTaskрезультатом, а исключение сохраняется в ней и выбросится приawait.
Частая ошибка
Считать, что после await код выполняется в том же потоке. В UI-приложении это так, а в консоли и ASP.NET Core — в любом потоке пула.
Как устроено
- Компилятор генерирует метод-заглушку и тип state machine. Локальные переменные, которые живут через
await, становятся её полями. - Заглушка вызывает
builder.Start, тот синхронно запускаетMoveNext, и заглушка возвращаетbuilder.Task. MoveNext— исходный код, разрезанный по состояниям и обёрнутый вtry/catch. Исключение уходит вbuilder.SetException, результат — вbuilder.SetResult.await xвызываетx.GetAwaiter()и проверяетIsCompleted.- Если задача не готова, builder подписывает
MoveNextна её завершение, и метод выходит. - После возобновления
GetResult()возвращает результат или выбрасывает исходное исключение с сохранённым стеком.
Где продолжится код
Что есть в момент await |
Где выполнится продолжение |
|---|---|
SynchronizationContext (UI, классический ASP.NET) |
в этом контексте |
нестандартный TaskScheduler |
в этом планировщике |
| ничего (консоль, ASP.NET Core) | в пуле потоков |
Ловушки
- После
awaitв ASP.NET Core поток может смениться, поэтому[ThreadStatic]-поля между шагами метода ненадёжны. awaitвнутриlockзапрещён (ошибка CS1996): продолжение может выполниться в другом потоке, аMonitorпривязан к потоку-владельцу.awaitработает с любым типом, у которого есть подходящийGetAwaiter(), — даже методом расширения.
Правило
Не полагайтесь на конкретный поток после await: состояние передавайте через параметры и локальные переменные.
Под капотом
- В Release state machine — структура, в Debug — класс для удобства отладки.
- Пока все
awaitзавершаются синхронно, структура не покидает стек. Если результат ещё и закеширован (Task.CompletedTask,Task<bool>, некоторые малыеint), метод ничего не аллоцирует. - При первой реальной приостановке структура копируется в
AsyncStateMachineBox<TStateMachine>. Этот тип наследуетTask<TResult>, то есть box и есть возвращаемая задача. builder.Startсохраняет и восстанавливаетExecutionContext, поэтому измененияAsyncLocal<T>внутри метода не утекают к вызывающему.
Цена
- Одна аллокация box при приостановке, захват контекстов и косвенный вызов
MoveNext. - Продолжение может выполниться синхронно внутри
SetResultу того, кто завершает задачу. ДляTaskCompletionSourceэто неожиданная реентерабельность, поэтому его создают сTaskCreationOptions.RunContinuationsAsynchronously.
Компромиссы
- Синхронный быстрый путь (проверить кеш до
await) убирает аллокации, но усложняет код. - В .NET 11 появляется runtime async — пока preview-функция, она включается свойством
<Features>runtime-async=on</Features>. Приостановкой управляет рантайм вместо сгенерированной state machine: живые стек-трейсы чище, накладные расходы ниже.
Как проверить в проде
- Аллокации async-кода видны в BenchmarkDotNet с
[MemoryDiagnoser]. - Незавершённые async-операции в дампе показывает команда SOS
dumpasync(dotnet-dump analyze).
Итог: state machine уходит в кучу только при реальной приостановке; цена async — эта аллокация и планирование продолжения.
// Исходный методasync Task<int> GetLengthAsync(HttpClient http, string url){ string s = await http.GetStringAsync(url); return s.Length;}
// Упрощённый MoveNext сгенерированной структуры (Release)void MoveNext(){ try { if (_state == 0) goto Resume; _awaiter = _http.GetStringAsync(_url).GetAwaiter(); if (!_awaiter.IsCompleted) { _state = 0; _builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this); return; // метод вышел, поток свободен } Resume: string s = _awaiter.GetResult(); // результат или исключение _builder.SetResult(s.Length); // завершает Task<int> } catch (Exception ex) { _builder.SetException(ex); }}- Почему локальные переменные async-метода могут оказаться в куче? — При приостановке state machine с ними копируется в кучу.
- Можно ли
await-ить свой тип? — Да, нуженGetAwaiter(), который возвращает awaiter сIsCompleted,OnCompletedиGetResult. - Что делает
await Task.Yield()? — Принудительно приостанавливает метод и ставит продолжение в очередь, даже если ждать нечего. - Почему нельзя
awaitвнутриlock? — Продолжение может выполниться в другом потоке, аMonitorпривязан к потоку-владельцу (CS1996). - Чем state machine в Debug отличается от Release? — В Debug это класс, в Release — структура, которая уходит в кучу только при приостановке.
Что такое SynchronizationContext и зачем ConfigureAwait(false)?
Заголовок раздела «Что такое SynchronizationContext и зачем ConfigureAwait(false)?»Что это
SynchronizationContext знает, как отправить работу в нужный поток. В WinForms и WPF элементы интерфейса можно менять только из UI-потока, и контекст отправляет код именно туда.
Зачем
Благодаря ему в обработчике кнопки после await можно снова менять интерфейс: код вернулся в UI-поток.
Как пользоваться
- По умолчанию
awaitзапоминает контекст и продолжает метод в нём. await task.ConfigureAwait(false)значит «контекст не нужен»: код послеawaitпойдёт в пул потоков.- В консоли и ASP.NET Core контекста нет:
SynchronizationContext.Currentравенnull.
Частая ошибка
Поставить ConfigureAwait(false) в обработчике кнопки, а потом обратиться к элементу управления. Получите исключение о доступе из чужого потока.
Как устроено
| Окружение | Куда возвращается код после await |
|---|---|
| WinForms, WPF | в очередь сообщений UI-потока |
| Классический ASP.NET | в контекст запроса, где доступен HttpContext.Current |
| Blazor | в свой контекст, один логический поток |
| ASP.NET Core, консоль, worker | контекста нет — в пул потоков |
Реализации — WindowsFormsSynchronizationContext, DispatcherSynchronizationContext и AspNetSynchronizationContext.
Когда что выбирать
- Код приложения (UI-обработчики, контроллеры) — без
ConfigureAwait(false): в UI контекст нужен, в ASP.NET Core его нет. - Библиотека общего назначения —
ConfigureAwait(false)на каждомawait: её могут вызвать из UI-приложения. Напомнить помогает анализатор CA2007.
Ловушки
- Только первый
await— мало. Если первая задача завершится синхронно, контекст не сбросится, и следующийawaitснова его захватит. - Контекст у каждого метода свой.
ConfigureAwait(false)внутри библиотеки не влияет на вызывающий обработчик — тот всё равно вернётся в UI-поток.
Правило
В приложении ConfigureAwait(false) не пишут, в библиотеке пишут на каждом await.
Под капотом
- Awaiter
TaskзахватываетSynchronizationContext.Current, если это не базовый класс. Иначе —TaskScheduler.Current, если он нестандартный. Иначе продолжение идёт в пул. ConfigureAwait(false)пропускает оба захвата. Но если задача уже завершена, код продолжится синхронно в текущем потоке и контексте.ExecutionContextтечёт через любойawait:AsyncLocal<T>иActivity.CurrentотConfigureAwaitне зависят.
Цена
- Каждое продолжение в UI-контексте проходит через очередь сообщений. Сотни мелких
awaitв UI-потоке заметно тормозят интерфейс. - В ASP.NET Core продолжения идут прямо в пул, без лишних переходов. Зато нет однопоточной гарантии:
HttpContextне потокобезопасен.
Компромиссы
| Способ уйти из контекста | Ограничение |
|---|---|
ConfigureAwait(false) |
нужен на каждом await, включая зависимости |
Task.Run вокруг вызова |
лишняя задача и переход в пул |
SetSynchronizationContext(null) |
не сбрасывает нестандартный TaskScheduler |
ConfigureAwaitOptions (.NET 8+): SuppressThrowing работает только с неуниверсальной Task, для Task<T> анализатор выдаёт CA2261. ForceYielding уводит в пул даже завершённую задачу.
Как проверить в проде
Зависший UI диагностируют дампом: UI-поток стоит в Wait или GetResult, а продолжение не выполняется. Стеки смотрят в Visual Studio (Parallel Stacks) или в dotnet-dump analyze.
Итог: ConfigureAwait(false) убирает переход в контекст, но не гарантирует уход из него и не трогает ExecutionContext.
// WinForms/WPF: после await мы снова в UI-потокеprivate async void LoadButton_Click(object sender, EventArgs e){ string data = await _service.LoadAsync(); // контекст захвачен resultBox.Text = data; // можно трогать UI}
// Библиотека: контекст вызывающего не нуженpublic async Task<string> LoadAsync(){ using var response = await _http.GetAsync(_url).ConfigureAwait(false); // здесь мы в пуле потоков, UI трогать нельзя return await response.Content.ReadAsStringAsync().ConfigureAwait(false);}
// .NET 8+: дождаться задачи, не выбрасывая её исключениеawait backgroundTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);- Нужен ли
ConfigureAwait(false)в ASP.NET Core? — В коде приложения нет, контекста нет; в библиотеках общего назначения да. - Почему его ставят на каждый
await, а не на первый? — Первая задача может завершиться синхронно, и контекст не сбросится. - Влияет ли
ConfigureAwait(false)наAsyncLocal<T>? — Нет,ExecutionContextтечёт черезawaitвсегда. - Как вернуться в UI-поток из фонового кода? —
Control.BeginInvoke,Dispatcher.InvokeAsyncилиPostу сохранённого контекста. - Есть ли контекст в Blazor? — Да, он эмулирует один логический поток; извне в него входят через
InvokeAsync.
Почему .Result и .Wait() приводят к дедлоку и как этого избежать?
Заголовок раздела «Почему .Result и .Wait() приводят к дедлоку и как этого избежать?»Что это
Дедлок возникает, когда UI-поток синхронно ждёт задачу, а задаче для завершения нужен UI-поток.
Как возникает
- Обработчик кнопки вызывает
LoadAsync().Result— UI-поток заблокирован. - Внутри
LoadAsyncестьawait. После завершения операции продолжение должно выполниться в UI-потоке. - UI-поток занят ожиданием, продолжение не начинается, задача не завершается.
Как избежать
| Вместо | Используйте |
|---|---|
.Result, .Wait() |
await |
Task.WaitAll |
await Task.WhenAll |
Thread.Sleep |
await Task.Delay |
Делайте асинхронной всю цепочку до точки входа: обработчика события, action контроллера, async Task Main.
Частая ошибка
Проверить код в консоли, где контекста нет и всё работает, а потом перенести его в UI, где он зависнет.
Как устроено
Дедлок требует трёх условий одновременно:
- Однопоточный контекст: WinForms, WPF, классический ASP.NET, Blazor.
- Блокирующее ожидание в этом контексте:
.Result,.Wait(),GetAwaiter().GetResult(). awaitбезConfigureAwait(false)где-то ниже по цепочке.
Уберите любое условие — дедлока не будет.
Когда что выбирать
| Способ | Что даёт |
|---|---|
await по всей цепочке |
решает проблему полностью |
GetAwaiter().GetResult() |
исходное исключение вместо AggregateException, но дедлок возможен |
Task.Run(() => DoAsync()).GetAwaiter().GetResult() |
контекста нет — нет и дедлока, но поток заблокирован |
Ловушки
ConfigureAwait(false)в своей библиотеке не спасает, если хоть одна зависимость ждёт без него.- Конструкторы и синхронные интерфейсы подталкивают к
.Result. Асинхронную инициализацию выносят в фабрику илиIHostedService.
Правило
Async all the way: async Task Main (с C# 7.1), async Task в тестах. .Result допустим только у уже завершённой задачи, например после await Task.WhenAll.
Под капотом
- В ASP.NET Core запрос с
.Resultдержит поток пула. - Продолжение, которое завершило бы задачу, тоже ждёт свободный поток пула.
- Пул быстро наращивает потоки до нескольких на ядро, а дальше добавляет примерно по 1–2 в секунду.
- Под нагрузкой задержки растут до секунд, хотя CPU почти свободен.
С .NET 6 пул быстрее реагирует на блокировки в Task.Wait и .Result, но проблему это не убирает.
Цена
- Каждый лишний поток — память под стек и переключения контекста.
ThreadPool.SetMinThreadsмаскирует проблему: на следующем пике нагрузки всё повторится.
Компромиссы
- Синхронная обёртка над async-методом — долг, а не решение. Если нужен синхронный API, его пишут честно синхронным.
- Sync-over-async прячется в синхронных методах поверх async-кода:
Dispose, фабрики DI, валидаторы, сторонние SDK.
Как проверить в проде
dotnet-counters monitor: медленно растётdotnet.thread_pool.thread.count, великdotnet.thread_pool.queue.length, CPU заметно ниже 100%. В старых версиях это счётчикиthreadpool-thread-countиthreadpool-queue-length.dotnet-stack report: много потоков пула стоят вTask.WaitилиGetResultCore.- Для редких эпизодов —
dotnet-trace collect --clrevents waithandle(.NET 9+) и анализ стеков в PerfView.
Итог: в ASP.NET Core sync-over-async не виснет сразу, а медленно душит сервис голоданием пула.
// WinForms: дедлокprivate void LoadButton_Click(object sender, EventArgs e){ string data = LoadAsync().Result; // UI-поток заблокирован resultBox.Text = data; // сюда выполнение не дойдёт}
private async Task<string> LoadAsync(){ await Task.Delay(1000); // продолжение ждёт свободный UI-поток return "done";}
// Исправление: async по всей цепочкеprivate async void LoadButtonFixed_Click(object sender, EventArgs e){ resultBox.Text = await LoadAsync(); // "done"}- Почему тот же код не виснет в консоли? — Там нет
SynchronizationContext, продолжение уходит в пул. - Чем
GetAwaiter().GetResult()лучше.Result? — Выбрасывает исходное исключение, а неAggregateException; от дедлока не спасает. - Бывает ли дедлок в ASP.NET Core? — Классического нет, но sync-over-async ведёт к голоданию пула потоков.
- Как найти sync-over-async в проде? —
dotnet-countersпо пулу потоков, затемdotnet-stackилиdotnet-traceс событиямиWaitHandleWait. - Безопасно ли
.Resultпослеawait Task.WhenAll? — Да, задача уже завершена, блокировки не будет.
Как правильно отменять асинхронные операции?
Заголовок раздела «Как правильно отменять асинхронные операции?»Что это
CancellationTokenSource— «пульт»: у него естьCancel()иCancelAfter(...).CancellationToken— «приёмник»: у него естьIsCancellationRequestedиThrowIfCancellationRequested().
Зачем
Чтобы остановить ненужную работу: пользователь закрыл окно, клиент оборвал запрос, истёк таймаут.
Как пользоваться
- Создайте источник через
using. - Передайте
cts.Tokenв метод последним параметром. - Внутри передавайте токен во все async-вызовы:
ReadAsync(buffer, ct),SaveChangesAsync(ct). - Ловите
OperationCanceledExceptionотдельно от остальных ошибок.
Частая ошибка
Принять токен и никуда его не передать. Тогда отмена ничего не отменяет.
Как устроено
Cancel()переводит все копии токена в состояние «отменён», и это необратимо.- Отменённая задача имеет статус
Canceled, аawaitна ней выбрасываетOperationCanceledException. ct.Register(...)подписывает колбэк для API без поддержки токена.Cancel()вызывает колбэки синхронно, поэтому они должны быть быстрыми.
Когда что выбирать
| Задача | Инструмент |
|---|---|
| Таймаут | new CancellationTokenSource(TimeSpan) или CancelAfter |
| Внешняя отмена плюс таймаут | CreateLinkedTokenSource(ct) и CancelAfter |
| Перестать ждать чужую задачу | task.WaitAsync(timeout, ct) (.NET 6+) |
| Длинный CPU-цикл | ct.ThrowIfCancellationRequested() раз в N итераций |
Ловушки
WaitAsyncперестаёт ждать, но саму операцию не останавливает.- Связанный источник подписан на родительский токен. Без
Disposeподписка живёт вместе с родителем — это утечка, если родитель долгоживущий. - Разные API бросают то
TaskCanceledException, тоOperationCanceledException. Ловите базовый тип.
Правило
Публичный async-метод принимает CancellationToken cancellationToken = default последним параметром и передаёт его дальше.
Под капотом
- Builder async-метода переводит задачу в
Canceledпри любомOperationCanceledException, независимо от токена. Task.Run(work, ct)даётCanceled, только если токен исключения совпадает сctи отменён. Иначе задачаFaulted.- Для
Task.WhenAllFaultedу одной задачи важнееCanceledу другой.
Цена
CancellationToken— структура,defaultничего не стоит.CancelAfterсоздаёт таймер,Register— запись в списке колбэков. В часто вызываемом коде неотменённый источник переиспользуют черезTryReset()(.NET 6+).CancelAsync()(.NET 8+) выполняет колбэки асинхронно и не задерживает вызывающий поток.
Компромиссы
- Отмена посреди записи оставляет частичное состояние. Токен принимают до точки фиксации (транзакция, отправленное сообщение), а после неё передают
CancellationToken.None. - В ASP.NET Core параметр
CancellationTokenв action — этоHttpContext.RequestAborted: клиент ушёл. Об остановке приложения сообщают токенBackgroundService.ExecuteAsyncиIHostApplicationLifetime.ApplicationStopping.
Как проверить в проде
Отличайте таймаут от отмены вызывающим: catch (OperationCanceledException) when (!ct.IsCancellationRequested) означает, что сработал внутренний таймаут. Его логируют и превращают в TimeoutException, иначе метрики спутают его с отказом клиента.
Итог: отмена кооперативна и меняет статус задачи; заранее решайте, где её можно принять без порчи данных.
public async Task<string> DownloadAsync(string url, CancellationToken ct = default){ // Отмена по внешнему токену или через 5 секунд using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(5));
try { return await _http.GetStringAsync(url, cts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // внешний токен не отменён — значит, сработал наш таймаут throw new TimeoutException($"{url}: нет ответа за 5 секунд"); }}
// CPU-bound цикл опрашивает токен самstatic long Sum(int[] data, CancellationToken ct){ long sum = 0; for (int i = 0; i < data.Length; i++) { if (i % 10_000 == 0) ct.ThrowIfCancellationRequested(); sum += data[i]; } return sum;}- Чем
OperationCanceledExceptionотличается отTaskCanceledException? — Второй — наследник первого; ловите базовый. - Зачем
Disposeу связанногоCancellationTokenSource? — Он подписан на родительский токен, и безDisposeподписка живёт вместе с родителем. - Отменяет ли
WaitAsyncсаму операцию? — Нет, он только перестаёт её ждать. - Как отличить таймаут от отмены клиентом? — Фильтр
whenпоIsCancellationRequestedвнешнего токена. - Откуда взять токен в ASP.NET Core? — Параметр
CancellationTokenв action (этоHttpContext.RequestAborted) и токенExecuteAsyncвBackgroundService.
Как выполнить несколько задач параллельно и ограничить их количество?
Заголовок раздела «Как выполнить несколько задач параллельно и ограничить их количество?»Что это
Task.WhenAll возвращает задачу, которая завершится, когда завершатся все переданные задачи.
Зачем
Последовательные await складывают время операций. Одновременные операции занимают время самой долгой из них.
Как пользоваться
- Запустите задачи без
await:var a = GetUserAsync(id);иvar b = GetOrdersAsync(id);. - Дождитесь всех:
await Task.WhenAll(a, b);. - Возьмите результаты через
await aиawait b— задачи уже завершены.
Для коллекции WhenAll сразу возвращает массив: int[] r = await Task.WhenAll(tasks);.
Частая ошибка
Дважды перечислить IEnumerable из Select: задачи создадутся заново. Сразу вызывайте ToArray().
Как устроено
- Задача
WhenAllждёт все задачи, даже если одна уже упала. - Если хоть одна
Faulted, итогFaulted, иException.InnerExceptionsсодержит все ошибки.awaitвыбрасывает только первую. WhenAnyвозвращает первую завершившуюся задачу и её исключение сам не выбрасывает — нужноawaitвыбранную задачу.
Когда что выбирать
| Задача | Инструмент |
|---|---|
| Несколько разных вызовов | Task.WhenAll |
| Результаты по мере готовности | Task.WhenEach (.NET 9+) |
| Большая коллекция с лимитом | Parallel.ForEachAsync (.NET 6+) |
| Лимит поверх произвольного кода | SemaphoreSlim: WaitAsync, Release в finally |
Ловушки
- У
Parallel.ForEachAsyncбез настроек лимит —Environment.ProcessorCount, для I/O это часто мало. ЗадавайтеMaxDegreeOfParallelism. Parallel.ForEachс async-лямбдой превращает её вasync void: цикл не ждёт операций.- Цикл из
WhenAnyс удалением готовой задачи из списка работает за квадратичное время. Для этого естьWhenEach.
Правило
Чтобы увидеть все ошибки, сохраните задачу WhenAll в переменную и в catch прочитайте её Exception.
Под капотом
WhenAllне отменяет соседние задачи. Для fail-fast передайте во все задачи токен общегоCancellationTokenSourceи вызовитеCancel()при первой ошибке.Parallel.ForEachAsyncпри исключении отменяет токен, переданный в тело, перестаёт выдавать элементы и завершаетсяFaultedсо всеми исключениями. При отмене внешним токеном итог —Canceled.
Цена
items.Select(ProcessAsync).ToArray() для миллиона элементов сразу создаёт миллион задач и state machine. Семафор внутри не помогает: задачи уже существуют и ждут. Parallel.ForEachAsync и каналы создают работу по мере освобождения слотов.
Компромиссы
- Непрерывный поток работы, производители и потребители с разной скоростью —
Channel<T>с ограниченной ёмкостью и N читателями. - Глобальный лимит на ресурс из разных мест кода — общий
SemaphoreSlimили rate limiter из пакетаSystem.Threading.RateLimiting. - Степень параллелизма для I/O задают по лимитам внешней системы, а не по числу ядер, и выносят в конфигурацию.
Как проверить в проде
Проведите нагрузочный тест с разной степенью параллелизма. Если пропускная способность перестала расти, а задержка, ответы 429 и таймауты растут — параллелизм слишком велик.
Итог: ограничивайте не только число одновременных операций, но и число созданных задач.
using System.Collections.Concurrent;
// Не больше 8 запросов одновременноvar options = new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct };var lengths = new ConcurrentDictionary<string, int>();await Parallel.ForEachAsync(urls, options, async (url, token) =>{ string html = await http.GetStringAsync(url, token); lengths[url] = html.Length;});
// WhenAll: все ошибки, а не только перваяTask<int>[] tasks = ids.Select(id => LoadAsync(id)).ToArray();Task<int[]> all = Task.WhenAll(tasks);try{ int[] values = await all;}catch (Exception) when (all.Exception is { } agg){ foreach (var ex in agg.InnerExceptions) Console.WriteLine(ex.Message); // каждая ошибка отдельно}
// .NET 9+: результаты по мере завершенияawait foreach (Task<int> done in Task.WhenEach(tasks)) Console.WriteLine(done.Status); // RanToCompletion или Faulted- Что выбросит
await Task.WhenAll, если упали три задачи? — Первое исключение; все три лежат вException.InnerExceptionsзадачиWhenAll. - Отменяет ли
WhenAllостальные задачи при ошибке? — Нет, нужен общийCancellationTokenSource. - Почему нельзя
Parallel.ForEachс async-лямбдой? — Лямбда станетasync void, цикл не дождётся операций. - Какая степень параллелизма у
Parallel.ForEachAsyncпо умолчанию? —Environment.ProcessorCount; для I/O её обычно задают явно. - Когда
Channel<T>лучшеParallel.ForEachAsync? — Когда данные поступают непрерывно и нужно обратное давление между производителем и потребителями.
Когда использовать ValueTask вместо Task и какие у него ограничения?
Заголовок раздела «Когда использовать ValueTask вместо Task и какие у него ограничения?»Что это
Task — класс, и async-метод обычно создаёт новый объект задачи в куче. ValueTask<T> — структура:
- если результат уже готов, она хранит само значение, и аллокации нет;
- если нет, внутри неё обычная
Task<T>.
Зачем
Метод читает значение из кеша и только при промахе идёт в базу. Почти все вызовы завершаются сразу, и ValueTask<T> избавляет от лишних объектов.
Как пользоваться
- По умолчанию пишите
TaskиTask<T>. - Результат
ValueTaskсразуawait-ите, и только один раз.
Частая ошибка
Сохранить ValueTask в поле или передать в Task.WhenAll. Для этого его сначала превращают в задачу через AsTask().
Как устроено
async ValueTask<T>при синхронном завершении не аллоцирует, а при асинхронном аллоцирует box state machine, как иTask<T>.- Метод без
asyncможет вернутьnew ValueTask<T>(value)на быстром пути иnew ValueTask<T>(LoadAsync())на медленном. Тогда на быстром пути нет даже state machine.
Когда что выбирать
| Ситуация | Тип |
|---|---|
| Обычный бизнес-метод | Task, Task<T> |
| Частый вызов, почти всегда синхронный результат | ValueTask<T> |
| Результат ждут несколько раз или кешируют | Task<T> |
| Синхронный успех без результата | Task и Task.CompletedTask |
Ловушки
С ValueTask нельзя:
awaitдважды;awaitиз двух мест одновременно;- читать
.Resultили.GetAwaiter().GetResult()до завершения; - сочетать несколько способов получить результат.
Нарушение даёт неопределённое поведение, а не понятное исключение.
Правило
Один ValueTask — одно потребление: await, или AsTask(), или Result после проверки IsCompletedSuccessfully.
Под капотом
ValueTask<T>с источником — это ссылка наIValueTaskSource<T>плюсshort-токен версии.Socketдержит по одному такому объекту на приём и отправку, поэтому последовательныеReceiveAsyncиSendAsyncне аллоцируют.- Токен лишь частично защищает от устаревшего
ValueTaskпосле переиспользования источника. Отсюда правило «одинawait».
Цена
ValueTask<T>состоит из нескольких полей: копируется дороже ссылки и увеличивает state machine вызывающего.- Для
WhenAllнуженAsTask()— та же аллокация, от которой уходили. - Свой источник строят на
ManualResetValueTaskSourceCore<T>, а пул объектов и сброс состояния остаются на вас.
Компромиссы
- С .NET 6 атрибут
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]на методе берёт box state machine из пула. Асинхронное завершение тоже обходится без аллокации, но пул удерживает память. ValueTaskв публичном интерфейсе даёт реализациям свободу оптимизировать, но обязывает всех потребителей соблюдать правила. Так сделаныIAsyncEnumerator<T>.MoveNextAsyncиIAsyncDisposable.DisposeAsync.
Как проверить в проде
- BenchmarkDotNet с
[MemoryDiagnoser]на реальном соотношении синхронных и асинхронных завершений. dotnet-counters: скорость аллокаций под нагрузкой до и после изменения.- Выигрыш заметен на миллионах вызовов в секунду; в обычном бизнес-коде это усложнение без пользы.
Итог: ValueTask — инструмент авторов библиотек, и переходить на него стоит только по данным профилирования.
using System.Collections.Concurrent;
private readonly ConcurrentDictionary<int, Product> _cache = new();
// Почти всегда результат уже в кешеpublic ValueTask<Product> GetProductAsync(int id){ if (_cache.TryGetValue(id, out var product)) return new ValueTask<Product>(product); // без аллокаций
return new ValueTask<Product>(LoadAndCacheAsync(id)); // промах: обычная Task}
private async Task<Product> LoadAndCacheAsync(int id){ Product product = await _repository.LoadAsync(id); _cache[id] = product; return product;}
// ПотребительProduct p = await GetProductAsync(42); // правильно: один await сразу
ValueTask<Product> vt = GetProductAsync(42);await vt;await vt; // нарушение: с IValueTaskSource это неопределённое поведение- Почему не возвращать
ValueTaskвезде? — Он крупнее, его нельзя ждать дважды, а выигрыш есть только при частом синхронном завершении. - Аллоцирует ли
async ValueTask<T>, если метод приостановился? — Да, box state machine, как уTask; без аллокаций — только с пулинговым builder. - Что такое
IValueTaskSource? — Переиспользуемый объект операции, через которыйValueTaskработает без аллокаций, например вSocket. - Как передать
ValueTaskвTask.WhenAll? — ЧерезAsTask(), ровно один раз. - Почему кеш задач строят на
Task<T>, а не наValueTask<T>? —Task<T>можно ждать многократно и из разных мест.