SynchronizationContext ConfigureAwait and deadlocks

 
 
  • Gérald Barré

This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!

The previous post covered ExecutionContext, which decides what state a continuation sees. This post is about the other thing await captures, which decides where the continuation runs.

It is also the mechanism behind the single most famous bug in async C#: calling .Result and hanging forever.

#What a SynchronizationContext is

A SynchronizationContext is an abstraction over "post this callback to the right place". It has one method that matters, Post, and the implementation decides what "the right place" means.

  • WinForms, WPF and MAUI install one that posts to the UI message loop. There is exactly one thread that may touch the UI, so this is how a continuation gets back to it.
  • Legacy ASP.NET installed one that restored the request context and allowed one thread at a time per request.
  • Console applications and ASP.NET Core have none. SynchronizationContext.Current is null, and continuations go straight to the thread pool.

The sample implements a minimal one. It is about thirty lines: a queue, a Post that adds to it, and a loop that takes items off and runs them.

C#
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));

public void RunOnCurrentThread()
{
    SetSynchronizationContext(this);
    foreach (var (callback, state) in _queue.GetConsumingEnumerable())
    {
        callback(state);
    }
}

That is genuinely all a UI message loop is, for our purposes: one thread, running one callback at a time, off a queue.

#What await does with it

When a TaskAwaiter needs to schedule a continuation, it decides where to run it:

yes

no

yes

no

Awaiter needs to schedule a continuation

SynchronizationContext.Current is not null?

Post to that context

TaskScheduler.Current is not Default?

Queue to that scheduler

Queue to the thread pool

yes

no

yes

no

Awaiter needs to schedule a continuation

SynchronizationContext.Current is not null?

Post to that context

TaskScheduler.Current is not Default?

Queue to that scheduler

Queue to the thread pool

ConfigureAwait(false) tells the awaiter to skip the first two branches and go straight to the thread pool.

The sample's tests check both halves of that. After a plain await inside the context, SynchronizationContext.Current is still the context. After await ... .ConfigureAwait(false), it is null.

#The deadlock

Now put the two together. Here is the whole bug:

C#
// on the UI thread
var value = GetValueAsync().Result;

static async Task<string> GetValueAsync()
{
    await Task.Delay(50);
    return "value";
}
  1. GetValueAsync runs until await Task.Delay(50), which has not completed, so it captures the context and returns.
  2. .Result blocks the UI thread waiting for the task.
  3. 50 ms later the delay completes and the continuation is posted to the UI message loop.
  4. The message loop is the blocked thread. It will never run the continuation.
  5. The task never completes, so .Result never returns.

Each side is waiting for the other. Running the sample:

A console app has no SynchronizationContext: True
So blocking here is merely wasteful, not fatal: 'value'

await without ConfigureAwait, then block: DEADLOCK, still blocked after 2s
await with ConfigureAwait(false), then block: completed with 'value'

The first line matters as much as the last one. The same code that deadlocks in WPF works in a console app or in ASP.NET Core, because there is no context to post back to and the continuation goes to the thread pool instead. That is why this bug is so good at reaching production: it does not reproduce in your unit tests.

#When to use ConfigureAwait(false)

The guidance has not changed in a decade, and it is short:

In library code, use ConfigureAwait(false) on every await. You do not know who is calling you. If somebody blocks on your library from a UI thread, ConfigureAwait(false) is what saves them. It also avoids a pointless post back to the UI thread for a continuation that does not touch the UI.

In application code, do not bother. In ASP.NET Core and console applications there is no context, so it changes nothing. In a UI application you usually want to come back to the UI thread, which is exactly what the default does.

.NET 8 added a more expressive form:

C#
await task.ConfigureAwait(ConfigureAwaitOptions.None);                  // like ConfigureAwait(false)
await task.ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext); // like ConfigureAwait(true)
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);      // await completion, ignore failure
await task.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);         // never continue inline

SuppressThrowing is genuinely useful for "wait for this to finish, I do not care how it went", which previously needed a try/catch that swallowed everything.

#What it costs

Almost nothing, and that is worth measuring because "ConfigureAwait(false) is faster" is a common belief:

MethodMeanRatioAllocated
Default5.618 ns1.0072 B
ConfigureAwaitTrue6.271 ns1.1272 B
ConfigureAwaitFalse6.348 ns1.1372 B
SuppressThrowing6.474 ns1.1572 B
Benchmark project, measured with BenchmarkDotNet

With no context installed, ConfigureAwait(false) is very slightly slower than not calling it, because it goes through ConfiguredTaskAwaitable rather than the plain TaskAwaiter. The difference is under a nanosecond and none of them allocate anything extra.

So use ConfigureAwait(false) for correctness in libraries. Do not use it expecting a speedup, and do not add it to application code as a performance measure. Where it genuinely saves time is the case this benchmark cannot show: skipping a post back to a busy UI thread.

#The other things that capture a context

await is not the only one, and the others are easier to get wrong:

  • Task.ContinueWith uses TaskScheduler.Current by default, not the SynchronizationContext. To get await-like behaviour you need TaskScheduler.FromCurrentSynchronizationContext() explicitly. This is one more reason to prefer await.
  • Task.Run deliberately does not capture. It runs on the pool, always.
  • Task.Yield() posts back to the current context, so in a UI application it returns to the UI thread. If you want the pool unconditionally, you need a custom awaitable like the one in the awaitable pattern post.
  • await foreach captures for each MoveNextAsync. ConfigureAwait(false) on an async stream needs WithCancellation and ConfigureAwait on the enumerable itself, which the async streams post covers.

#What to take away

  • A SynchronizationContext decides where a continuation runs. UI frameworks have one, ASP.NET Core and console applications do not.
  • await captures it by default and posts the continuation back to it.
  • Blocking on a task from a thread that owns a single-threaded context deadlocks: the continuation needs the thread you are blocking.
  • The same code does not deadlock without a context, which is why the bug survives testing.
  • ConfigureAwait(false) skips the capture. Use it throughout library code, and rely on an analyzer to enforce it.
  • It costs nothing measurable. It is a correctness tool, not a performance one.

The next post moves to the return types themselves: Task versus ValueTask, and the IValueTaskSource machinery underneath.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?