Практическая таблица решений для 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.

Содержание

  1. Сначала вывод (в двух словах)
  2. Слова, которые используются в статье
    • 2.1. Сначала стоит различить
    • 2.2. Часто встречающиеся слова
  3. Таблица решений, с которой стоит начать
    • 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. Базовые правила записи
    • 4.1. Возвращаемый тип — сначала Task / Task<T>
    • 4.2. async void — только для обработчиков событий
    • 4.3. Принять CancellationToken и передать его дальше
    • 4.4. Асинхронный API вести асинхронным до конца
    • 4.5. Задачи из LINQ фиксировать через ToArray / ToList
  5. Частые антипаттерны
  6. Чек-лист на ревью
  7. Кратко: как выбирать
  8. Итог
  9. Источники

Карта знаний этой статьи

Статья показывает, что в 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 и BackgroundService.

Карта знаний: практические решения по async/await в C#Схема показывает, как различение I/O-bound и CPU-bound связано с выбором Task.Run и ConfigureAwait(false) и с выбором WhenAll, WhenAny, Channel и других средств.не рекомендуетсярекомендуется длярекомендуется дляне рекомендуетсяможет вызватьиспользуетрекомендуется дляне рекомендуетсяиспользуетрекомендуется дляне рекомендуетсярекомендуется дляиспользуетрекомендуется длярекомендуется дляиспользуетрекомендуется дляреализуетне рекомендуетсярекомендуется дляиспользуетрекомендуется длятребуетне рекомендуетсятребуетрекомендуется дляоперация I/O-boundCPU-bound обработкаTask.Runконтекст потока UIобработка запросов ASP.NET Coresync-over-asyncголодание пула потоков (starvation)SynchronizationContextConfigureAwait(false)универсальный библиотечный кодCancellationToken (.NET)async voidметод обработчика событияобычный асинхронный метод (Task/Task)Task.WhenAllTask.WhenAnyParallel.ForEachAsyncSemaphoreSlimChannelbackpressureBackgroundServiceIHostedServicefire-and-forgetфоновая работа, отвязанная от времени жизни вызывающегоPeriodicTimerIAsyncEnumerableawait usingValueTask

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 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 и прикладном коде для начала достаточно обычного await
  • async void не используют нигде, кроме обработчиков событий

Иначе говоря, вокруг async / await важнее всего не скатываться к «на всякий случай Task.Run», «на всякий случай fire-and-forget», «на всякий случай ValueTask».

Для начала смотрят на три вещи.

  1. чего именно ждёт эта работа
  2. кто владеет временем жизни этой работы
  3. где контролируют число одновременных выполнений

Если рассмотреть эти три пункта, колебаний становится заметно меньше.

Три вопроса, которые снимают путаницуЕсли по очереди спросить, чего ждёт эта работа, кто владеет её временем жизни и где контролируют число одновременных выполнений, путаница вокруг записи async/await заметно снижается.Чего ждёт эта работаКто владеет временем жизниГде контролируют число одновременных выполненийПутаницы в записи становится меньшеИзбегать Task.Run «на всякий случай»

Рис. 1: Не выбирать «на всякий случай». Сначала смотрят на тип ожидания, время жизни и число одновременных выполнений.

2. Слова, которые используются в статье

2.1. Сначала стоит различить

Если сразу развести эти два понятия, путаницы становится гораздо меньше.

Слово Что имеется в виду здесь
I/O-bound Работа, в центре которой ожидание внешнего завершения: HTTP, БД, файлы, сокеты и т. п.
CPU-bound Работа, в центре которой само вычисление CPU: сжатие, обработка изображений, хеш, тяжёлые преобразования и т. п.

async / await особенно полезен именно при ожидании I/O: пока идёт ожидание, поток можно отдать другой работе. Вычисление CPU — это не «ожидание», а реальное время счёта, поэтому главные вопросы — на каком потоке это выполнять и как задать степень параллелизма.

Чем I/O-bound отличается от CPU-boundI/O-bound — это в основном ожидание внешнего завершения, и на время await поток можно отдать другой работе; CPU-bound — это само вычисление, и там важны поток выполнения и степень параллелизма.I/O-bound (ожидание внешнего завершения)Пока ждём, поток можно отдать другой работеИменно здесь async/await особенно полезенCPU-bound (само вычисление)Главный вопрос — на каком потоке выполнятьСтепень параллелизма тоже становится темой

Рис. 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) — указание: продолжение можно выполнять, не возвращаясь в захваченный контекст
Куда возвращается продолжение awaitawait захватывает SynchronizationContext на момент входа в ожидание и возвращает продолжение туда: в WinForms/WPF это поток UI, поэтому после await можно трогать элементы управления; в ASP.NET Core возвращаться некуда, и продолжение идёт на пуле потоков.await захватывает контекстПоток UI WinForms или WPFВ ASP.NET Core возвращаться некудаПосле await можно трогать UIПродолжение идёт на пуле потоков

