Мне никогда не нравилась многословность кода. Длинные и подробные названия упрощают работу с бизнес-логикой, но технические детали кода хочется держать краткими, чтобы они отвлекали на себя минимум внимания.
Одна из многословных конструкций .NET связана с деталями реализации асинхронности и обросла кучей мифов. Про неё спрашивают на собеседованиях, код-ревью, делают обязательной, добавляя в правила линтера. Это .ConfigureAwait(false)
, сопровождающий каждый await
в коде.
В этой статье я расскажу, зачем нужен ConfigureAwait(false)
и как обойтись без него.
async/await: continuation
Перед тем, как перейти к ConfigureAwait
напомню, что такое асинхронный код, где у таска continuation
, и что такое SynchronizationContext
.
- Асинхронный код с использованием
async/await
нарезается на отдельные блоки синхронного кода (разделение происходит поawait
). Каждый из этих блоков назовём continuation. - Переходы между блоками происходят либо синхронно, либо путём подписки на завершение асинхронного действия. Если асинхронное действие завершилось до
await
, то выполнение продолжится в том же потоке.
Как именно компилятор трансформирует код можно посмотреть, например, на sharplab.io
async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
Task<string> task = GetTextAsync();
var text = await task;
// continuation
Text.Text = text;
}
Выше приведён код обработчика события нажатия на кнопку. Где будет выполняться continuation этого обработчика событий после завершения асинхронного Task
? Нет, не в Thread Pool. Все действия с UI должны производиться в одном потоке, на котором крутится event loop. Этот код будет работать только в том случае, если continuation, обновляющий содержимое TextBox Text
вернётся на UI-поток, в котором началась обработка события.
Для этого UI-фреймворки устанавливают SynchronizationContext
, который возвращает continuation в очередь основного потока.
Без SynchronziationContext
пришлось бы явно перекладывать UI-код на UI-поток:
async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
var text = await GetTextAsync().ConfigureAwait(false); // теряем SynchronizationContext и переходим в Thread Pool
await Dispatcher.UIThread.InvokeAsync(() => Text.Text = text); // переданный делегат выполняется в контексте UIThread
}
SynchronizationContext
встречается не только в UI-коде. Например, xUnit переопределяет его, для отслеживания async void
методов и обработки исключений в них. В старом ASP.NET тоже был задан SynchronizationContext
для доступа к HttpContext
. К счастью, в ASP.NET Core его нет.
Кроме SynchronizationContext
также может быть переопределён TaskScheduler
, примерно с теми же последствиями.
И где здесь проблема?
void Btn_OnClick(object? sender, RoutedEventArgs e)
{
var text = GetTextAsync().GetAwaiter().GetResult(); // синхронное ожидание
Text.Text = text;
}
async Task<string> GetTextAsync()
{
var request = CreateRequest();
var response = await client.SendAsync(request);
// GetTextAsync continuation
var text = Deserialize(response);
return text;
}
Блокировка UI-потока
Разработчик UI может ожидать выполнения метода GetTextAsync
синхронно (или в коде используется библиотека с плохим, синхронным ожиданием внутри).
В этом случае:
- UI-поток заблокируется до завершения этого метода
- В соответствии с
SynchronizationContext
, внутренний continuation методаGetTextAsync
(в котором вызываетсяDeserialize
) должен выполняться на UI-потоке - Но UI-поток заблокирован и не может выполнить этот continuation
- Результат: deadlock, хотя поток при этом всего один
В некоторых случаях deadlock может не произойти: если GetTextAsync
выполнится синхронно, либо если в нём произойдёт переход в другой контекст, например на Thread Pool.
Стоит отметить, что желательно избегать блокирующего ожидания, особенно на UI-потоке. Даже если deadlock не произойдёт, во время блокировки UI-потока программа будет выглядеть зависшей.
Излишняя нагрузка на UI-поток
Если GetTextAsync
ожидается асинхронно (с использованием await
), то возникнет другая проблема. Контекст синхронизации попадает в метод GetTextAsync
и его continuation с методом Deserialize
тоже выполнится на UI-потоке. Блокировки не будет, но UI-поток во время выполнения этого метода не сможет выполнять более полезную нагрузку. Если на UI-поток попадает много лишнего кода, который мог бы выполняться в фоне — приложение станет менее отзывчивым.
ConfigureAwait(false) как решение
Из-за этих проблем и сложности их отлова, в .NET сложилась практика писать код, который потенциально может быть вызван внутри SynchronizationContext
(т.е. в коде библиотек) так, чтобы эти проблемы не возникли, каким бы этот контекст не был.
А средство для этого — .ConfigureAwait(false)
, обеспечивающий перекладывание continuation на Thread Pool.
async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
var text = await GetTextAsync();
// Btn_OnClick continuation
Text.Text = text;
}
async Task<string> GetTextAsync()
{
var request = CreateRequest(authToken);
var response = await client.SendAsync(request).ConfigureAwait(false);
// GetTextAsync continuation
// в случае, если `SendAsync` выполнился асинхронно
// SynchronizationContext.Current теперь null
var text = Deserialize(response);
return text;
}
В этом случае, continuation метода GetTextAsync
будет выполняться на потоке из Thread Pool, а возврат в исходный контекст синхронизации произойдёт лишь при выходе из GetTextAsync
— в результате, Btn_OnClick continuation
выполнится на UI потоке, как и ожидалось изначально.
Если await
выполнится синхронно — переход в Thread Pool не произойдёт. Отсюда берётся рекомендация использовать .ConfigureAwait(false)
вместе с каждым await
.
Также, .ConfigureAwait(false)
лишает вызывающий код возможности управлять тем, где будет выполняться асинхронный код переопределением SynchronizationContext
и TaskScheduler
. Какая-то часть кода "сбежит" в стандартный Thread Pool из-за повсеместного использования .ConfigureAwait(false)
.
.ConfigureAwait(true)
задаёт поведение по-умолчанию и не несёт в себе никакого смысла.
Не только Task
Кроме Task/Task<T>
.ConfigureAwait(false)
актуален для ValueTask/ValueTask<T>
, IAsyncEnumerable<T>
и IAsyncDisposable
(и некоторых других типов).
Особую боль представляет собой IAsyncDisposable
. Обёртка ConfiguredAsyncDisposable
— не generic, и не даёт возможности получить доступ к оригинальному объекту, в результате требуется разделять создание объекта и его использование в конструкции using
. Область видимости переменной при этом выходит за границы блока using
, что создаёт риск ошибки в коде.
Как обойтись без .ConfigureAwait(false)
1. Решить проблему на стороне вызывающего кода
Наивно считать, что во всём используемом коде расставлены .ConfigureAwait(false)
. Можно изначально писать код, выполняющийся в контексте синхронизации так, чтобы ему было безразлично, как написаны await
в вызываемом коде.
Этого можно достичь, если запускать весь код, которому не нужен контекст синхронизации на Thread Pool, например с помощью Task.Run
. Делегат, переданный в Task.Run
будет выполнен без контекста синхронизации — на стандартном Thread Pool. В отсутствии контекста синхронизации ConfugureAwait
не несёт смысла.
Task
, возвращённый методом Task.Run
ожидается уже в контексте синхронизации, поэтому continuation Btn_OnClick
будет выполнен на UI-потоке и значение в Text
успешно изменится.
async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
var text = await Task.Run(() => GetTextAsync()); // внутри этой лямбды SynchronizationContext.Current == null
// Btn_OnClick continuation
Text.Text = text;
}
async Task<string> GetTextAsync()
{
var request = CreateRequest(authToken);
var response = await client.SendAsync(request);
// GetTextAsync continuation
var text = Deserialize(response);
return text;
}
Этот способ переносит сложность в вызывающий код, но имеет несколько преимуществ:
- будет работать, независимо от наличия
.ConfigureAwait(false)
в вызываемом коде - позволяет вынести больше работы в фон — теперь в Thread Pool выполняется код не только после первого сработавшего
.ConfigureAwait(false)
, но и весь код до, в нашем примере — не толькоDeserialize
, но иCreateRequest
.
Также, в Task.Run
можно обернуть не только асинхронный метод, но и синхронный, например на случай, если внутри есть блокировка, приводящая к deadlock, или для выноса тяжелых вычислений в фон.
2. Использовать правильное синхронное ожидание
Предыдущий способ будет также работать и при синхронном ожидании. Можно сделать синхронную обёртку над асинхронным методом, которая в отличие от простого .Wait()/.Result/.GetAwaiter().GetResult()
будет устойчива к описанному deadlock
.
В идеальном мире хочется, чтобы синхронная версия метода была реализована отдельно и не задействовала Thread Pool. Часто это нереалистично из-за необходимости поддерживать две реализации сразу. Этот способ как раз для таких случаев.
public void Do()
{
if (SynchronizationContext.Current == null && TaskScheduler.Current == TaskScheduler.Default)
DoAsync().GetAwaiter().GetResult();
else
Task.Run(() => DoAsync()).GetAwaiter().GetResult();
}
3. Однократный переход в Thread Pool
Этот способ предназначен для разработчиков асинхронного кода в библиотеках, которым нужно гарантировать работу кода независимо от SynchronizationContext
и того, как код используется извне, но нет желания засорять код .ConfigureAwait(false)
.
Вместо повсеместных .ConfigureAwait(false)
в вызываемом коде, предлагается написать конструкцию, уводящую выполнение метода на Thread Pool один раз в начале метода. Можно ограничиться только публичными методами.
При таком способе переход на Thread Pool произойдёт сразу, до первого асинхронного await
. Это может снизить производительность, если обычно все await
выполняются синхронно, без смены потока, или наоборот, повысить — если до первого асинхронного await
выполняется вычислительный код, зря занимающий UI-поток. В реальных условиях разницу вряд ли получится заметить вовсе.
async Task<string> GetTextAsync()
{
await TaskEx.EscapeContext(); // await TaskScheduler.Default;
var request = CreateRequest(authToken);
var response = await client.SendAsync(request); // .ConfigureAwait больше не нужен
// GetTextAsync continuation
var text = Deserialize(response);
return text;
}
Способ подсмотрен в dotnet/runtime. Также есть issue о добавлении публичного API и готовая реализация в Microsoft.VisualStudio.Threading.
Ниже приведена реализация, переходящая в Thread Pool только если задан контекст синхронизации или TaskScheduler
:
readonly struct EscapeAwaiter : ICriticalNotifyCompletion
{
public bool IsCompleted
=> SynchronizationContext.Current == null &&
TaskScheduler.Current == TaskScheduler.Default;
public void GetResult() { }
public void OnCompleted(Action continuation)
=> Task.Run(continuation);
public void UnsafeOnCompleted(Action continuation)
=> ThreadPool.QueueUserWorkItem(state => ((Action)state!)(), continuation);
}
readonly struct EscapeAwaitable
{
public EscapeAwaiter GetAwaiter() => new EscapeAwaiter();
}
static class TaskEx
{
public static EscapeAwaitable EscapeContext() => new EscapeAwaitable();
}
Однако, есть случай, когда такой подход не сработает — при реализации IAsyncEnumerable<T>
. В этом случае, если вызывающий метод имеет SynchronizationContext
, то итератор будет получать контекст при каждом вызове .MoveNextAsync()
. В итоге, уход с контекста потребуется делать заново после каждого yield return
.
async IAsyncEnumerable<int> Process()
{
for (int i = 0; i < 3; ++i)
{
await Task.Delay(1000).ConfigureAwait(false);
// перешли на Thread Pool
yield return i;
// получили контекст обратно, т.к. это уже новый вызов метода `.MoveNextAsync()`
}
}
4. Кодогенерация
Чтобы не писать .ConfigureAwait(false)
вручную — их можно сгенерировать сразу по всей сборке или для отдельных классов и методов. Например, с помощью ConfigureAwait.Fody.
На мой взгляд, предыдущее решение лучше, как более явное, но если в проекте уже используется Fody, то выбрать этот вариант вполне логично.
Выводы
Проблемы, возникающие при работе асинхронного кода во внешнем контексте синхронизации могут быть решены и без повсеместного иcпользования .ConfigureAwait(false)
, причём как со стороны вызывающего, так и со стороны вызываемого кода.
Сейчас уже сложно сказать, почему работа с контекстом синхронизации в C# была спроектирована именно таким образом. Да и это лишено смысла — изменить это уже невозможно, т.к. это огромный breaking change.
В .NET сложилась практика использования .ConfigureAwait(false)
в коде библиотек, однако это не является обязательным:
- перейти на Thread Pool можно и другими способами, например с помощью своего
Awaiter
- клиентский код всегда может сам вызывать код библиотеки в правильном контексте
- в библиотеках созданных для использования, например из ASP.NET Core кода
.ConfigureAwait(false)
не нужны, т.к. их нет в самом ASP.NET Core
Так что на вопрос "нужен ли ConfigureAwait?" можно ответить: если вы его не используете и никто не жалуется — не нужен. А если уже используете, то всё зависит от кода, который ваш код использует.
Ссылки
Автор: Евгений Пешков