Асинхронність
Як влаштовані async/await і Task: чим асинхронність відрізняється від паралелізму, де виконується код після await, чому зависає .Result, як скасовувати операції та обмежувати їхню кількість. Без цієї теми не обходиться жодна співбесіда на .NET-розробника.
Перед цією темою: Типи й пам’ять, Багатопотоковість.
Чим асинхронність відрізняється від багатопотоковості та паралелізму?
Section titled “Чим асинхронність відрізняється від багатопотоковості та паралелізму?”Що це
- Асинхронність — потік не простоює, поки операція чекає на зовнішній ресурс.
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 небезпечний і де він усе ж допустимий?
Section titled “Чому 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 працює під капотом?
Section titled “Як 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)?
Section titled “Що таке 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() призводять до дедлоку і як цього уникнути?
Section titled “Чому .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? — Так, задача вже завершена, блокування не буде.
Як правильно скасовувати асинхронні операції?
Section titled “Як правильно скасовувати асинхронні операції?”Що це
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.
Як виконати кілька задач паралельно й обмежити їхню кількість?
Section titled “Як виконати кілька задач паралельно й обмежити їхню кількість?”Що це
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 і які в нього обмеження?
Section titled “Коли використовувати 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>можна чекати багаторазово й з різних місць.