Рис. 3: Разговор про ConfigureAwait(false) сводится к одному: куда возвращается продолжение await.

Отсюда выводы раздела 3.12: «в UI-коде естественнее не ставить», «в прикладном коде ASP.NET Core разница невелика», «в универсальной библиотеке, которую могут вызвать из любого контекста, ставить имеет смысл». Самый цельный фон — ConfigureAwait FAQ в источниках, раздел 9.

Особенно важно, что асинхронность и параллелизм — разные вещи.

  • асинхронность — про то, как ждать
  • параллелизм — про то, как продвигаться одновременно

Когда эти два понятия смешивают, хочется вставить Task.Run куда угодно. Это первая развилка.

Асинхронность и параллелизм — разные вещиАсинхронность — про то, как ждать, параллелизм — про то, как продвигаться одновременно; если смешать эти два разговора, хочется ставить Task.Run везде, и именно здесь проходит первая развилка.Асинхронность (как ждать)Смешение ведёт к Task.Run повсюдуПараллелизм (как идти одновременно)Первая развилка здесь

Рис. 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 и прикладного контекста
Общая картина выбораСначала отделяют ожидание внешнего I/O от тяжёлого вычисления CPU; если задач несколько, инструмент выбирают по тому, ждать ли все, брать первую завершившуюся, ограничивать параллелизм, вести по порядку, крутить по таймеру или читать поток.ДаНетДаСобытие UI / десктопЗапрос ASP.NET Coreworker / фонНетЖдать завершения всехИспользовать первую завершившуюсяМного элементовВести по порядкуФиксированный интервалПоследовательный потокРабота, которую нужно сделатьЖдём внешний I/O?Напрямую await async APIТяжёлое вычисление CPU?Где выполнять?Рассмотреть Task.RunНе оборачивать в Task.RunПри необходимости — в отдельный worker или очередьВыполнить на месте илиявно задать степень параллелизмаОбрабатывается несколько задач?Task.WhenAllTask.WhenAnyParallel.ForEachAsyncили SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

Рис. 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 получен, его передают дальше как есть

Это вполне стандартный путь.

Базовая форма ожидания I/OДля ожидания HTTP, БД или файла сначала ищут асинхронную версию API и напрямую её await-ят; обернуть уже асинхронный I/O в Task.Run — значит только перебросить ожидание на другой поток, без выгоды.Ожидание HTTP, БД, файлаСначала искать async APIНапрямую awaitОбернуть в Task.RunТолько переброс, выгоды нет

Рис. 6: Базовый приём для I/O — напрямую await-ить async API. Оборачивать в Task.Run не стоит.

3.3. Если нагрузка на CPU тяжёлая — выбрать, где вызывать Task.Run

Task.Run полезен, когда нужно снять вычисление CPU с текущего потока.

Если тяжёлый расчёт крутить прямо в обработчике события UI, экран останавливается. В такой ситуации Task.Run — естественный выбор.

Как Task.Run помогает в UIТяжёлый расчёт прямо в обработчике события UI останавливает экран; если через Task.Run снять вычисление CPU с потока UI, интерфейс продолжает отвечать. Это типичный случай, где Task.Run уместен.Тяжёлый расчёт в событии UIЭкран останавливаетсяTask.Run снимает работу с потока UIИнтерфейс продолжает отвечать

Рис. 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). На сервере такого одного потока нет. Исходный поток действительно освобождается, но вместо него ровно на то же время занимает другой — в сумме ноль

Остаются стоимость постановки в очередь и переключения потока и то, что «на каком потоке это сейчас идёт» становится на шаг менее понятным. Поэтому так не делают.

Почему в ASP.NET Core избегают Task.RunЕсли вставить Task.Run в обработку запроса, суммарный объём вычислений не меняется, особого потока, который нужно освободить, как в UI, нет, в сумме ноль, и остаются только стоимость переключения и худшая читаемость.Task.Run в обработке запросаСуммарный объём вычислений тот жеОсобого одного потока освобождать некогоВ сумме нольОстаются стоимость переключения и худшая читаемость

Рис. 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(), все задачи стартуют именно в этот момент.

Независимые задачи и Task.WhenAllНезависимые задачи сначала все запускают, затем ждут завершения вместе через Task.WhenAll.Task 3Task 2Task 1Вызывающая сторонаTask 3Task 2Task 1Вызывающая сторонаЗапускЗапускЗапускawait Task.WhenAll(...)ГотовоГотовоГотово

Рис. 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. Понятнее всего выбирать его, когда «достаточно только первого результата».

