Is ConfigureAwait necessary?

image

I’ve never liked the verbosity of .NET code. Long and detailed names make it easier to work with business logic, but I want to keep the technical details of the code concise so that they distract as little attention as possible.

One of the verbose .NET constructs is related to the implementation details of asynchrony and overgrown with a bunch of myths. They ask about it at interviews, code reviews, make it mandatory, adding to the rules linter. This .ConfigureAwait(false)accompanying each await in code.

In this article I will tell you why you need ConfigureAwait(false) and how to do without it.

Before moving on to ConfigureAwait Let me remind you what is asynchronous code, where is the task continuationand what is SynchronizationContext.

How exactly the compiler transforms the code can be seen, for example, at sharplab.io

async void Btn_OnClick(object? sender, RoutedEventArgs e)
{
  Task<string> task = GetTextAsync();
  var text = await task;

  // continuation
  Text.Text = text;
}

Above is the code for the button click event handler. Where will the continuation of this event handler be executed after the asynchronous Task? No, not in the Thread Pool. All actions with the UI must be performed in one thread, on which the event loop is spinning. This code will only work if the continuation that updates the content TextBox Text will return to the UI thread that started processing the event.

For this UI frameworks establish SynchronizationContextwhich returns the continuation to the main thread’s queue.

Without SynchronziationContext I would have to explicitly shift the UI code to the UI thread:

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 found not only in UI code. For example xUnit redefines him to track async void methods and their exception handling. In the old ASP.NET was also set SynchronizationContext to access HttpContext. Luckily, ASP.NET Core doesn’t have it.

Except SynchronizationContext can also be redefined TaskSchedulerwith roughly the same results.

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;
}

Blocking the UI Thread

The UI developer can expect the execution of a method GetTextAsync synchronously (or the code uses a library with a bad, synchronous wait inside).

In this case:

  • The UI thread will block until this method completes.
  • In accordance with SynchronizationContextthe inner continuation of the method GetTextAsync (which calls Deserialize) must be executed on the UI thread
  • But the UI thread is blocked and cannot execute this continuation
  • Result: deadlock, although there is only one thread

In some cases, deadlock may not occur: if GetTextAsync will be executed synchronously, or if there is a transition to another context in it, for example, to the Thread Pool.

It’s worth noting that it’s desirable to avoid blocking waits, especially on the UI thread. Even if the deadlock does not occur, the program will appear to hang when the UI thread is blocked.

Excessive load on the UI thread

If GetTextAsync expected asynchronously (using await), another problem arises. Synchronization context gets into the method GetTextAsync and its continuation with method Deserialize will also run on the UI thread. There will be no blocking, but the UI thread will not be able to execute any more payloads while this method is executing. If the UI thread gets a lot of extra code that could be running in the background, the application will become less responsive.

Because of these problems and the difficulty of catching them, it has become a practice in .NET to write code that could potentially be called inside SynchronizationContext (i.e. in the library code) so that these problems do not arise, whatever this context is.

And the remedy for this is .ConfigureAwait(false)which provides the transfer of continuation to the 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;
}

In this case, the continuation of the method GetTextAsync will be executed on a thread from the Thread Pool, and the return to the original synchronization context will occur only when exiting from GetTextAsync – as a result, Btn_OnClick continuation will execute on the UI thread as expected.

If await will be executed synchronously – the transition to the Thread Pool will not occur. Hence the recommendation to use .ConfigureAwait(false) together with everyone await.

Also, .ConfigureAwait(false) deprives the caller of the ability to control where the asynchronous code will be executed by overriding SynchronizationContext And TaskScheduler. Some part of the code will “escape” to the standard Thread Pool due to widespread use .ConfigureAwait(false).

.ConfigureAwait(true) sets the default behavior and does not carry any meaning.

Not only Task

Except Task/Task<T> .ConfigureAwait(false) relevant for ValueTask/ValueTask<T>, IAsyncEnumerable<T> And IAsyncDisposable (and some other types).

Particular pain is IAsyncDisposable. Wrapper ConfiguredAsyncDisposable – not generic, and does not provide access to the original object, as a result, it is required divide creating an object and using it in a construct using. The scope of the variable goes beyond the boundaries of the block usingwhich creates the risk of errors in the code.

1. Solve the problem on the side of the calling code

It is naive to think that in all the code used there are .ConfigureAwait(false). You can initially write code that runs in the synchronization context so that it does not care how they are written await in the called code.

This can be achieved by running all code that does not need a synchronization context on the Thread Pool, for example with Task.Run. Delegate passed to Task.Run will be executed without a synchronization context – on a standard Thread Pool. In the absence of a synchronization context ConfugureAwait doesn’t make sense.

Taskreturned by the method Task.Run expected already in synchronization context, so continuation Btn_OnClick will be executed on the UI thread and the value in Text change successfully.

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;
}

This technique shifts the complexity to the calling code, but has several advantages:

  • will work regardless of .ConfigureAwait(false) in the called code
  • allows you to put more work into the background – now the code in the Thread Pool is executed not only after the first one that worked .ConfigureAwait(false)but also all the code before, in our example – not only Deserializebut also CreateRequest.

Also in Task.Run you can wrap not only an asynchronous method, but also a synchronous one, for example, in case there is a lock inside, leading to a deadlock, or to put heavy calculations into the background.

2. Use proper synchronous wait

The previous method will also work for synchronous waits. You can make a synchronous wrapper over an asynchronous method, which, unlike a simple .Wait()/.Result/.GetAwaiter().GetResult() will be resistant to the described deadlock.

In an ideal world, you want the synchronous version of the method to be implemented separately and not involve the Thread Pool. This is often unrealistic due to the need to support two implementations at once. This method is just for such cases.

public void Do()
{
  if (SynchronizationContext.Current == null && TaskScheduler.Current == TaskScheduler.Default)
    DoAsync().GetAwaiter().GetResult();
  else
    Task.Run(() => DoAsync()).GetAwaiter().GetResult();
}

3. One-time transition to the Thread Pool

This method is intended for developers of asynchronous code in libraries who need to ensure that their code works regardless of SynchronizationContext and how the code is used from outside, but there is no desire to litter the code .ConfigureAwait(false).

Instead of ubiquitous .ConfigureAwait(false) in the called code, it is proposed to write a construct that leads the execution of the method on the Thread Pool once at the beginning of the method. It is possible to be limited only to public methods.

With this method, the transition to the Thread Pool will occur immediately, before the first asynchronous await. This can degrade performance if everything is normally await are executed synchronously, without changing the thread, or vice versa, increase – if before the first asynchronous await the computational code is executed, occupying the UI thread in vain. In real conditions, the difference is unlikely to be noticed at all.

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;
}

The method is seen in dotnet/runtime. Also have issue about adding a public API and a ready-made implementation in Microsoft.VisualStudio.Threading.

The following is an implementation that only transitions to the Thread Pool if a synchronization context is specified, or 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();
}

4. Code generation

not to write .ConfigureAwait(false) manually – they can be generated immediately for the entire assembly or for individual classes and methods. For example, using ConfigureAwait.Fody.

In my opinion, the previous solution is better, as it is more explicit, but if the project is already using Fody, then choosing this option is quite logical.

Problems that arise when running asynchronous code in an external synchronization context can be solved without the widespread use of .ConfigureAwait(false)both from the side of the caller and from the side of the called code.

Now it’s hard to say why the synchronization context in C# was designed this way. Yes, and this makes no sense – it is already impossible to change it, because. this is a huge breaking change.

The practice in .NET is to use .ConfigureAwait(false) in the library code, however this is not mandatory:

  • You can switch to Thread Pool in other ways, for example, using your Awaiter
  • client code can always call the library code itself in the correct context
  • in libraries created for use, for example from ASP.NET Core code .ConfigureAwait(false) are not needed, because they are not in ASP.NET Core itself

So to the question “is ConfigureAwait necessary?” you can answer: if you don’t use it and no one complains, you don’t need it. And if you are already using it, then it all depends on the code that your code uses.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *