Перейти к содержимому

Асинхронность

Как устроены async/await и Task: чем асинхронность отличается от параллелизма, где выполняется код после await, почему зависает .Result, как отменять операции и ограничивать их число. Без этой темы не обходится ни одно собеседование на .NET-разработчика.

Перед этой темой: Типы и память, Многопоточность.

Junior+

Чем асинхронность отличается от многопоточности и параллелизма?

Заголовок раздела «Чем асинхронность отличается от многопоточности и параллелизма?»
Ответ

Что это

  • Асинхронность — поток не простаивает, пока операция ждёт внешний ресурс. await возвращает управление, а когда результат готов, метод продолжается.
  • Многопоточность — код выполняется в нескольких потоках.
  • Параллелизм — несколько потоков реально работают одновременно на разных ядрах.

Зачем

Асинхронность сохраняет отзывчивость интерфейса и экономит потоки сервера. Параллелизм ускоряет вычисления.

Как пользоваться

Задача Инструмент
Ждём сеть, диск, базу (I/O-bound) async/await без Task.Run
Тяжёлый расчёт в UI-приложении await Task.Run(...)
Нужно посчитать быстрее Parallel.For, PLINQ

Частая ошибка

Думать, что async сам создаёт поток. Async-метод выполняется синхронно до первого await незавершённой задачи, поэтому тяжёлый цикл в нём блокирует вызывающий поток.

Как устроено

  1. Асинхронный API передаёт I/O-операцию ОС и возвращает незавершённую Task.
  2. Метод на await выходит, поток возвращается в пул и берёт другую работу.
  3. ОС сообщает о завершении, и оставшаяся часть метода ставится в пул потоков или в 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
Junior+

Почему 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>.

Под капотом

  1. AsyncVoidMethodBuilder при создании вызывает SynchronizationContext.Current?.OperationStarted().
  2. При завершении метода он вызывает OperationCompleted(). Так контекст, например тестового фреймворка, может дождаться всех async void.
  3. Необработанное исключение 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 Task
async Task SaveAsync() => await File.WriteAllTextAsync("data.txt", "...");
// Обработчик события — единственное законное место для async void
private 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)); // правильно
Middle+
Ответ

Что это

Компилятор переписывает async-метод в машину состояний: номер текущего шага плюс локальные переменные.

Как это работает

  1. Код до первого await выполняется сразу, синхронно, в вызывающем потоке.
  2. На await проверяется, завершена ли задача. Если да, выполнение идёт дальше.
  3. Если нет, метод запоминает шаг и возвращает вызывающему коду незавершённую Task.
  4. Когда задача завершится, метод продолжит работу с места остановки.
  5. return завершает Task результатом, а исключение сохраняется в ней и выбросится при await.

Частая ошибка

Считать, что после await код выполняется в том же потоке. В UI-приложении это так, а в консоли и ASP.NET Core — в любом потоке пула.

Как устроено

  1. Компилятор генерирует метод-заглушку и тип state machine. Локальные переменные, которые живут через await, становятся её полями.
  2. Заглушка вызывает builder.Start, тот синхронно запускает MoveNext, и заглушка возвращает builder.Task.
  3. MoveNext — исходный код, разрезанный по состояниям и обёрнутый в try/catch. Исключение уходит в builder.SetException, результат — в builder.SetResult.
  4. await x вызывает x.GetAwaiter() и проверяет IsCompleted.
  5. Если задача не готова, builder подписывает MoveNext на её завершение, и метод выходит.
  6. После возобновления 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 — эта аллокация и планирование продолжения.

Упрощённо: во что разворачивается await
// Исходный метод
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); }
}
Middle+

Что такое 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);
Middle+

Почему .Result и .Wait() приводят к дедлоку и как этого избежать?

Заголовок раздела «Почему .Result и .Wait() приводят к дедлоку и как этого избежать?»
Ответ

Что это

