Нужен ли ConfigureAwait?

в 19:01, , рубрики: .net, C#, configureawait checker, асинхронность

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

Ссылки

Автор: Евгений Пешков

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js