image


Мне никогда не нравилась многословность кода. Длинные и подробные названия упрощают работу с бизнес-логикой, но технические детали кода хочется держать краткими, чтобы они отвлекали на себя минимум внимания.


Одна из многословных конструкций .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.


IAsyncEnumerable
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?" можно ответить: если вы его не используете и никто не жалуется — не нужен. А если уже используете, то всё зависит от кода, который ваш код использует.


Ссылки