Дедлок возникает, когда UI-поток синхронно ждёт задачу, а задаче для завершения нужен UI-поток.

Как возникает

  1. Обработчик кнопки вызывает LoadAsync().Result — UI-поток заблокирован.
  2. Внутри LoadAsync есть await. После завершения операции продолжение должно выполниться в UI-потоке.
  3. UI-поток занят ожиданием, продолжение не начинается, задача не завершается.

Как избежать

Вместо Используйте
.Result, .Wait() await
Task.WaitAll await Task.WhenAll
Thread.Sleep await Task.Delay

Делайте асинхронной всю цепочку до точки входа: обработчика события, action контроллера, async Task Main.

Частая ошибка

Проверить код в консоли, где контекста нет и всё работает, а потом перенести его в UI, где он зависнет.

Как устроено

Дедлок требует трёх условий одновременно:

  1. Однопоточный контекст: WinForms, WPF, классический ASP.NET, Blazor.
  2. Блокирующее ожидание в этом контексте: .Result, .Wait(), GetAwaiter().GetResult().
  3. 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.

Под капотом

  1. В ASP.NET Core запрос с .Result держит поток пула.
  2. Продолжение, которое завершило бы задачу, тоже ждёт свободный поток пула.
  3. Пул быстро наращивает потоки до нескольких на ядро, а дальше добавляет примерно по 1–2 в секунду.
  4. Под нагрузкой задержки растут до секунд, хотя CPU почти свободен.

С .NET 6 пул быстрее реагирует на блокировки в Task.Wait и .Result, но проблему это не убирает.

Цена

  • Каждый лишний поток — память под стек и переключения контекста.
  • ThreadPool.SetMinThreads маскирует проблему: на следующем пике нагрузки всё повторится.

Компромиссы

  • Синхронная обёртка над async-методом — долг, а не решение. Если нужен синхронный API, его пишут честно синхронным.
  • Sync-over-async прячется в синхронных методах поверх async-кода: Dispose, фабрики DI, валидаторы, сторонние SDK.

Как проверить в проде

  1. dotnet-counters monitor: медленно растёт dotnet.thread_pool.thread.count, велик dotnet.thread_pool.queue.length, CPU заметно ниже 100%. В старых версиях это счётчики threadpool-thread-count и threadpool-queue-length.
  2. dotnet-stack report: много потоков пула стоят в Task.Wait или GetResultCore.
  3. Для редких эпизодов — 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"
}
Middle+

Как правильно отменять асинхронные операции?

Заголовок раздела «Как правильно отменять асинхронные операции?»
Ответ

Что это

  • CancellationTokenSource — «пульт»: у него есть Cancel() и CancelAfter(...).
  • CancellationToken — «приёмник»: у него есть IsCancellationRequested и ThrowIfCancellationRequested().

Зачем

Чтобы остановить ненужную работу: пользователь закрыл окно, клиент оборвал запрос, истёк таймаут.

Как пользоваться

  1. Создайте источник через using.
  2. Передайте cts.Token в метод последним параметром.
  3. Внутри передавайте токен во все async-вызовы: ReadAsync(buffer, ct), SaveChangesAsync(ct).
  4. Ловите 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.WhenAll Faulted у одной задачи важнее 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;
}
Middle+

Как выполнить несколько задач параллельно и ограничить их количество?

Заголовок раздела «Как выполнить несколько задач параллельно и ограничить их количество?»
Ответ

Что это

Task.WhenAll возвращает задачу, которая завершится, когда завершатся все переданные задачи.

Зачем

Последовательные await складывают время операций. Одновременные операции занимают время самой долгой из них.

Как пользоваться

  1. Запустите задачи без await: var a = GetUserAsync(id); и var b = GetOrdersAsync(id);.
  2. Дождитесь всех: await Task.WhenAll(a, b);.
  3. Возьмите результаты через 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
Senior+

Когда использовать 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 это неопределённое поведение