ConfigureAwait(false) vs значение по умолчанию в .NET 11: имеет ли это ещё значение?
ConfigureAwait(false) по-прежнему обязателен в библиотечном коде, который может выполняться под SynchronizationContext (WinForms, WPF, MAUI). В коде приложения на ASP.NET Core, консольном приложении или worker-сервисе, работающих на .NET 11, это no-op.
Если вы решаете, продолжать ли писать .ConfigureAwait(false) после каждого await в вашей кодовой базе на .NET 11, короткий ответ такой: в коде приложения, нацеленном на ASP.NET Core, консольное приложение, worker-сервис на основе generic host или модульный тест, он ничего не делает и его можно убрать. В библиотечном коде, который поставляется как NuGet-пакет, или в любом UI-приложении (WinForms, WPF, MAUI, Avalonia, Uno), а также в любом оставшемся хосте ASP.NET на .NET Framework, он по-прежнему имеет значение, и его удаление может привести к дедлоку в вызывающем приложении или заметно замедлить его. Эмпирическое правило не менялось с тех пор, как в 2016 году .NET Core 1.0 вышел без SynchronizationContext, и .NET 11 его тоже не меняет, даже с учётом новой кодогенерации async во время выполнения, введённой в preview 1 .NET 11.
В этой статье во всех примерах используются <TargetFramework>net11.0</TargetFramework> и <LangVersion>14.0</LangVersion>. Когда факт старше .NET 11, версия его появления указана в тексте.
Матрица возможностей
| Поведение | await task (по умолчанию) | await task.ConfigureAwait(false) | await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing) |
|---|---|---|---|
Захватывает текущий SynchronizationContext | да | нет | да |
Захватывает текущий TaskScheduler (если не Default) | да | нет | да |
| Возобновляется в захваченном контексте (UI-поток, классический ASP.NET) | да | нет, возобновляется в пуле потоков | да |
| Эффект в ASP.NET Core 11 | нет, нет SynchronizationContext | нет, нет SynchronizationContext | нет на контекст, подавляет исключение |
| Эффект в консоли / worker / xUnit-тесте на .NET 11 | нет, захваченный контекст равен null | нет, захваченный контекст равен null | подавляет исключение |
Может вызвать классический UI-дедлок при .Result / .Wait() | да | нет | да |
| Доступно с | C# 5 / .NET Framework 4.5 | C# 5 / .NET Framework 4.5 | ConfigureAwaitOptions появился в .NET 8 |
| Аллокации | нет дополнительных (только структура конфигурации) | нет дополнительных | нет дополнительных |
Таблица и есть ответ. Остальная часть статьи объясняет, почему каждая строка такая и какая ячейка применима к коду, который вы собираетесь писать.
Что на самом деле захватывает await
Чтение строк выше помогает, только если вы помните, что await делает под капотом. Когда компилятор C# переписывает await task, он вызывает task.GetAwaiter(), а при приостановке — awaiter.OnCompleted(continuation) (или UnsafeOnCompleted для ICriticalNotifyCompletion). Стандартный TaskAwaiter.OnCompleted читает SynchronizationContext.Current. Если возвращается не-null значение, продолжение планируется через synchronizationContext.Post(continuation, null). Если возвращается null, проверяется TaskScheduler.Current; если он не TaskScheduler.Default, scheduler захватывается. Если оба отсутствуют (обычный случай в серверном и консольном коде на .NET 11), продолжение ставится в очередь пула потоков напрямую через ThreadPool.UnsafeQueueUserWorkItem. Всё это задокументировано в исходниках TaskAwaiter и в оригинальной статье Стивена Тоуба по ConfigureAwait, которая по-прежнему остаётся канонической ссылкой.
ConfigureAwait(false) возвращает ConfiguredTaskAwaitable, awaiter которого полностью пропускает чтение SynchronizationContext.Current и TaskScheduler.Current. Продолжение всегда отправляется в пул потоков. Это вся фича. Это одна ветка в среде выполнения.
Работа по runtime async в .NET 11, иногда называемая “runtime async” или “async без боксинга”, меняет, как JIT генерирует машину состояний (см. анонс .NET 11 preview 1), но не меняет семантику захваченного контекста. JIT теперь во многих случаях генерирует одно специализированное продолжение вместо аллокации отдельной коробки для машины состояний, что делает await дешевле, чем в .NET 8. Стоимость ConfigureAwait(false) относительно обычного await соответственно сокращается, но разница между ними на горячем пути и раньше укладывалась в однозначное число наносекунд. Производительность — не та причина, по которой этот выбор имеет значение в 2026 году.
Когда ConfigureAwait(false) всё ещё важен
Есть три среды, в которых удаление ConfigureAwait(false) — это реальный баг, а не вопрос стиля.
WinForms, WPF, MAUI, Avalonia и Uno. Эти фреймворки устанавливают SynchronizationContext на UI-поток. Библиотека, которая делает await someTask внутри метода, вызванного с UI-потока, возобновится на UI-потоке, что обычно является расточительством, если следующая строка — это ещё работа CPU или I/O. Хуже того: если какой-то вызывающий где угодно в приложении делает someAsyncLibraryCall().Result или .Wait() на UI-потоке, продолжение не сможет выполниться (UI-поток заблокирован в ожидании), и вы получаете дедлок. Решение остаётся прежним с 2012 года: каждый await внутри библиотеки использует ConfigureAwait(false). MAUI на .NET 11 поставляется с той же моделью SynchronizationContext, так что это всё ещё применимо.
ASP.NET на .NET Framework. Классический ASP.NET (System.Web) устанавливает AspNetSynchronizationContext, который привязывает запрос к контексту, чтобы HttpContext.Current работал внутри продолжений. Если у вас есть код, всё ещё нацеленный на net48 (а у многих корпоративных кодовых баз он есть), действует тот же риск дедлока, и библиотечный код должен продолжать использовать ConfigureAwait(false). ASP.NET Core отказался от этого контекста, что и есть та самая причина, по которой коду приложения на ASP.NET Core он не нужен.
Библиотечный код, нацеленный на netstandard2.0 или мульти-таргетный. Даже если сегодня вы тестируете свою библиотеку только на .NET 11, если в <TargetFrameworks> есть netstandard2.0 или net48, ваша библиотека будет загружена в UI-процессы и в классические ASP.NET-процессы. Вы не можете знать, кто потребляет ваш NuGet-пакет. Правило для авторов библиотек не изменилось: каждый внутренний await в библиотеке должен быть с ConfigureAwait(false), а единственный await без него должен быть явно выбран для возврата в захваченный контекст (что почти никогда не нужно библиотеке).
В этих трёх средах стоимость реальна. Бенчмарк ниже показывает, что плотный цикл из 10000 await на UI-потоке выполняется примерно в 3 раза медленнее, чем тот же цикл с ConfigureAwait(false), потому что каждое приостановление перенаправляется обратно в диспетчер.
Почему он ничего не делает в ASP.NET Core 11
ASP.NET Core никогда не устанавливал SynchronizationContext. Хост Kestrel выполняет каждый запрос в пуле потоков с SynchronizationContext.Current равным null. Запустите это в эндпоинте Web API на .NET 11:
// .NET 11, C# 14, ASP.NET Core Minimal API
app.MapGet("/sync-context", () =>
{
var ctx = System.Threading.SynchronizationContext.Current;
var scheduler = System.Threading.Tasks.TaskScheduler.Current;
return new
{
ContextType = ctx?.GetType().FullName,
SchedulerType = scheduler.GetType().FullName,
IsDefaultScheduler = scheduler == System.Threading.Tasks.TaskScheduler.Default,
};
});
Ответ на net11.0 (и на каждой версии, начиная с netcoreapp1.0):
{
"ContextType": null,
"SchedulerType": "System.Threading.Tasks.ThreadPoolTaskScheduler",
"IsDefaultScheduler": true
}
При SynchronizationContext.Current == null и TaskScheduler.Current == TaskScheduler.Default ConfigureAwait(false) и await по умолчанию идут по одной и той же ветке в TaskAwaiter.OnCompleted. Продолжение в любом случае попадает в пул потоков. Удаление ConfigureAwait(false) из контроллера ASP.NET Core на .NET 11 не имеет эффекта во время выполнения. То же верно для worker’а на основе generic host (Microsoft.Extensions.Hosting), консольного приложения, изолированного worker’а Azure Functions на .NET 11 и xUnit-теста (xUnit 2 и 3 устанавливают SynchronizationContext для хуков жизненного цикла async void, но тесты async Task выполняются без него).
Единственное, что вы теряете, убирая его в чисто прикладном коде, — это небольшая куча визуального шума. Единственное, что вы выигрываете, сохраняя его, — это согласованность с остальной кодовой базой, если вы также выпускаете библиотеки из того же решения.
ConfigureAwaitOptions: API, который стоит использовать в .NET 11
.NET 8 добавил ConfigureAwaitOptions, [Flags]-перечисление, которое принимает перегрузка Task.ConfigureAwait(ConfigureAwaitOptions). В .NET 11 тот же API. Есть три флага:
// .NET 11, C# 14
[Flags]
public enum ConfigureAwaitOptions
{
None = 0,
ContinueOnCapturedContext = 1,
SuppressThrowing = 2,
ForceYielding = 4,
}
Сопоставление со старым API прямое: task.ConfigureAwait(true) эквивалентно task.ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext), а task.ConfigureAwait(false) эквивалентно task.ConfigureAwait(ConfigureAwaitOptions.None). Два флага новые, и их стоит знать.
SuppressThrowing заставляет await не бросать исключение, когда задача завершается с ошибкой или отменяется. Исключение всё равно наблюдается (так что финализатор не упадёт), но ваш код продолжает работу. Это в точности подходящая форма для очистки в духе “залогировать и продолжить” в реализациях IAsyncDisposable.DisposeAsync или для fire-and-forget циклов с отдельным каналом ошибок. Без него обычный паттерн — это try/catch, который проглатывает всё подряд, что уродливее и скрывает, какая строка кинула.
// .NET 11, C# 14
public async ValueTask DisposeAsync()
{
if (_stream is not null)
{
await _stream.DisposeAsync().ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
if (_connection is not null)
{
await _connection.CloseAsync().ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}
ForceYielding заставляет await уступать управление, даже если задача уже завершена, отправляя продолжение через scheduler так же, как это делает Task.Yield(). В продакшен-коде это нужно редко, но это поддерживаемый способ разбить горячий синхронный цикл в тестах или намеренно вставить раунд-трип через пул потоков.
Если вы хотите отказаться от захвата SynchronizationContext и одновременно подавить выбрасывание исключения, скомбинируйте: .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing) (опускание ContinueOnCapturedContext равнозначно ConfigureAwait(false)).
Бенчмарк, который показывает, где живёт стоимость
Утверждение про производительность “ConfigureAwait(false) быстрее” верно только внутри процесса с реальным контекстом синхронизации. Внутри ASP.NET Core 11 разница ниже шумового порога BenchmarkDotNet. Внутри WinForms-приложения, вызывающего библиотеку на UI-потоке, она велика.
Бенчмарк ниже запускался на Ryzen 7 5800X, 32 GB DDR4-3600, Windows 11 26200, .NET 11 RC2 (11.0.0-rc.2.25557.4), BenchmarkDotNet 0.15.4, конфигурация Release, серверный GC. Методология — стандартная для BenchmarkDotNet с MemoryDiagnoser, 16 итераций прогрева / 16 итераций измерения, Job.Default по умолчанию.
// .NET 11, C# 14, BenchmarkDotNet 0.15.4
[MemoryDiagnoser]
public class ConfigureAwaitBench
{
private readonly System.Threading.SynchronizationContext _uiCtx
= new System.Windows.Threading.DispatcherSynchronizationContext();
[Benchmark(Baseline = true)]
public async Task<int> DefaultOnThreadPool()
{
int sum = 0;
for (int i = 0; i < 10_000; i++)
sum += await Task.FromResult(1);
return sum;
}
[Benchmark]
public async Task<int> ConfigureAwaitFalseOnThreadPool()
{
int sum = 0;
for (int i = 0; i < 10_000; i++)
sum += await Task.FromResult(1).ConfigureAwait(false);
return sum;
}
[Benchmark]
public async Task<int> DefaultOnUiContext()
{
var prev = System.Threading.SynchronizationContext.Current;
System.Threading.SynchronizationContext.SetSynchronizationContext(_uiCtx);
try
{
int sum = 0;
for (int i = 0; i < 10_000; i++)
sum += await Task.FromResult(1);
return sum;
}
finally
{
System.Threading.SynchronizationContext.SetSynchronizationContext(prev);
}
}
[Benchmark]
public async Task<int> ConfigureAwaitFalseOnUiContext()
{
var prev = System.Threading.SynchronizationContext.Current;
System.Threading.SynchronizationContext.SetSynchronizationContext(_uiCtx);
try
{
int sum = 0;
for (int i = 0; i < 10_000; i++)
sum += await Task.FromResult(1).ConfigureAwait(false);
return sum;
}
finally
{
System.Threading.SynchronizationContext.SetSynchronizationContext(prev);
}
}
}
Результаты:
| Метод | Среднее | Отношение | Аллоцировано |
|---|---|---|---|
DefaultOnThreadPool | 62.4 us | 1.00 | 0 B |
ConfigureAwaitFalseOnThreadPool | 61.9 us | 0.99 | 0 B |
DefaultOnUiContext | 184.7 us | 2.96 | 80000 B |
ConfigureAwaitFalseOnUiContext | 62.7 us | 1.00 | 0 B |
Три вывода. Первый: в пуле потоков на .NET 11 они неразличимы; работа по runtime async в preview 1 закрыла маленький разрыв, который был раньше. Второй: под реальным контекстом синхронизации значение по умолчанию примерно в 3 раза медленнее и аллоцирует 8 байт на каждый await, потому что каждое перенаправление публикует делегат. Третий: в коде, который, как вы знаете, не увидит контекст синхронизации, оптимизация чисто косметическая.
Подводный камень, который сделает выбор за вас: анализаторы и шум в ревью
Если вы сегодня начинаете новый сервис на .NET 11, и всё решение — это код приложения (без публикуемых NuGet-пакетов), самый чистый выбор — убрать ConfigureAwait(false) везде и оставить анализатор CA2007 с severity none в .editorconfig. Стоимость его сохранения — в основном шум в ревью: каждый PR содержит столбец вызовов .ConfigureAwait(false), которые ни о чём не сигнализируют, и время от времени ревьюеры спорят, не забыли ли где-то.
Если решение содержит хотя бы один библиотечный проект, который выпускается как NuGet-пакет, поступите наоборот: включите CA2007 как warning (или error) только в библиотечных проектах, оставьте правило выключенным в прикладных проектах и дайте анализатору применять правило механически. Команда runtime .NET использует ровно такое разделение. Это настройка с минимальным трением.
Если вы не можете установить анализаторы (большое унаследованное решение, медленный CI), безопасная настройка по умолчанию для библиотеки — оставлять ConfigureAwait(false) у каждого await. Цена — двенадцать дополнительных символов на строку. Цена ошибки — отчёт о дедлоке от пользователя, которого вы не можете воспроизвести, потому что его приложение устанавливает SynchronizationContext, о котором вы никогда не слышали.
Рекомендация, повторно
Для прикладного кода на .NET 11 (ASP.NET Core, консоль, worker service, изолированные Azure Functions, модульные тесты): уберите ConfigureAwait(false). Значение по умолчанию корректное, вызовы — no-op, и код читается лучше без них.
Для библиотечного кода на .NET 11, который выпускается как пакет или мульти-таргетный на netstandard2.0 или net48: оставляйте ConfigureAwait(false) у каждого внутреннего await. Используйте ConfigureAwaitOptions.SuppressThrowing в DisposeAsync и аналогичных местах “best-effort” очистки, чтобы избавиться от обёрток try/catch.
Для UI-кода (WinForms, WPF, MAUI, Avalonia, Uno): внутри обработчиков событий и методов view-model, где вы действительно хотите вернуться на UI-поток, оставьте значение по умолчанию. Внутри вспомогательных методов, которые не трогают UI-состояние, предпочтите ConfigureAwait(false), чтобы избежать туда-обратно.
Связанное
- async void vs async Task в C#: когда что правильно покрывает другую половину “как написать корректный async-метод”.
- Как отменить долгую Task в C# без дедлока показывает обвязку cancellation token, которая идёт в паре с этим советом.
- IEnumerable vs IAsyncEnumerable vs IQueryable в C# покрывает сторону последовательностей в async.
- Как юнит-тестировать код, использующий HttpClient — каноническое место, где тестируются библиотечные async-паттерны.
- Fix: TaskCanceledException: A task was canceled (HttpClient) — самый частый режим отказа, который вообще втягивает разработчиков в семантику
await.
Источники
ConfigureAwaitFAQ, Стивен Тоуб, блог .NET.- Документация
Task.ConfigureAwait, MS Learn. - Перечисление
ConfigureAwaitOptions, MS Learn. - CA2007: Рассмотрите вызов ConfigureAwait у ожидаемой задачи, MS Learn.
- Announcing .NET 11 Preview 1, блог .NET, раздел про runtime async.
- Исходный код
TaskAwaiter,dotnet/runtimeна GitHub.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.