Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait
· Го Комура · C#, async/await, .NET, Проектирование
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)
Содержание
- Сначала вывод (в двух словах)
- Термины, используемые в этой статье
- 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
- Частые антипаттерны
- Чек-лист для код-ревью
- Краткое руководство по выбору
- Итог
- Источники
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».
Для начала стоит посмотреть на три вещи:
- чего именно ждёт эта операция;
- кто владеет временем жизни этой операции;
- где контролируется число одновременных выполнений.
Если рассмотреть эти три пункта, колебаний становится заметно меньше.
2. Термины, используемые в этой статье
2.1. Термины, которые стоит различать сразу
Если сразу развести эти два понятия, путаницы становится заметно меньше.
| Термин | Значение здесь |
|---|---|
| I/O-bound | Обработка, в основе которой — ожидание завершения внешней операции: HTTP, БД, файлы, сокеты и т. п. |
| CPU-bound | Обработка, в основе которой — само вычисление CPU: сжатие, обработка изображений, вычисление хешей, тяжёлые преобразования и т. п. |
async / await особенно эффективен именно при ожидании I/O: пока идёт ожидание, поток можно вернуть на другую работу. Вычисление CPU, напротив, — это не «ожидание», а время реального счёта, поэтому главными вопросами становятся на каком потоке это выполнять и как определить степень параллелизма.
2.2. Часто встречающиеся термины
| Термин | Значение здесь |
|---|---|
| Блокировка | Продолжать занимать поток, пока ожидается завершение |
fire-and-forget |
Способ запуска, при котором вызывающая сторона не ждёт завершения |
SynchronizationContext |
Механизм «возврата к исходному месту выполнения», например в UI |
| Backpressure | Механизм, который при слишком быстром поступлении данных заставляет ждать записывающую сторону, предотвращая неконтролируемый рост |
Особенно важно то, что асинхронность и параллелизм — разные вещи.
- асинхронность — это вопрос о том, как ждать
- параллелизм — это вопрос о том, как продвигаться одновременно
Когда эти два понятия смешиваются, начинает хотеться вставить 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
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>"]
Далее рассмотрим каждый паттерн по порядку.
3.2. При ожидании I/O — await async API напрямую
Это самый базовый паттерн.
Например, для HTTP, БД, чтения/записи файлов сначала смотрят, есть ли async-версия 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, передаём его напрямую дальше
Это вполне проторённый путь.
3.3. При тяжёлой нагрузке CPU — выбирать место для Task.Run
Task.Run оправдан, когда нужно убрать вычисление CPU с текущего потока.
Например, если запускать тяжёлое вычисление прямо в обработчике события UI, экран замирает.
В таком случае Task.Run — естественный выбор.
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 изначально выполняется на ThreadPool, поэтому если вставить туда ещё один слой Task.Run и сразу же его await-ить, это, как правило, лишь добавляет ненужное планирование.
Поэтому в ASP.NET Core разумнее рассуждать так.
- при ожидании I/O — обычный
await - при коротком вычислении CPU — выполнять на месте
- при длительной обработке или обработке, которую нужно отделить от времени жизни запроса, — выносить в очередь или HostedService
Отметим, что при вызове из UI API, у которого есть только синхронная версия, ради отзывчивости интерфейса Task.Run иногда используют.
Но это не «асинхронный 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
participant Caller as Вызывающая сторона
participant T1 as Задача 1
participant T2 as Задача 2
participant T3 as Задача 3
Caller->>T1: Запуск
Caller->>T2: Запуск
Caller->>T3: Запуск
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: Готово
T2-->>Caller: Готово
T3-->>Caller: Готово
Этот паттерн подходит, когда:
- элементов немного или умеренное количество
- нужно дождаться всех вместе
- запускать всё одновременно без ограничений не проблема
При большом количестве элементов безопаснее добавить верхний предел параллелизма, как в разделе 3.6 ниже.
3.5. Когда нужен первый завершившийся результат — Task.WhenAny
Например, когда нужно использовать первый ответивший из нескольких зеркал, Task.WhenAny — понятный выбор.
public async Task<byte[]> DownloadFromFirstMirrorAsync(
IReadOnlyList<string> urls,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<byte[]>[] tasks = urls
.Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
.ToArray();
Task<byte[]> winner = await Task.WhenAny(tasks);
cts.Cancel();
try
{
return await winner;
}
finally
{
try
{
await Task.WhenAll(tasks);
}
catch
{
// Собираем отмены и сбои всех, кроме победителя
}
}
}
Здесь стоит помнить: WhenAny возвращает только одного победителя.
Остальные операции, если ничего не предпринять, продолжают выполняться сами по себе.
Поэтому заранее нужно решить:
- нужно ли отменять остальные
- нужно ли наблюдать их исключения
Task.WhenAny удобен, но требует немного больше проектирования, чем WhenAll.
Понятнее всего выбирать его именно тогда, когда «достаточно только первого результата».
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 —
например, ограничение «не более 4 одновременных вызовов конкретного внешнего API».
Иными словами:
- при небольшом числе элементов —
Task.WhenAll - при больших объёмах —
Parallel.ForEachAsyncилиSemaphoreSlim
Такое разделение почти никогда сильно не подводит.
3.7. Для последовательной обработки — Channel<T>
Иногда хочется отделить от вызывающей стороны работу, которая «не обязана завершиться прямо сейчас, но обязательно должна быть выполнена»: отправку почты, пересылку логов, постобработку webhook’ов, конвертацию файлов.
Если в таких случаях просто запускать Task.Run и забывать о нём, становится неясно:
- где отслеживать исключения
- нужно ли дожидаться завершения при остановке
- сколько принимать, если объём вырастет
Такую работу проще контролировать, если складывать её в очередь и обрабатывать по порядку выделенным потребителем.
flowchart LR
p["producer"] --> w["WriteAsync"]
w --> q{"В очереди есть место?"}
q -- "Да" --> c["Попадает в Channel"]
q -- "Нет" --> b["Ждёт, пока освободится место"]
c --> d["consumer вызывает ReadAsync"]
d --> e["await и обработка по порядку"]
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» это заметно упрощает работу с исключениями, остановкой, степенью параллелизма и верхними пределами.
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.
Также, если обработка занимает больше времени, чем период, это отставание нужно учитывать в проектировании — таймер сам по себе не распараллелится, чтобы «догнать» расписание.
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>, ответив на вопрос:
результаты будут использоваться только после полной готовности, или по мере поступления?
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 весьма практичен.
3.12. Разный стиль await для UI / прикладного кода / библиотек
ConfigureAwait(false) — не то, что можно ставить всегда и везде.
В общих чертах разделение такое.
flowchart LR
a["UI / прикладной код"] --> b["await someAsync()"]
b --> c["Продолжение в исходном контексте"]
d["Универсальная библиотека"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["Нет предположения о возврате в конкретный контекст"]
- 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.
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и распространение токена
Это различие часто становится источником дефектов позже, поэтому стабильнее решить его заранее.
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(), поэтому причин насильно делать код синхронным даже в консольных приложениях стало заметно меньше.
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без управления временем жизни
Одно только исправление этих трёх пунктов заметно улучшает читаемость кода.
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. Краткое руководство по выбору
| Что нужно сделать | Что выбрать в первую очередь |
|---|---|
| Один вызов HTTP / БД / файловый I/O | await async API напрямую |
| Тяжёлое вычисление, которое не должно останавливать UI | Task.Run |
| Несколько независимых асинхронных операций | Task.WhenAll |
| Нужен только первый результат | Task.WhenAny |
| Много элементов с верхним пределом | Parallel.ForEachAsync / SemaphoreSlim |
| Упорядоченная фоновая обработка | Channel<T> |
| Работа с фиксированным интервалом | PeriodicTimer |
| Обработка последовательного потока | IAsyncEnumerable<T> / await foreach |
| Взаимное исключение через await | SemaphoreSlim |
| Универсальная библиотека | Рассмотреть ConfigureAwait(false) |
| Затруднения с типом возвращаемого значения | Сначала Task / Task<T> |
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
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Как выбрать межпроцессное взаимодействие в 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, и с практи...
Как выбирать между тремя таймерами .NET - PeriodicTimer/Timer/DispatcherTimer
Разбираем различия между PeriodicTimer, System.Threading.Timer и DispatcherTimer и то, как выбирать между ними для async-обработки, callb...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы 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-ить async-версию API; оборачивать её в Task.Run не нужно. Оборачивание уже асинхронного I/O в Task.Run лишь заново перебрасывает ожидание I/O на другой поток — код становится сложнее для понимания, а выгоды нет. При вызове из 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.
Публичные ссылки