Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait
· Обновлено: · Го Комура · C#, async/await, .NET, Проектирование
История изменений (8 обновлений, последнее 30 Aug 2026)
Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.
- Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
- По замечаниям ревью схемы, добавленные в тот же день и слишком широкие, переложены в вертикальную компоновку; формулировки части схем и подписей приведены в точное соответствие с текстом. Сам текст статьи не менялся.
- Чтобы ход решения по каждому шаблону можно было проследить и по схеме, добавлено 17 диаграмм Mermaid (по норме «не меньше одной схемы на 500–750 знаков основного текста»). К уже существовавшим схемам добавлены подписи со сквозной нумерацией. Текст статьи не менялся.
- В начало статьи добавлен раздел «Карта знаний этой статьи». Понятия из текста и связи между ними собраны в краткое изложение, схему и ссылку на страницу сведений. Утверждения статьи не менялись.
- Текст обновлён по результатам внешнего ревью (1283 замечания). Содержание отдельных правок — в записях ниже.
- В примере гонки зеркал отмена со стороны вызывающего кода больше не считается сбоем зеркала. Если срабатывает `cancellationToken`, все задачи заканчиваются `OperationCanceledException`; если складывать их в `failures`, в конце получается `AggregateException`, и прерывание или тайм-аут пользователя фиксируются и повторяются как «отказ всех зеркал». В начале `catch` вызывается `ThrowIfCancellationRequested()`, и отмена уходит наружу как есть.
- В примере гонки зеркал остальные задачи больше не отменяются до проверки победителя. `Task.WhenAny` возвращает первую завершившуюся задачу, а не первую успешную. Если самое быстрое зеркало падает с 404 или обрывом соединения, оно тоже приходит как «победитель»; отменив ещё живые зеркала и пробросив исключение неудачника, вы сами уничтожаете смысл нескольких зеркал. Теперь завершившиеся задачи по одной `await`-ятся, остальные отменяются только после успеха, а при полном провале отдельные сбои собираются и выбрасываются вместе.
- Дублирующая таблица решений в главе 7 удалена и заменена ссылкой на 3.1. Формулировку про ASP.NET Core «бессмысленно, потому что уже на пуле потоков» разложили на два пункта: «пропускная способность не растёт» и «особого потока, который нужно освободить, нет». В словарь терминов добавлены `IHostedService` и `BackgroundService`; отдельно объяснено, зачем `cts.Cancel()` стоит до `try`, и добавлена шпаргалка «с чего читать».
- Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619641)
Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.
Го Комура (2026). Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619641 https://comcomponent.com/ru/blog/2026/03/09/001-csharp-async-await-best-practices/
- DOI (последняя версия)
- 10.5281/zenodo.21619641
- DOI (эта версия)
- 10.5281/zenodo.21619642
async / await в C# используют каждый день, но на практике путает не синтаксис, а в какой ситуации какую запись выбрать.
В поиске чаще всего спрашивают: когда брать Task.Run, куда ставить ConfigureAwait(false), можно ли fire-and-forget.
- ожидание I/O оборачивают в
Task.Run - независимые операции
await-ят по одной, последовательно fire-and-forgetставят слишком легко и теряют исключения и момент завершенияConfigureAwait(false)везде расставляют одинаковоValueTaskвыбирают только потому, что «звучит легче»
Это проще не зубрить по отдельности, а начать с того, чтобы сначала различить тип работы.
Статья рассчитана в основном на обычную разработку приложений на C# / .NET для .NET 6 и новее и выстраивает приёмы вокруг async / await в порядке, удобном для решения.
Имеется в виду, например, такая разработка.
- настольные приложения вроде WinForms / WPF
- веб-приложения и API на ASP.NET Core
- worker / фоновые службы
- консольные приложения
- переиспользуемые библиотеки классов
Код из статьи опубликован на GitHub как полный набор, который можно собрать и запустить (библиотека, консольная демонстрация и модульные тесты, проверяющие каждый шаблон из таблицы решений).
csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)
Как читать эту статью
Статья довольно длинная, поэтому сначала — входы по цели.
| Цель | Куда смотреть |
|---|---|
| Нужна только таблица решений | Таблица и схема в 3.1. Это центр статьи |
| Нужна запись каждого шаблона | С 3.2 дальше. Строки таблицы 3.1 соответствуют разделам один к одному |
| Нужно пересмотреть свой код | Таблица антипаттернов в 5. |
| Нужно выровнять критерии ревью | Чек-лист в 6. |
| Нужен только вывод | 1. |
Содержание
- Сначала вывод (в двух словах)
- Слова, которые используются в статье
- 2.1. Сначала стоит различить
- 2.2. Часто встречающиеся слова
- Таблица решений, с которой стоит начать
- 3.1. Общая картина
- 3.2. Если это ожидание I/O — await-ить async API напрямую
- 3.3. Если нагрузка на CPU тяжёлая — выбрать, где вызывать Task.Run
- 3.4. Если несколько независимых операций — Task.WhenAll
- 3.5. Если нужен первый завершившийся результат — Task.WhenAny
- 3.6. Если элементов много и нужно ограничить параллелизм — Parallel.ForEachAsync или SemaphoreSlim
- 3.7. Если нужно вести по порядку — Channel<T>
- 3.8. Если нужно крутить с фиксированным интервалом — PeriodicTimer
- 3.9. Если данные приходят постепенно — IAsyncEnumerable<T>
- 3.10. Если нужно освобождать асинхронно — await using
- 3.11. Если взаимное исключение пересекает await — SemaphoreSlim
- 3.12. Разный await в UI, прикладном коде и библиотеке
- Базовые правила записи
- 4.1. Возвращаемый тип — сначала Task / Task<T>
- 4.2. async void — только для обработчиков событий
- 4.3. Принять CancellationToken и передать его дальше
- 4.4. Асинхронный API вести асинхронным до конца
- 4.5. Задачи из LINQ фиксировать через ToArray / ToList
- Частые антипаттерны
- Чек-лист на ревью
- Кратко: как выбирать
- Итог
- Источники
Карта знаний этой статьи
Статья показывает, что в async/await в C# первым делом нужно отличить I/O-bound от CPU-bound: ожидание I/O делают прямым await к async API, а CPU-вычисления в UI выносят в Task.Run и избегают этого в обработке запроса ASP.NET Core. ConfigureAwait(false) сильнее уместен в универсальном библиотечном коде, который не зависит от UI или контекста приложения, чем в ASP.NET Core, у которого нет SynchronizationContext. Независимые I/O-операции связывают Task.WhenAll или Task.WhenAny; если элементов много, параллелизм ограничивают Parallel.ForEachAsync или SemaphoreSlim. Обработку, чьё время жизни нужно отвязать от вызывающего, ведут не fire-and-forget, а через Channel
flowchart LR
accTitle: Карта знаний: практические решения по async/await в C#
accDescr: Схема показывает, как различение I/O-bound и CPU-bound связано с выбором Task.Run и ConfigureAwait(false) и с выбором WhenAll, WhenAny, Channel и других средств.
io_bound_operation["операция I/O-bound"]
cpu_bound_operation["CPU-bound обработка"]
taskrun_dotnet["Task.Run"]
ui_thread_context["контекст потока UI"]
asp_net_core_request_thread["обработка запросов ASP.NET Core"]
sync_over_async["sync-over-async"]
threadpool_starvation["голодание пула потоков (starvation)"]
synchronizationcontext["SynchronizationContext"]
configureawait_false["ConfigureAwait(false)"]
generic_library_code["универсальный библиотечный код"]
cancellationtoken_dotnet["CancellationToken (.NET)"]
async_void["async void"]
event_handler_method["метод обработчика события"]
regular_async_method["обычный асинхронный метод (Task/Task<T>)"]
task_whenall["Task.WhenAll"]
task_whenany["Task.WhenAny"]
parallel_foreachasync["Parallel.ForEachAsync"]
semaphoreslim["SemaphoreSlim"]
channel_t["Channel<T>"]
backpressure["backpressure"]
backgroundservice["BackgroundService"]
ihostedservice["IHostedService"]
fire_and_forget["fire-and-forget"]
decoupled_background_work["фоновая работа, отвязанная от времени жизни вызывающего"]
periodictimer["PeriodicTimer"]
iasyncenumerable["IAsyncEnumerable<T>"]
await_using["await using"]
valuetask["ValueTask"]
taskrun_dotnet -->|"не рекомендуется"| io_bound_operation
taskrun_dotnet -->|"рекомендуется для"| cpu_bound_operation
taskrun_dotnet -->|"рекомендуется для"| ui_thread_context
taskrun_dotnet -->|"не рекомендуется"| asp_net_core_request_thread
sync_over_async -->|"может вызвать"| threadpool_starvation
ui_thread_context -->|"использует"| synchronizationcontext
configureawait_false -->|"рекомендуется для"| generic_library_code
configureawait_false -->|"не рекомендуется"| ui_thread_context
io_bound_operation -.->|"использует"| cancellationtoken_dotnet
async_void -->|"рекомендуется для"| event_handler_method
async_void -->|"не рекомендуется"| regular_async_method
task_whenall -->|"рекомендуется для"| io_bound_operation
task_whenany -.->|"использует"| cancellationtoken_dotnet
parallel_foreachasync -->|"рекомендуется для"| io_bound_operation
semaphoreslim -.->|"рекомендуется для"| io_bound_operation
channel_t -.->|"использует"| backpressure
backgroundservice -->|"рекомендуется для"| channel_t
backgroundservice -->|"реализует"| ihostedservice
fire_and_forget -->|"не рекомендуется"| decoupled_background_work
channel_t -->|"рекомендуется для"| decoupled_background_work
periodictimer -->|"использует"| cancellationtoken_dotnet
iasyncenumerable -->|"рекомендуется для"| io_bound_operation
await_using -.->|"требует"| io_bound_operation
valuetask -->|"не рекомендуется"| regular_async_method
event_handler_method -.->|"требует"| ui_thread_context
ihostedservice -->|"рекомендуется для"| asp_net_core_request_thread
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 26, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
1. Сначала вывод (в двух словах)
async/await— это способ записи, чтобы во время ожидания не занимать поток, а не механизм, который сам по себе всё ускоряет или сам переносит работу на другой поток- Сначала разделяют, чем является эта работа: ожиданием I/O или вычислением CPU
- Для ожидания I/O базовый приём — напрямую await-ить async API
- Для вычисления CPU думают, где это вычисление должно идти. В UI
Task.Runиногда помогает, но в обработке запроса ASP.NET Core запись, гдеTask.Runсразу await-ят, в основном избегают - Для нескольких независимых операций сначала смотрят на
Task.WhenAll, а не на последовательный await - Когда элементов много, не бросают всё сразу через
Task.WhenAll, а задают верхнюю границу параллелизма fire-and-forgetвыглядит просто, но им трудно управлять. Если время жизни действительно нужно отвязать от вызывающей стороны, стабильнее отдать работу в управляемое место — Channel, HostedService и т. п.- Возвращаемый тип — сначала
Task/Task<T>.ValueTaskвыбирают, когда измерения показали, что он нужен ConfigureAwait(false)силён в коде универсальной библиотеки, а в UI и прикладном коде для начала достаточно обычногоawaitasync voidне используют нигде, кроме обработчиков событий
Иначе говоря, вокруг async / await важнее всего не скатываться к «на всякий случай Task.Run», «на всякий случай fire-and-forget», «на всякий случай ValueTask».
Для начала смотрят на три вещи.
- чего именно ждёт эта работа
- кто владеет временем жизни этой работы
- где контролируют число одновременных выполнений
Если рассмотреть эти три пункта, колебаний становится заметно меньше.
flowchart TB
accTitle: Три вопроса, которые снимают путаницу
accDescr: Если по очереди спросить, чего ждёт эта работа, кто владеет её временем жизни и где контролируют число одновременных выполнений, путаница вокруг записи async/await заметно снижается.
q1["Чего ждёт эта работа"] --> q2["Кто владеет временем жизни"]
q2 --> q3["Где контролируют число одновременных выполнений"]
q3 --> less["Путаницы в записи становится меньше"]
q1 -.-> avoid["Избегать Task.Run «на всякий случай»"]
Рис. 1: Не выбирать «на всякий случай». Сначала смотрят на тип ожидания, время жизни и число одновременных выполнений.
2. Слова, которые используются в статье
2.1. Сначала стоит различить
Если сразу развести эти два понятия, путаницы становится гораздо меньше.
| Слово | Что имеется в виду здесь |
|---|---|
| I/O-bound | Работа, в центре которой ожидание внешнего завершения: HTTP, БД, файлы, сокеты и т. п. |
| CPU-bound | Работа, в центре которой само вычисление CPU: сжатие, обработка изображений, хеш, тяжёлые преобразования и т. п. |
async / await особенно полезен именно при ожидании I/O: пока идёт ожидание, поток можно отдать другой работе. Вычисление CPU — это не «ожидание», а реальное время счёта, поэтому главные вопросы — на каком потоке это выполнять и как задать степень параллелизма.
flowchart TB
accTitle: Чем I/O-bound отличается от CPU-bound
accDescr: I/O-bound — это в основном ожидание внешнего завершения, и на время await поток можно отдать другой работе; CPU-bound — это само вычисление, и там важны поток выполнения и степень параллелизма.
io["I/O-bound (ожидание внешнего завершения)"] --> e1["Пока ждём, поток можно отдать другой работе"]
e1 --> fit["Именно здесь async/await особенно полезен"]
cpu["CPU-bound (само вычисление)"] --> e2["Главный вопрос — на каком потоке выполнять"]
e2 --> par["Степень параллелизма тоже становится темой"]
Рис. 2: Сначала разделяют эти два типа. Если в центре ожидание или вычисление, меняется и предмет рассуждения.
2.2. Часто встречающиеся слова
| Слово | Что имеется в виду здесь |
|---|---|
| Блокировка | Продолжать занимать поток, пока ждёте завершения |
fire-and-forget |
Запуск, при котором вызывающая сторона не ждёт завершения |
SynchronizationContext |
Механизм, который держит ответ на вопрос «где выполнять продолжение после await». Подробности — в пояснении ниже |
| backpressure | Механизм, который при слишком быстром поступлении данных заставляет ждать пишущую сторону и не даёт очереди разрастаться |
IHostedService |
Механизм универсального хоста .NET: при старте вызывается StartAsync, при остановке — StopAsync. Вход для постоянно живущей работы, привязанной к времени жизни приложения |
BackgroundService |
Абстрактный класс, реализующий IHostedService. Достаточно переопределить ExecuteAsync(CancellationToken), чтобы написать постоянно живущий цикл. Регистрируется через AddHostedService<T>() (раздел 3.7) |
Когда используют Channel<T>, потребитель (consumer) как раз и ставят в этот BackgroundService. В 3.7 речь о схеме «положить в очередь, выделенный потребитель обрабатывает по порядку»; время жизни этого потребителя хост совмещает со стартом и остановкой приложения.
Пояснение про SynchronizationContext
Разговор про ConfigureAwait(false) (3.12) в итоге сводится к пониманию этого одного термина.
- когда
awaitвыполняет продолжение, он захватываетSynchronizationContextна момент входа в ожидание и возвращает выполнение туда (еслиSynchronizationContextне задан, смотрит, не используется лиTaskSchedulerпомимо стандартного) - у WinForms / WPF есть
SynchronizationContext, который перебрасывает работу на поток UI. Поэтому послеawaitможно обычным образом трогать элементы управления - в ASP.NET Core
SynchronizationContextнет. Возвращаться некуда, и продолжение послеawaitидёт на свободном потоке пула ConfigureAwait(false)— указание: продолжение можно выполнять, не возвращаясь в захваченный контекст
flowchart TB
accTitle: Куда возвращается продолжение await
accDescr: await захватывает SynchronizationContext на момент входа в ожидание и возвращает продолжение туда: в WinForms/WPF это поток UI, поэтому после await можно трогать элементы управления; в ASP.NET Core возвращаться некуда, и продолжение идёт на пуле потоков.
aw["await захватывает контекст"] --> ui["Поток UI WinForms или WPF"]
aw --> asp["В ASP.NET Core возвращаться некуда"]
ui --> touch["После await можно трогать UI"]
asp --> pool["Продолжение идёт на пуле потоков"]
Рис. 3: Разговор про ConfigureAwait(false) сводится к одному: куда возвращается продолжение await.
Отсюда выводы раздела 3.12: «в UI-коде естественнее не ставить», «в прикладном коде ASP.NET Core разница невелика», «в универсальной библиотеке, которую могут вызвать из любого контекста, ставить имеет смысл». Самый цельный фон — ConfigureAwait FAQ в источниках, раздел 9.
Особенно важно, что асинхронность и параллелизм — разные вещи.
- асинхронность — про то, как ждать
- параллелизм — про то, как продвигаться одновременно
Когда эти два понятия смешивают, хочется вставить Task.Run куда угодно.
Это первая развилка.
flowchart TB
accTitle: Асинхронность и параллелизм — разные вещи
accDescr: Асинхронность — про то, как ждать, параллелизм — про то, как продвигаться одновременно; если смешать эти два разговора, хочется ставить Task.Run везде, и именно здесь проходит первая развилка.
a["Асинхронность (как ждать)"] -.-> mix["Смешение ведёт к Task.Run повсюду"]
p["Параллелизм (как идти одновременно)"] -.-> mix
mix --> fork["Первая развилка здесь"]
Рис. 4: Асинхронность — про ожидание, параллелизм — про одновременное продвижение. Если это различие ломается, Task.Run начинают ставить без разбора.
3. Таблица решений, с которой стоит начать
3.1. Общая картина
Если начать с этой таблицы, общее направление обычно становится ясным.
| Ситуация | Что брать сначала | На что смотреть |
|---|---|---|
| Ожидание HTTP / БД / файлов | Напрямую await async API |
Не оборачивать в Task.Run |
| Тяжёлое вычисление, которое не должно останавливать UI | Task.Run |
Снять вычисление CPU с потока UI |
| Обработка запроса в ASP.NET Core | Обычный await |
Не await-ить Task.Run сразу же |
| Несколько независимых асинхронных операций | Task.WhenAll |
Сначала запустить всё, потом ждать вместе |
| Нужен только первый завершившийся результат | Task.WhenAny |
Продумать отмену остальных и сбор исключений |
| Много элементов, нужен верхний предел | Parallel.ForEachAsync / SemaphoreSlim |
Явно задать степень параллелизма |
| Фоновая работа, которую нужно вести по порядку | Channel<T> |
Продумать ограниченную очередь и backpressure |
| Асинхронная работа с фиксированным интервалом | PeriodicTimer |
Соблюдать правило «один таймер — один потребитель» |
| Обрабатывать результаты по мере поступления | IAsyncEnumerable<T> / await foreach |
Не ждать завершения всех |
| Нужно асинхронное освобождение | await using |
Использовать IAsyncDisposable |
| Взаимное исключение через await | SemaphoreSlim.WaitAsync |
Обязательно Release в try/finally |
| Код универсальной библиотеки | Рассмотреть ConfigureAwait(false) |
Не зависеть от UI и прикладного контекста |
flowchart TD
accTitle: Общая картина выбора
accDescr: Сначала отделяют ожидание внешнего I/O от тяжёлого вычисления CPU; если задач несколько, инструмент выбирают по тому, ждать ли все, брать первую завершившуюся, ограничивать параллелизм, вести по порядку, крутить по таймеру или читать поток.
start["Работа, которую нужно сделать"] --> q1{"Ждём внешний I/O?"}
q1 -- "Да" --> p1["Напрямую await async API"]
q1 -- "Нет" --> q2{"Тяжёлое вычисление CPU?"}
q2 -- "Да" --> q3{"Где выполнять?"}
q3 -- "Событие UI / десктоп" --> p2["Рассмотреть Task.Run"]
q3 -- "Запрос ASP.NET Core" --> p3["Не оборачивать в Task.Run<br/>При необходимости — в отдельный worker или очередь"]
q3 -- "worker / фон" --> p4["Выполнить на месте или<br/>явно задать степень параллелизма"]
q2 -- "Нет" --> q4{"Обрабатывается несколько задач?"}
q4 -- "Ждать завершения всех" --> p5["Task.WhenAll"]
q4 -- "Использовать первую завершившуюся" --> p6["Task.WhenAny"]
q4 -- "Много элементов" --> p7["Parallel.ForEachAsync<br/>или SemaphoreSlim"]
q4 -- "Вести по порядку" --> p8["Channel<T>"]
q4 -- "Фиксированный интервал" --> p9["PeriodicTimer"]
q4 -- "Последовательный поток" --> p10["IAsyncEnumerable<T>"]
Рис. 5: Общая картина выбора. Сначала отделяют ожидание I/O от вычисления CPU; если задач несколько, инструмент выбирают по способу объединения.
Дальше каждый шаблон — по порядку.
3.2. Если это ожидание I/O — await-ить async API напрямую
Это самый базовый шаблон.
Для HTTP, БД, чтения и записи файлов сначала смотрят, есть ли асинхронная версия API.
Если есть, базовый приём — напрямую её await-ить.
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
Чего здесь стоит избегать — оборачивать уже асинхронный I/O в Task.Run.
// Плохой пример
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}
Это лишь заново перебрасывает ожидание I/O на другой поток: читать код становится труднее, а выгоды нет.
- при ожидании I/O
Task.Runне нужен - сначала ищут async API
- если token получен, его передают дальше как есть
Это вполне стандартный путь.
flowchart TB
accTitle: Базовая форма ожидания I/O
accDescr: Для ожидания HTTP, БД или файла сначала ищут асинхронную версию API и напрямую её await-ят; обернуть уже асинхронный I/O в Task.Run — значит только перебросить ожидание на другой поток, без выгоды.
need["Ожидание HTTP, БД, файла"] --> find["Сначала искать async API"]
find --> aw["Напрямую await"]
wrap["Обернуть в Task.Run"] -.-> bad["Только переброс, выгоды нет"]
Рис. 6: Базовый приём для I/O — напрямую await-ить async API. Оборачивать в Task.Run не стоит.
3.3. Если нагрузка на CPU тяжёлая — выбрать, где вызывать Task.Run
Task.Run полезен, когда нужно снять вычисление CPU с текущего потока.
Если тяжёлый расчёт крутить прямо в обработчике события UI, экран останавливается.
В такой ситуации Task.Run — естественный выбор.
flowchart TB
accTitle: Как Task.Run помогает в UI
accDescr: Тяжёлый расчёт прямо в обработчике события UI останавливает экран; если через Task.Run снять вычисление CPU с потока UI, интерфейс продолжает отвечать. Это типичный случай, где Task.Run уместен.
heavy["Тяжёлый расчёт в событии UI"] -.-> freeze["Экран останавливается"]
run["Task.Run снимает работу с потока UI"] --> keep["Интерфейс продолжает отвечать"]
Рис. 7: Task.Run помогает, когда есть особый поток, который нужно освободить (поток UI).
public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var sha256 = System.Security.Cryptography.SHA256.Create();
byte[] current = data;
for (int i = 0; i < repeat; i++)
{
cancellationToken.ThrowIfCancellationRequested();
current = sha256.ComputeHash(current);
}
return current;
}, cancellationToken);
}
Важно, откуда именно идёт вызов.
- UI вроде WinForms / WPF: бывают ситуации, где
Task.Runуместен - обработка запроса ASP.NET Core: запись, где
Task.Runсразуawait-ят, в основном избегают - worker / фоновая работа: либо выполнять на месте, либо проектировать степень параллелизма
Если в обработку запроса ASP.NET Core вставить ещё один слой Task.Run и сразу его await-ить, обычно добавляется лишь лишнее планирование.
Это место легко понять неправильно, поэтому причины лучше разделить. Дело не в том, что «уже на пуле потоков, значит Task.Run бессмыслен» (на пуле потоков идёт и фоновая работа UI-приложения). Суть в двух пунктах.
- Пропускная способность не растёт. Суммарный объём вычислений CPU тот же, меняется только место: другой поток пула. Число одновременно обрабатываемых запросов от этого не увеличивается
- Ожидание тоже не освобождается. В UI
Task.Runпомогает, потому что есть один особый поток, который нужно освободить (поток UI). На сервере такого одного потока нет. Исходный поток действительно освобождается, но вместо него ровно на то же время занимает другой — в сумме ноль
Остаются стоимость постановки в очередь и переключения потока и то, что «на каком потоке это сейчас идёт» становится на шаг менее понятным. Поэтому так не делают.
flowchart TB
accTitle: Почему в ASP.NET Core избегают Task.Run
accDescr: Если вставить Task.Run в обработку запроса, суммарный объём вычислений не меняется, особого потока, который нужно освободить, как в UI, нет, в сумме ноль, и остаются только стоимость переключения и худшая читаемость.
tr["Task.Run в обработке запроса"] --> r1["Суммарный объём вычислений тот же"]
tr --> r2["Особого одного потока освобождать некого"]
r1 --> zero["В сумме ноль"]
r2 --> zero
zero --> cost["Остаются стоимость переключения и худшая читаемость"]
Рис. 8: На сервере сразу await-ить Task.Run не даёт ни пропускной способности, ни освобождения ожидания.
Поэтому в ASP.NET Core разумнее рассуждать так.
- ожидание I/O — обычный
await - короткое вычисление CPU — выполнять на месте
- длительную работу или работу, которую нужно отвязать от времени жизни запроса, — отдавать в очередь или HostedService
Отдельно: если из UI вызывают API, у которого есть только синхронная версия, Task.Run иногда берут ради отзывчивости UI.
Но это не «асинхронный I/O», а лишь обход ценой занятия одного потока.
На серверной стороне вроде ASP.NET Core такой обход, как правило, плохо масштабируется.
3.4. Если несколько независимых операций — Task.WhenAll
Часто встречается код, который независимые асинхронные операции ждёт по одной.
// Независимые операции стали последовательными
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);
Если они друг от друга не зависят, естественнее сначала запустить всё, а в конце ждать вместе.
public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
Task<string>[] tasks = urls
.Select(url => _httpClient.GetStringAsync(url, cancellationToken))
.ToArray();
return await Task.WhenAll(tasks);
}
Ключевой момент — ToArray().
LINQ выполняется отложенно, поэтому после одного Select перечисление ещё может не произойти.
Если один раз зафиксировать результат через ToArray() или ToList(), все задачи стартуют именно в этот момент.
sequenceDiagram
accTitle: Независимые задачи и Task.WhenAll
accDescr: Независимые задачи сначала все запускают, затем ждут завершения вместе через Task.WhenAll.
participant Caller as Вызывающая сторона
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3
Caller->>T1: Запуск
Caller->>T2: Запуск
Caller->>T3: Запуск
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: Готово
T2-->>Caller: Готово
T3-->>Caller: Готово
Рис. 9: Независимые задачи сначала все запускают, затем ждут завершения вместе через Task.WhenAll.
Этот шаблон подходит, когда:
- элементов мало или умеренное количество
- нужно дождаться всех вместе
- запускать всё одновременно без ограничения допустимо
Если элементов много, безопаснее задать верхнюю границу параллелизма, как в разделе 3.6 ниже.
3.5. Если нужен первый завершившийся результат — Task.WhenAny
Когда из нескольких зеркал нужно взять первое ответившее, Task.WhenAny читается прямо.
public async Task<byte[]> DownloadFromFirstMirrorAsync(
IReadOnlyList<string> urls,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
List<Task<byte[]>> pending = urls
.Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
.ToList();
var failures = new List<Exception>();
try
{
while (pending.Count > 0)
{
Task<byte[]> finished = await Task.WhenAny(pending);
pending.Remove(finished);
try
{
byte[] data = await finished; // Сюда попадаем только при успехе
cts.Cancel(); // Останавливаем остальные, когда победитель уже известен
return data;
}
catch (Exception ex)
{
// Если остановила вызывающая сторона, это не «сбой зеркала».
// Если пропустить это, отмены всех задач накопятся как сбои,
// в конце получится AggregateException, и прерывание не отличить от аварии
cancellationToken.ThrowIfCancellationRequested();
// Это зеркало не подошло. В остальных ещё есть шанс — продолжаем
failures.Add(ex);
}
}
}
finally
{
cts.Cancel(); // Даже если вышли по исключению, остановить незавершённые загрузки
try
{
await Task.WhenAll(pending);
}
catch
{
// Собираем отмены и сбои всех, кроме победителя
}
}
throw new AggregateException("Не удалось получить данные ни с одного зеркала.", failures);
}
В этом коде важен порядок: отмена уходит только после того, как победитель уже известен.
Task.WhenAnyвозвращает первую завершившуюся задачу, а не первую успешную. Если самое быстрое зеркало падает с 404 или обрывом соединения, оно всё равно приходит как «победитель»- если отменить остальные до просмотра результата, вы сами остановите ещё живые зеркала и пробросите исключение неудачника. Смысл нескольких зеркал пропадает целиком — это самый плохой полом
- поэтому завершившиеся задачи по одной
await-ят и остальные отменяют только после успеха. При неудаче задачу убирают из кандидатов и ждут следующего завершения Cancel()только выставляет требование и не ждёт, пока другая сторона остановится. Поэтому вfinallyждут оставшиеся задачи и здесь же наблюдают исключения отмены и сбоя. Если это пропустить, никто не увидит исключение, оставшееся на задаче- если не удалось ничего, отдельные сбои выбрасывают вместе. Если бросить только первое исключение, пропадает, «какое зеркало и как именно не сработало»
- отмену со стороны вызывающего кода не считают сбоем и пробрасывают наружу как есть. Если срабатывает
cancellationToken, все задачи заканчиваютсяOperationCanceledException; сложить это вfailures— значит получить в концеAggregateExceptionи записать и повторить прерывание или тайм-аут пользователя как «отказ всех зеркал». В началеcatchвызываютThrowIfCancellationRequested(), и отмена уходит какOperationCanceledException
Здесь важно помнить: WhenAny возвращает только одного победителя.
Остальные операции, если ничего не сделать, продолжают идти сами.
Поэтому заранее решают:
- нужно ли отменять остальные
- нужно ли наблюдать их исключения
Task.WhenAny удобен, но требует чуть больше проектирования, чем WhenAll.
Понятнее всего выбирать его, когда «достаточно только первого результата».
flowchart TB
accTitle: WhenAny: сначала проверить победителя, потом остановить остальные
accDescr: Task.WhenAny возвращает первую завершившуюся задачу, а не успешную, поэтому каждое завершение по очереди await-ят, остальные отменяют только при успехе, при неудаче кандидата убирают и ждут следующего, а при полном провале сбои выбрасывают вместе.
any["WhenAny даёт первое завершение"] --> chk{"Эта задача завершилась успехом?"}
chk -->|"Успех"| win["Отменить остальные и вернуть результат"]
chk -->|"Неудача"| next["Убрать из кандидатов и записать сбой"]
next --> rest{"Кандидаты ещё есть?"}
rest -->|"Да"| any
rest -->|"Нет"| agg["Выбросить сбои вместе"]
Рис. 10: «Первое завершение» — не «первый успех». Остальные останавливают после проверки результата победителя.
3.6. Если элементов много и нужно ограничить параллелизм — Parallel.ForEachAsync или SemaphoreSlim
Task.WhenAll запускает все созданные задачи одновременно.
Поэтому при большом числе элементов сразу растут HTTP-подключения, подключения к БД, память и нагрузка на внешние службы.
В таких случаях стабильнее заранее решить, сколько элементов обрабатывать одновременно.
Parallel.ForEachAsync делает это намерение очень читаемым.
public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
urls.Select((url, index) => (url, index)),
options,
async (item, token) =>
{
string html = await _httpClient.GetStringAsync(item.url, token);
string path = Path.Combine("cache", $"{item.index}.html");
await File.WriteAllTextAsync(path, html, token);
});
}
Этот шаблон подходит, когда:
- элементов много
- обработка каждого элемента независима
- при этом запускать всё разом нежелательно
Если нужен более гибкий контроль, есть подход с SemaphoreSlim —
например, «к конкретному внешнему API не больше 4 одновременных вызовов».
Иначе говоря:
- если элементов немного —
Task.WhenAll - если много —
Parallel.ForEachAsyncилиSemaphoreSlim
Такое разделение почти никогда сильно не подводит.
flowchart TB
accTitle: Как ограничивать параллелизм в зависимости от числа элементов
accDescr: Несколько независимых операций можно сразу запустить через Task.WhenAll; если элементов много, сразу растут подключения, память и внешняя нагрузка, поэтому верхнюю границу одновременных выполнений задают Parallel.ForEachAsync или SemaphoreSlim.
q{"Элементов много?"}
q -->|"Несколько"| all["Сразу все через Task.WhenAll"]
q -->|"Много"| limit["Задать верхнюю границу параллелизма"]
limit --> pfe["Parallel.ForEachAsync"]
limit --> sem["Гибкий контроль через SemaphoreSlim"]
Рис. 11: Развилка — можно ли бросать все сразу. Если элементов много, число одновременных выполнений задают явно.
3.7. Если нужно вести по порядку — Channel<T>
Иногда хочется отвязать от вызывающей стороны работу, которая «не обязана закончиться прямо сейчас, но обязательно должна быть сделана»: отправку почты, пересылку логов, постобработку webhook, преобразование файлов.
Если в таких случаях просто запускать Task.Run и забывать о нём, становится неясно:
- где смотреть исключения
- ждать ли завершения при остановке
- сколько принимать, если объём вырастет
Такую работу проще контролировать, если класть её в очередь и обрабатывать по порядку выделенным потребителем.
flowchart LR
accTitle: Ограниченный Channel и backpressure
accDescr: Producer пишет через WriteAsync; если в очереди есть место, элемент попадает в Channel, иначе пишущая сторона ждёт, пока место освободится; consumer читает ReadAsync и обрабатывает по порядку.
p["producer"] --> w["WriteAsync"]
w --> q{"В очереди есть место?"}
q -- "Да" --> c["Попадает в Channel"]
q -- "Нет" --> b["Ждёт, пока освободится место"]
c --> d["consumer вызывает ReadAsync"]
d --> e["await и обработка по порядку"]
Рис. 12: Поток ограниченного Channel. Если очередь заполнена, пишущая сторона ждёт — это и есть backpressure.
Channel<T> позволяет довольно прямо записать схему producer / consumer.
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
public ValueTask EnqueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workItem);
return _queue.Writer.WriteAsync(workItem, cancellationToken);
}
public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
=> _queue.Reader.ReadAsync(cancellationToken);
}
BoundedChannelFullMode.Wait в этом примере значит: если очередь заполнена, заставить ждать пишущую сторону.
Это и есть backpressure.
В ASP.NET Core понятная схема — потреблять такую очередь вместе с BackgroundService.
По сравнению с «настоящим fire-and-forget» так заметно проще работать с исключениями, остановкой, степенью параллелизма и верхними пределами.
flowchart TB
accTitle: Запуск «в никуда» и управление через очередь
accDescr: Голый Task.Run без ожидания делает неясными исключения, остановку и лимит приёма; если класть работу в Channel и потреблять её в BackgroundService, исключениями, остановкой, параллелизмом и верхними пределами управлять проще.
ff["Голый Task.Run без ожидания"] -.-> vague["Исключения, остановка и лимит неясны"]
ch["Класть в очередь Channel"] --> bs["Потребляет BackgroundService"]
bs --> mng["Можно управлять исключениями, остановкой и параллелизмом"]
Рис. 13: Если время жизни нужно отвязать от вызывающей стороны, работу отдают не в fire-and-forget, а в управляемое место.
3.8. Если нужно крутить с фиксированным интервалом — PeriodicTimer
Для асинхронной работы с фиксированным интервалом PeriodicTimer читается очень прямо.
public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await RefreshCacheAsync(cancellationToken);
}
}
Достоинства такой записи:
- поток выполнения проще проследить, чем у таймера с колбэками
- можно писать в стиле
await - при остановке естественно используется
CancellationToken
Предостережение: PeriodicTimer используют исходя из того, что для одного таймера одновременно не запускают несколько вызовов WaitForNextTickAsync.
Если обработка длится дольше периода, это отставание нужно учитывать в проектировании.
Таймер сам по себе не распараллелится, чтобы «догнать» расписание.
flowchart TB
accTitle: Периодический цикл PeriodicTimer
accDescr: WaitForNextTickAsync ждёт следующий период, работу выполняют через await и снова ждут; остановку делают через CancellationToken, а отставание, если работа длиннее периода, учитывают в проектировании.
tick["Ждать WaitForNextTickAsync"] --> proc["Выполнить работу через await"]
proc --> tick
stop["Остановка через CancellationToken"] -.-> tick
proc -.-> warn["Отставание дольше периода учитывать в проектировании"]
Рис. 14: Крутят по правилу «один таймер — один потребитель». Таймер сам не распараллелится, чтобы догнать расписание.
3.9. Если данные приходят постепенно — IAsyncEnumerable<T>
Иногда хочется обрабатывать элементы по мере поступления, а не копить всё в List<T> и только потом возвращать.
- последовательно читать постраничный API
- построчно читать файл небольшими порциями
- сразу отдавать потоковые результаты
Для таких случаев естественны IAsyncEnumerable<T> и await foreach.
public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
{
await ProcessUserAsync(user, cancellationToken);
}
}
Такая форма подходит, когда:
- не хочется ждать, пока соберутся все элементы
- нужно обрабатывать по одному
- не хочется держать всё в памяти
Проще всего решить, возвращать Task<List<T>> или IAsyncEnumerable<T>, ответив на вопрос:
результаты будут использоваться только после полной готовности или по мере поступления?
flowchart TB
accTitle: Форма возвращаемого значения зависит от того, как используют результат
accDescr: Если результат нужен целиком — возвращают список через Task; если по мере поступления по одному — возвращают IAsyncEnumerable и обрабатывают await foreach.
q{"Как будут использовать результат?"}
q -->|"Сначала весь целиком"| list["Вернуть список через Task"]
q -->|"По мере поступления"| ae["Отдавать через IAsyncEnumerable"]
ae --> each["Обрабатывать по одному через await foreach"]
ae -.-> mem["Не копить все элементы в памяти"]
Рис. 15: Копить все элементы или отдавать по мере поступления. От способа использования зависит тип возвращаемого значения.
3.10. Если нужно освобождать асинхронно — await using
Типы, которым при освобождении нужны асинхронные операции — сброс буфера, завершение соединения, — реализуют IAsyncDisposable.
В таком случае вместо using используют await using.
public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await stream.WriteAsync(data, cancellationToken);
}
Ключевые моменты:
- для
IAsyncDisposableиспользуютawait using - совершенно нормально, что «открытие» синхронно, а «закрытие» асинхронно
Так избегают нестыковки: «запись сделали асинхронной, а самое последнее освобождение оставили синхронным».
3.11. Если взаимное исключение пересекает await — SemaphoreSlim
В коде, который пересекает await, бывают случаи, когда вместо lock используют SemaphoreSlim.
public sealed class CacheRefresher
{
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
await RefreshCoreAsync(cancellationToken);
}
finally
{
_gate.Release();
}
}
private static Task RefreshCoreAsync(CancellationToken cancellationToken)
=> Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
Важны два момента:
- входить через
WaitAsync - обязательно вызывать
Releaseвfinally
Для ситуаций вроде «пускать только одного за раз» или «не больше 3 одновременных вызовов внешнего API»
SemaphoreSlim весьма практичен.
flowchart TB
accTitle: Взаимное исключение, которое пересекает await
accDescr: В коде, который пересекает await, вместо lock используют SemaphoreSlim: входят через WaitAsync, выполняют работу с await и обязательно вызывают Release в finally.
lk["lock нельзя провести через await"] -.-> alt["Вместо него SemaphoreSlim"]
wait["Войти через WaitAsync"] --> crit["Выполнить работу, включая await"]
crit --> rel["Обязательно Release в finally"]
Рис. 16: Вход — WaitAsync, выход — Release в finally. Эту пару не разрывают.
3.12. Разный await в UI, прикладном коде и библиотеке
ConfigureAwait(false) — не то, что можно ставить всегда и везде.
Крупными мазками разделение такое.
flowchart LR
accTitle: Обычный await в приложении и ConfigureAwait(false) в библиотеке
accDescr: В UI и прикладном коде обычный await возвращает продолжение в исходный контекст; в универсальной библиотеке рассматривают ConfigureAwait(false) и не предполагают возврат в конкретный контекст.
a["UI / прикладной код"] --> b["await someAsync()"]
b --> c["Продолжение в исходном контексте"]
d["Универсальная библиотека"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["Нет предположения о возврате в конкретный контекст"]
Рис. 17: В прикладном коде — обычный await и возврат в исходный контекст; в универсальной библиотеке рассматривают ConfigureAwait(false).
- UI / прикладной код
- для начала достаточно обычного
await - если после
awaitидёт обновление UI или код, завязанный на прикладной контекст,ConfigureAwait(false)естественнее не ставить
- для начала достаточно обычного
- Прикладной код ASP.NET Core
- обычно достаточно обычного
await - не нужно насильно вводить
ConfigureAwait(false)как общее правило
- обычно достаточно обычного
- Код универсальной библиотеки
- если он не зависит от UI и прикладной модели,
ConfigureAwait(false)— сильный вариант
- если он не зависит от UI и прикладной модели,
Иначе говоря:
- прикладной код — обычный
await - универсальная библиотека — рассмотреть
ConfigureAwait(false)
Если запомнить это, на практике проблем почти не возникает.
4. Базовые правила записи
4.1. Возвращаемый тип — сначала Task / Task<T>
Для возвращаемого типа async-метода сначала рассуждают в таком порядке.
| Возвращаемый тип | Первое соображение |
|---|---|
Task |
Стандарт для async-метода без возвращаемого значения |
Task<T> |
Стандарт для async-метода, который возвращает значение |
ValueTask / ValueTask<T> |
Выбирают, когда измерения показали необходимость |
ValueTask выглядит привлекательно, но он не всегда лучше Task.
Это структура, у неё есть стоимость копирования, и использование накладывает ограничения.
Особенно важно, что ValueTask по своей природе рассчитан на однократный await.
Он не подходит для того, чтобы бездумно класть его в локальную переменную и ждать несколько раз.
Поэтому для повседневного прикладного кода Task / Task<T> в первую очередь вполне достаточно.
Кроме того, понятнее добавлять к именам методов суффикс Async.
public Task SaveAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<int> CountAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_count);
}
Как показано выше, если await-ить нечего, естественнее не добавлять async насильно, а возвращать Task.CompletedTask или Task.FromResult.
4.2. async void — только для обработчиков событий
Базовое правило — избегать async void вне обработчиков событий.
Причина простая:
- вызывающая сторона не может
await-ить - нельзя дождаться завершения
- обработка исключений усложняется
- код тяжело тестировать
void требуется только для обработчиков событий, поэтому его используют исключительно там.
private async void SaveButton_Click(object? sender, EventArgs e)
{
try
{
await SaveAsync(_saveCancellation.Token);
_statusLabel.Text = "Сохранено.";
}
catch (OperationCanceledException)
{
_statusLabel.Text = "Отменено.";
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Ошибка сохранения");
}
}
В обработчиках событий важно самим перехватить исключение внутри и вернуть его в UI.
flowchart TB
accTitle: Почему избегают async void и единственное исключение
accDescr: async void нельзя await-ить, нельзя дождаться завершения, с исключениями и тестами работать труднее, поэтому в обычных методах его избегают и оставляют только для обработчика событий, которому по сигнатуре нужен void, а внутри пишут try/catch и возвращают исключение в UI.
av["Метод async void"] --> p1["Нельзя await-ить"]
av --> p2["Нельзя дождаться завершения"]
av --> p3["С исключениями и тестами трудно"]
ev["Обработчик событий — исключение"] -.-> duty["try/catch и возврат в UI"]
Рис. 18: Обычный метод возвращает Task / Task<T>. async void допускают только в обработчике событий.
4.3. Принять CancellationToken и передать его дальше
Для отменяемых операций принимают CancellationToken и передают его дальше как есть.
public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
Часто встречается шаблон, когда на верхнем уровне token принимают, но дальше не передают.
Из-за этого код часто выглядит отменяемым, но на деле на середине не останавливается.
Кроме того, смысл тайм-аута меняется в зависимости от того, хотите ли вы «ограничить только ожидание» или «остановить саму выполняемую работу».
- ограничить только ожидание:
WaitAsync - остановить саму работу:
CancellationTokenSource.CancelAfterи распространение токена
Это различие часто становится источником дефектов позже, поэтому стабильнее решить его заранее.
flowchart TB
accTitle: Как передают CancellationToken
accDescr: Если token, принятый наверху, передают дальше в API, работа останавливается и на середине; если только принять и не передать, код выглядит отменяемым, но не останавливается.
up["Принять token наверху"] --> pass["Передать дальше в API как есть"]
pass --> stop["Останавливается и на середине"]
nopass["Только принять, не передать"] -.-> fake["Выглядит так, будто остановится, но не останавливается"]
Рис. 19: Token, который приняли, передают до конца. Пропуск передачи даёт «отмену, которая не останавливается».
4.4. Асинхронный API вести асинхронным до конца
Если уж используется async / await, естественнее по возможности оставаться асинхронным до самого конца.
Примерный ориентир для замены такой.
| Хочется написать | Заменить на |
|---|---|
Task.Result / Task.Wait() |
await |
Task.WaitAll() |
await Task.WhenAll(...) |
Task.WaitAny() |
await Task.WhenAny(...) |
Thread.Sleep(...) |
await Task.Delay(...) |
Особенно в UI и ASP.NET Core, если подмешать синхронное ожидание, становится трудно понять природу зависаний.
В современном C# доступен и async Task Main(), поэтому причин насильно делать код синхронным даже в консольных приложениях стало заметно меньше.
flowchart TB
accTitle: Не смешивать синхронное ожидание
accDescr: Если используют async/await, цепочку ведут асинхронной до конца: Result и Wait заменяют на await, Thread.Sleep — на Task.Delay. Смешение синхронного ожидания делает зависания плохо читаемыми.
chain["Вести асинхронным до конца"] --> r1["Result и Wait — на await"]
chain --> r2["Thread.Sleep — на Task.Delay"]
mix["Смешано синхронное ожидание"] -.-> clog["Зависания становятся плохо читаемыми"]
Рис. 20: Решив использовать async, не сбрасываются на синхронное ожидание посередине и ведут цепочку асинхронной до конца.
4.5. Задачи из LINQ фиксировать через ToArray / ToList
При сочетании Task.WhenAll или Task.WhenAny с LINQ
безопаснее один раз зафиксировать результат через ToArray() или ToList().
Task<User>[] tasks = userIds
.Select(id => _userRepository.GetAsync(id, cancellationToken))
.ToArray();
User[] users = await Task.WhenAll(tasks);
Причина в отложенном выполнении LINQ. Читать код в предположении «всё уже запущено», хотя перечисление на самом деле ещё не произошло, — незаметно опасная ловушка.
- если нужно дождаться всех вместе —
ToArray() - если по ходу дела нужно удалять / заменять элементы —
ToList()
Если запомнить это, выбор становится проще.
5. Частые антипаттерны
| Антипаттерн | Что тяжело | Первая замена |
|---|---|---|
Task.Run(async () => await IoAsync()) |
Бессмысленно перебрасывает ожидание I/O | await IoAsync() |
Task.Result / Wait() |
Блокирует поток. Легко приводит к зависаниям | await |
Подмешивание Thread.Sleep() в асинхронный поток |
Занимает поток даже во время ожидания | Task.Delay() |
async void в обычном методе |
Нельзя дождаться, тяжело управлять исключениями | Task / Task<T> |
Последовательный await там, где нужен Task.WhenAll |
Излишне замедляет работу | Сначала запустить всё, потом WhenAll |
Запуск огромного числа элементов через WhenAll разом |
Скачок нагрузки | Parallel.ForEachAsync / SemaphoreSlim |
Попытка пересечь await через lock |
Не подходит по назначению | SemaphoreSlim.WaitAsync |
fire-and-forget через голый Task.Run |
Управление исключениями, остановкой и пределами становится расплывчатым | Channel<T> / BackgroundService |
Механическая расстановка ConfigureAwait(false) в коде UI |
Обновление UI после await легко ломается |
Обычный await |
ValueTask по умолчанию |
Сложность часто не окупается | Сначала Task |
Из этой таблицы на практике особенно часто встречаются три пункта:
Task.Runтам, где на самом деле I/O- последовательный
awaitтам, где операции на самом деле независимы fire-and-forgetбез управления временем жизни
Одно только исправление этих трёх пунктов заметно улучшает читаемость кода.
flowchart TB
accTitle: Три правки, которые на практике встречаются чаще всего
accDescr: I/O, обёрнутый в Task.Run; последовательный await независимых операций; fire-and-forget без управления временем жизни — три самых частых случая; достаточно заменить их на прямой await, WhenAll после запуска всех и Channel или BackgroundService, и читаемость заметно растёт.
a1["I/O, обёрнутый в Task.Run"] --> f1["Напрямую await async API"]
a2["Последовательный await независимых операций"] --> f2["Сначала запустить, потом WhenAll"]
a3["fire-and-forget без управления временем жизни"] --> f3["Отдать в Channel или BackgroundService"]
f1 --> better["Читаемость заметно растёт"]
f2 --> better
f3 --> better
Рис. 21: Даже из таблицы антипаттернов эффективнее всего сначала поправить эти три пункта.
6. Чек-лист на ревью
При ревью кода вокруг async/await по порядку сверху проверяют примерно следующее.
- можно ли сразу словами объяснить, является ли операция I/O-bound или CPU-bound
- не осталось ли
Task.Result/Task.Wait()/Thread.Sleep() - не обёрнуто ли ожидание I/O в
Task.Run - не await-ятся ли независимые операции без необходимости последовательно
- и наоборот, не запускается ли большое количество элементов через
WhenAllбез ограничения - если принимается
CancellationToken, передаётся ли он корректно дальше - нет ли
async voidвне обработчиков событий - если используется
fire-and-forget, решено ли, кто управляет исключениями, остановкой и пределами - если используется
SemaphoreSlim, находится лиReleaseвfinally - если используется
ValueTask, есть ли для этого измеренная причина и рассчитан ли код на однократныйawait - соответствует ли наличие/отсутствие
ConfigureAwait(false)типу этого кода- для UI / прикладного кода — обычный
await - для универсальных библиотек — рассмотреть
ConfigureAwait(false)
- для UI / прикладного кода — обычный
Этот чек-лист удобно использовать и для того, чтобы выровнять критерии ревью внутри команды.
7. Кратко: как выбирать
Список выбора сведён в таблицу решений в 3.1. Повторять ту же таблицу здесь хуже, чем возвращаться туда, когда она понадобится, поэтому в этом разделе таблицы нет.
- по ситуации нужно увидеть, «что брать сначала» → таблица решений в 3.1
- нужна запись каждого шаблона → 3.2 — 3.12 (они соответствуют строкам таблицы 3.1)
В таблицу 3.1 не входит только одно решение. Это тип возвращаемого значения. Это уже не ситуация, а устройство метода, поэтому оно собрано в 4.1. Короткий вывод: сначала выбирают Task / Task<T>, а ValueTask — когда измерения показали, что он нужен.
8. Итог
Лучшая практика для async / await на практике — это не запоминание множества мелких техник,
а организующий принцип «выбирать приём в соответствии с типом обработки».
Порядок рассмотрения примерно такой.
- разделить ожидание I/O и вычисление CPU
- для I/O — await-ить async API напрямую
- для вычислений CPU — решить, где их выполнять
- для нескольких операций — выбрать
WhenAll/WhenAny/ ограничение параллелизма - чтобы отделить от времени жизни запроса, использовать не голый fire-and-forget, а очередь
- согласовать подход к возвращаемым типам, отмене, исключениям, взаимному исключению и контекстам
Поскольку сам синтаксис async / await лаконичен, небрежное использование легко скрывает замысел. И наоборот:
- относиться к I/O как к I/O
- относиться к CPU как к CPU
- управлять временем жизни фоновой обработки как фоновой обработки
Одно только это разделение делает код заметно более читаемым.
9. Источники
- Полный набор примеров кода для этой статьи (библиотека, демонстрация, юнит-тесты) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Практические рекомендации по многопоточности: .NET — что решить до добавления потоков
Проверенные приёмы проектирования на .NET/C#, чтобы код не «иногда падал или зависал»: не создавать потоки вручную и опираться на Task, с...
Как выбрать межпроцессное взаимодействие в Windows — таблица: именованные каналы, TCP, gRPC, разделяемая память, COM
Как выбрать способ связи между Windows-приложениями. В таблице решений разобраны сильные стороны и типичные ошибки именованных каналов, л...
Где хранить данные Windows-приложения: таблица решений SQLite / JSON / реестр / Access
Куда и в каком виде хранить данные настольного Windows-приложения. Разбираем выбор между AppData и ProgramData, сильные стороны и ловушки...
Что такое .NET Generic Host — основа для DI, конфигурации и логирования
Разбираем роль Generic Host через связь DI, конфигурации, логирования, IHostedService и BackgroundService и с практической стороны показы...
Что такое .NET Native AOT — чем он отличается от JIT и trimming
Разбираем, что такое Native AOT, через отличия от JIT, ReadyToRun, self-contained, single-file, trimming и source generator, и с практиче...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Поток UI и таймеры
Поток UI WPF / WinForms, асинхронные операции, Dispatcher и проектирование таймеров.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
В Windows-приложениях с UI, фоновой работой и I/O от выбора варианта async/await напрямую зависит качество реализации.
Технические консультации и ревью дизайна
Если решения по Task.Run и ConfigureAwait нужно увязать с разделением ответственности, это уже тема технической консультации и ревью архитектуры.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Когда в C# стоит использовать Task.Run?
- Task.Run полезен, когда нужно снять вычисления CPU с текущего потока. Например, тяжёлый расчёт прямо в обработчике события UI в WinForms / WPF останавливает экран, поэтому его естественно вынести с потока UI через Task.Run. Обработка запроса в ASP.NET Core и так идёт на ThreadPool, поэтому вставить Task.Run и сразу же его await — как правило, лишь лишнее планирование; этого в основном избегают. Длительную работу или работу, которую нужно отвязать от времени жизни запроса, лучше отдавать в очередь или HostedService.
- Нельзя ли оборачивать I/O в await Task.Run()?
- Для ожидания I/O — HTTP, БД, чтение и запись файлов — базовый приём: напрямую await-ить асинхронную версию API; оборачивать её в Task.Run не нужно. Обернуть уже асинхронный I/O в Task.Run — значит просто перебросить ожидание на другой поток: читать код становится труднее, а выгоды нет. Когда из UI вызывают API, у которого есть только синхронная версия, Task.Run иногда берут ради отзывчивости интерфейса, но это не асинхронный I/O, а обход ценой занятия одного потока. На сервере такой обход плохо масштабируется.
- Куда ставить ConfigureAwait(false)?
- В коде UI и приложения для начала достаточно обычного await. Если после await идёт обновление UI или код, завязанный на прикладной контекст, ConfigureAwait(false) естественнее не ставить. В прикладном коде ASP.NET Core обычно тоже хватает обычного await, и не нужно насильно делать это общим правилом. ConfigureAwait(false) силён именно в коде универсальной библиотеки, который не зависит от UI и прикладной модели. Правило «в прикладном коде — обычный await, в универсальной библиотеке — рассмотреть ConfigureAwait(false)» на практике почти никого не подводит.
- Почему async void стоит избегать вне обработчиков событий?
- Потому что async void нельзя await-ить со стороны вызывающего кода, нельзя дождаться завершения, с исключениями работать труднее, и такой код плохо тестировать. Обычный метод по умолчанию должен возвращать Task или Task<T>. void по сигнатуре нужен только обработчику событий, поэтому его используют только там. В этом случае важно самим написать внутри обработчика try/catch, перехватить исключение и вернуть его в UI.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.