WhenAny: сначала проверить победителя, потом остановить остальныеTask.WhenAny возвращает первую завершившуюся задачу, а не успешную, поэтому каждое завершение по очереди await-ят, остальные отменяют только при успехе, при неудаче кандидата убирают и ждут следующего, а при полном провале сбои выбрасывают вместе.УспехНеудачаДаНетWhenAny даёт первое завершениеЭта задача завершилась успехом?Отменить остальные и вернуть результатУбрать из кандидатов и записать сбойКандидаты ещё есть?Выбросить сбои вместе

Рис. 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

Такое разделение почти никогда сильно не подводит.

Как ограничивать параллелизм в зависимости от числа элементовНесколько независимых операций можно сразу запустить через Task.WhenAll; если элементов много, сразу растут подключения, память и внешняя нагрузка, поэтому верхнюю границу одновременных выполнений задают Parallel.ForEachAsync или SemaphoreSlim.НесколькоМногоЭлементов много?Сразу все через Task.WhenAllЗадать верхнюю границу параллелизмаParallel.ForEachAsyncГибкий контроль через SemaphoreSlim

Рис. 11: Развилка — можно ли бросать все сразу. Если элементов много, число одновременных выполнений задают явно.

3.7. Если нужно вести по порядку — Channel<T>

Иногда хочется отвязать от вызывающей стороны работу, которая «не обязана закончиться прямо сейчас, но обязательно должна быть сделана»: отправку почты, пересылку логов, постобработку webhook, преобразование файлов.

Если в таких случаях просто запускать Task.Run и забывать о нём, становится неясно:

  • где смотреть исключения
  • ждать ли завершения при остановке
  • сколько принимать, если объём вырастет

Такую работу проще контролировать, если класть её в очередь и обрабатывать по порядку выделенным потребителем.

Ограниченный Channel и backpressureProducer пишет через WriteAsync; если в очереди есть место, элемент попадает в Channel, иначе пишущая сторона ждёт, пока место освободится; consumer читает ReadAsync и обрабатывает по порядку.ДаНетproducerWriteAsyncВ очереди есть место?Попадает в ChannelЖдёт, пока освободится местоconsumer вызывает ReadAsyncawait и обработка по порядку

Рис. 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» так заметно проще работать с исключениями, остановкой, степенью параллелизма и верхними пределами.

Запуск «в никуда» и управление через очередьГолый Task.Run без ожидания делает неясными исключения, остановку и лимит приёма; если класть работу в Channel и потреблять её в BackgroundService, исключениями, остановкой, параллелизмом и верхними пределами управлять проще.Голый Task.Run без ожиданияИсключения, остановка и лимит неясныКласть в очередь ChannelПотребляет BackgroundServiceМожно управлять исключениями, остановкой и параллелизмом

Рис. 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. Если обработка длится дольше периода, это отставание нужно учитывать в проектировании. Таймер сам по себе не распараллелится, чтобы «догнать» расписание.

Периодический цикл PeriodicTimerWaitForNextTickAsync ждёт следующий период, работу выполняют через await и снова ждут; остановку делают через CancellationToken, а отставание, если работа длиннее периода, учитывают в проектировании.Ждать WaitForNextTickAsyncВыполнить работу через awaitОстановка через CancellationTokenОтставание дольше периода учитывать в проектировании

Рис. 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>, ответив на вопрос: результаты будут использоваться только после полной готовности или по мере поступления?

Форма возвращаемого значения зависит от того, как используют результатЕсли результат нужен целиком — возвращают список через Task; если по мере поступления по одному — возвращают IAsyncEnumerable и обрабатывают await foreach.Сначала весь целикомПо мере поступленияКак будут использовать результат?Вернуть список через TaskОтдавать через IAsyncEnumerableОбрабатывать по одному через await foreachНе копить все элементы в памяти

Рис. 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 весьма практичен.

Взаимное исключение, которое пересекает awaitВ коде, который пересекает await, вместо lock используют SemaphoreSlim: входят через WaitAsync, выполняют работу с await и обязательно вызывают Release в finally.lock нельзя провести через awaitВместо него SemaphoreSlimВойти через WaitAsyncВыполнить работу, включая awaitОбязательно Release в finally

Рис. 16: Вход — WaitAsync, выход — Release в finally. Эту пару не разрывают.

3.12. Разный await в UI, прикладном коде и библиотеке

ConfigureAwait(false) — не то, что можно ставить всегда и везде.

Крупными мазками разделение такое.

Обычный await в приложении и ConfigureAwait(false) в библиотекеВ UI и прикладном коде обычный await возвращает продолжение в исходный контекст; в универсальной библиотеке рассматривают ConfigureAwait(false) и не предполагают возврат в конкретный контекст.UI / прикладной кодawait someAsync()Продолжение в исходном контекстеУниверсальная библиотекаawait someAsync().ConfigureAwait(false)Нет предположения о возврате в конкретный контекст

