
Мне никогда не нравилась многословность кода. Длинные и подробные названия упрощают работу с бизнес-логикой, но технические детали кода хочется держать краткими, чтобы они отвлекали на себя минимум внимания.
Одна из многословных конструкций .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?" можно ответить: если вы его не используете и никто не жалуется — не нужен. А если уже используете, то всё зависит от кода, который ваш код использует.