Рис. 17: В прикладном коде — обычный await и возврат в исходный контекст; в универсальной библиотеке рассматривают ConfigureAwait(false).

  • UI / прикладной код
    • для начала достаточно обычного await
    • если после await идёт обновление UI или код, завязанный на прикладной контекст, ConfigureAwait(false) естественнее не ставить
  • Прикладной код ASP.NET Core
    • обычно достаточно обычного await
    • не нужно насильно вводить ConfigureAwait(false) как общее правило
  • Код универсальной библиотеки
    • если он не зависит от UI и прикладной модели, ConfigureAwait(false) — сильный вариант

Иначе говоря:

  • прикладной код — обычный 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.

Почему избегают async void и единственное исключениеasync void нельзя await-ить, нельзя дождаться завершения, с исключениями и тестами работать труднее, поэтому в обычных методах его избегают и оставляют только для обработчика событий, которому по сигнатуре нужен void, а внутри пишут try/catch и возвращают исключение в UI.Метод async voidНельзя await-итьНельзя дождаться завершенияС исключениями и тестами трудноОбработчик событий — исключение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 и распространение токена

Это различие часто становится источником дефектов позже, поэтому стабильнее решить его заранее.

Как передают CancellationTokenЕсли token, принятый наверху, передают дальше в API, работа останавливается и на середине; если только принять и не передать, код выглядит отменяемым, но не останавливается.Принять token наверхуПередать дальше в API как естьОстанавливается и на серединеТолько принять, не передатьВыглядит так, будто остановится, но не останавливается

Рис. 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(), поэтому причин насильно делать код синхронным даже в консольных приложениях стало заметно меньше.

Не смешивать синхронное ожиданиеЕсли используют async/await, цепочку ведут асинхронной до конца: Result и Wait заменяют на await, Thread.Sleep — на Task.Delay. Смешение синхронного ожидания делает зависания плохо читаемыми.Вести асинхронным до концаResult и Wait — на awaitThread.Sleep — на Task.DelayСмешано синхронное ожиданиеЗависания становятся плохо читаемыми

Рис. 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

Из этой таблицы на практике особенно часто встречаются три пункта:

  1. Task.Run там, где на самом деле I/O
  2. последовательный await там, где операции на самом деле независимы
  3. fire-and-forget без управления временем жизни

Одно только исправление этих трёх пунктов заметно улучшает читаемость кода.

Три правки, которые на практике встречаются чаще всегоI/O, обёрнутый в Task.Run; последовательный await независимых операций; fire-and-forget без управления временем жизни — три самых частых случая; достаточно заменить их на прямой await, WhenAll после запуска всех и Channel или BackgroundService, и читаемость заметно растёт.I/O, обёрнутый в Task.RunНапрямую await async APIПоследовательный await независимых операцийСначала запустить, потом WhenAllfire-and-forget без управления временем жизниОтдать в Channel или BackgroundServiceЧитаемость заметно растёт

Рис. 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)

Этот чек-лист удобно использовать и для того, чтобы выровнять критерии ревью внутри команды.

7. Кратко: как выбирать

Список выбора сведён в таблицу решений в 3.1. Повторять ту же таблицу здесь хуже, чем возвращаться туда, когда она понадобится, поэтому в этом разделе таблицы нет.

  • по ситуации нужно увидеть, «что брать сначала» → таблица решений в 3.1
  • нужна запись каждого шаблона → 3.2 — 3.12 (они соответствуют строкам таблицы 3.1)

В таблицу 3.1 не входит только одно решение. Это тип возвращаемого значения. Это уже не ситуация, а устройство метода, поэтому оно собрано в 4.1. Короткий вывод: сначала выбирают Task / Task<T>, а ValueTask — когда измерения показали, что он нужен.

8. Итог

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

Порядок рассмотрения примерно такой.

  1. разделить ожидание I/O и вычисление CPU
  2. для I/O — await-ить async API напрямую
  3. для вычислений CPU — решить, где их выполнять
  4. для нескольких операций — выбрать WhenAll / WhenAny / ограничение параллелизма
  5. чтобы отделить от времени жизни запроса, использовать не голый fire-and-forget, а очередь
  6. согласовать подход к возвращаемым типам, отмене, исключениям, взаимному исключению и контекстам

Поскольку сам синтаксис async / await лаконичен, небрежное использование легко скрывает замысел. И наоборот:

  • относиться к I/O как к I/O
  • относиться к CPU как к CPU
  • управлять временем жизни фоновой обработки как фоновой обработки

Одно только это разделение делает код заметно более читаемым.

9. Источники

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Когда в 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог