C# & .NET (C# 14, .NET 10 LTS) Module 15 – Asynchronous programming
SynchronizationContext, ConfigureAwait and deadlocks
See where C# code resumes after await, why blocking with .Result on a UI thread deadlocks, and when library code should use ConfigureAwait(false).
What you will learn
- Predict which thread runs the code after an await, with and without a SynchronizationContext
- Explain, step by step, why blocking on a task deadlocks a thread whose context runs one callback at a time
- Fix the deadlock by awaiting all the way up, and use ConfigureAwait(false) in library code
- Predict where code runs after each ConfigureAwait setting, and which state reaches the code after an await
- Build a synchronous wrapper over an async library that is safe for callers on a UI thread, and test it
Before you start
What you’ll learn · Before you start
What you will learn
- Predict which thread runs the code after an await, with and without a SynchronizationContext
- Explain, step by step, why blocking on a task deadlocks a thread whose context runs one callback at a time
- Fix the deadlock by awaiting all the way up, and use ConfigureAwait(false) in library code
- Predict where code runs after each ConfigureAwait setting, and which state reaches the code after an await
- Build a synchronous wrapper over an async library that is safe for callers on a UI thread, and test it
Before you start
On this page
A click handler that calls GetPriceAsync("ABC").Result freezes a desktop app for good, while the same line in a
console app returns the price after 100 ms. The line is the same; the thread it runs on is not, and what differs is
that thread’s SynchronizationContext, the object that decides where the rest of a method runs after an await.
You will predict where each part of an async method runs, reproduce the freeze with a context of your own, fix it the two ways Microsoft’s guidance gives, and see what ConfigureAwait changes.
After an await, the SynchronizationContext decides where the method resumes
where.cs runs one method twice: under a context that runs every callback on one thread, as a desktop app’s UI
thread does, and in a plain console app. It prints where it is before and after one await:
using System.Collections.Concurrent;
// Where does the rest of a method run after an await? The SynchronizationContext decides.
Console.WriteLine("Under a context that runs its callbacks on one thread, as a UI thread does:");
OneThreadContext.Run(ShowAsync);
Console.WriteLine("In a plain console app, with no context:");
await ShowAsync();
static async Task ShowAsync()
{
int before = Environment.CurrentManagedThreadId;
Console.WriteLine($" before await: {Describe()}");
await Task.Delay(50); // not finished yet, so the method stops here and resumes later
string same = Environment.CurrentManagedThreadId == before ? "the same thread" : "another thread";
Console.WriteLine($" after await: {Describe()}, {same}");
}
static string Describe()
{
string thread = Thread.CurrentThread.IsThreadPoolThread ? "a pool thread" : "the main thread";
string context = SynchronizationContext.Current?.GetType().Name ?? "none";
return $"{thread}, context {context}";
}
// A context that queues every callback and runs them one at a time on the thread that called Run,
// the way a UI thread runs its message loop.
sealed class OneThreadContext : SynchronizationContext
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> queue = new();
public override void Post(SendOrPostCallback d, object? state) => queue.Add((d, state));
public static void Run(Func<Task> work)
{
var context = new OneThreadContext();
SynchronizationContext? previous = Current;
SetSynchronizationContext(context);
try
{
Task task = work();
task.ContinueWith(_ => context.queue.CompleteAdding(), TaskScheduler.Default);
foreach (var (callback, state) in context.queue.GetConsumingEnumerable())
{
callback(state);
}
task.GetAwaiter().GetResult();
}
finally
{
SetSynchronizationContext(previous);
}
}
} Output
Under a context that runs its callbacks on one thread, as a UI thread does: before await: the main thread, context OneThreadContext after await: the main thread, context OneThreadContext, the same thread In a plain console app, with no context: before await: the main thread, context none after await: a pool thread, context none, another thread
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run where.cs
Before the await, both runs were on the main thread. After it, the first came back to that same thread and the
second continued on a pool thread. A synchronization context: The object in SynchronizationContext.Current that decides where callbacks run. An await that finds one posts the rest of its method to it; UI threads have one that runs callbacks on the UI thread, while console apps and ASP.NET Core have none. is the object
that decides where the rest of an async method runs after an await. An await that meets an unfinished task reads
SynchronizationContext.Current: with a context there, the rest of the method is posted to it; with none, the rest
runs on the thread pool. Step through the questions one await answers:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The await meets a pending task. ShowAsync reaches await Task.Delay(50). The delay is not finished, so the method returns for now, and the await must decide where its rest will run when the delay ends.
- Was the context switched off?. The awaiter first asks whether this await said ConfigureAwait(false). In where.cs it did not, so the answer is no and the next question is asked.
- Is a context current?. Next it reads SynchronizationContext.Current on the thread that is running the method. Under OneThreadContext.Run the answer is that context; in the plain console run it is null.
- A context: the rest is posted. When a context is current, the awaiter captures it. When the delay ends, the rest of ShowAsync is posted to that context, whose queue keeps it until its thread asks for work.
- The context's thread runs it. OneThreadContext runs its queue on the main thread, so the first run printed "after await: the main thread, the same thread". A UI thread does the same with its message loop.
- Without one, the pool runs it. In the console run Current was null, so the rest of ShowAsync ran on whichever pool thread completed the delay: "after await: a pool thread, another thread".
- ConfigureAwait(false) skips it. An await written with ConfigureAwait(false) never reads the context: its rest goes to the pool even on a UI thread. That is the second fix of the next section.
Which context a thread has depends on the app:
| The code runs on | SynchronizationContext.Current |
The rest after an await runs on |
|---|---|---|
| a console app or a worker service | null |
a pool thread |
| ASP.NET Core | null |
a pool thread |
| the UI thread of WPF, Windows Forms or .NET MAUI | a UI context | the UI thread, one callback at a time |
| Unity’s main thread | UnitySynchronizationContext |
the main thread, at a later frame’s update |
| ASP.NET on .NET Framework | the request’s context | that request, one thread at a time |
A UI context is what brings a click handler back to the UI thread after an await, and every continuation posted to
it needs that one thread to be free.
Checkpoint
Check: Where code resumes after an await
Blocking the context’s only thread on its own task is a deadlock
deadlock.cs is the click handler of the opening, on a thread whose context runs queued callbacks only when that
thread asks. A watchdog ends the process after 2 seconds; the program would otherwise wait for ever:
using System.Collections.Concurrent;
// The click handler of a desktop app runs on the UI thread, whose context runs callbacks one at a time.
var ui = new OneThreadContext();
SynchronizationContext.SetSynchronizationContext(ui);
Task<decimal>? price = null;
var watchdog = new Thread(() =>
{
Thread.Sleep(2000); // a real app would hang here for ever; this one is stopped after 2 seconds
Console.WriteLine($"watchdog: after 2000 ms the price task is {price?.Status}");
Console.WriteLine($"watchdog: callbacks waiting for the UI thread: {ui.Waiting}");
Environment.Exit(1);
}) { IsBackground = true };
watchdog.Start();
Console.WriteLine("the handler calls GetPriceAsync");
price = GetPriceAsync("ABC");
Console.WriteLine($"GetPriceAsync returned a task that is {price.Status}");
Console.WriteLine("the handler blocks on .Result");
decimal value = price.Result; // the UI thread waits here, so it can never run the rest of GetPriceAsync
Console.WriteLine($"price: {value}");
static async Task<decimal> GetPriceAsync(string code)
{
await Task.Delay(100); // captures the UI context: the rest of the method must run on the UI thread
Console.WriteLine($"the rest of GetPriceAsync runs for {code}");
return 412.50m;
}
// A context that queues every callback for its one thread; nothing runs them until that thread asks.
sealed class OneThreadContext : SynchronizationContext
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> queue = new();
public int Waiting => queue.Count;
public override void Post(SendOrPostCallback d, object? state) => queue.Add((d, state));
} Output (exit status 1)
the handler calls GetPriceAsync GetPriceAsync returned a task that is WaitingForActivation the handler blocks on .Result watchdog: after 2000 ms the price task is WaitingForActivation watchdog: callbacks waiting for the UI thread: 1
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run deadlock.cs
After 2,000 ms the price task was still WaitingForActivation, and one callback, the rest of GetPriceAsync, was
waiting for the UI thread. Step through how the waits close a circle:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The call. The handler calls GetPriceAsync("ABC") on the UI thread. The method runs up to await Task.Delay(100), and the delay is not finished.
- The capture. The await finds the UI context current and captures it. GetPriceAsync returns its task, still WaitingForActivation, as the second line of the run shows.
- The block. The handler reads price.Result. The UI thread now waits for the task, and while it waits it runs nothing from its queue.
- The post. At 100 ms the delay ends. The rest of GetPriceAsync must run on the UI thread, so it is posted to the UI queue and waits there.
- The cycle. The callback waits for the UI thread, the UI thread waits for the task, and the task can finish only when the callback runs. None of the three can move.
- The watchdog. After 2,000 ms the watchdog finds the task still WaitingForActivation and 1 callback waiting, and ends the process with exit status 1. A real app would freeze for ever.
Such deadlock: A state in which pieces of work each wait for another to go first, so none of them can go on. Two threads that each hold a lock the other needs are one case; a thread blocked on a task whose continuation needs that same thread is another. need three things at once: a context that runs its callbacks on one thread,
an await in the called code that captures it, and a caller that blocks that same thread with .Result, .Wait() or
GetAwaiter().GetResult(), the pattern called sync-over-async: Calling async code from synchronous code by blocking on its task with .Result, .Wait() or GetAwaiter().GetResult(). The thread waits for the whole call, and on a thread whose context runs one callback at a time it can deadlock.. Console apps and
ASP.NET Core hide these deadlocks, because with no context the continuation runs on a pool thread and the blocked
caller gets its result. Each case of fixes.cs removes one of the
three, under the same kind of context:
using System.Collections.Concurrent;
// The handler of deadlock.cs, fixed in two ways, under the same kind of one-thread context.
OneThreadContext.Run(async () =>
{
// Fix 1, async all the way: the handler awaits, so the UI thread stays free to run the rest of GetPriceAsync.
decimal price = await GetPriceAsync("ABC");
Console.WriteLine($"fix 1, the handler awaits: {price}; the handler resumes on {Describe()}");
});
OneThreadContext.Run(() =>
{
// Fix 2, the library captures no context: the handler still blocks, but nothing needs the UI thread.
decimal price = GetPriceNoContextAsync("ABC").Result;
Console.WriteLine($"fix 2, the handler blocks on a library that uses ConfigureAwait(false): {price}");
return Task.CompletedTask;
});
static async Task<decimal> GetPriceAsync(string code)
{
await Task.Delay(100);
Console.WriteLine($" GetPriceAsync({code}) resumes on {Describe()}");
return 412.50m;
}
static async Task<decimal> GetPriceNoContextAsync(string code)
{
await Task.Delay(100).ConfigureAwait(false);
Console.WriteLine($" GetPriceNoContextAsync({code}) resumes on {Describe()}");
return 412.50m;
}
static string Describe()
{
string thread = Thread.CurrentThread.IsThreadPoolThread ? "a pool thread" : "the main thread";
string context = SynchronizationContext.Current?.GetType().Name ?? "none";
return $"{thread}, context {context}";
}
// A context that queues every callback and runs them one at a time on the thread that called Run.
sealed class OneThreadContext : SynchronizationContext
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> queue = new();
public override void Post(SendOrPostCallback d, object? state) => queue.Add((d, state));
public static void Run(Func<Task> work)
{
var context = new OneThreadContext();
SynchronizationContext? previous = Current;
SetSynchronizationContext(context);
try
{
Task task = work();
task.ContinueWith(_ => context.queue.CompleteAdding(), TaskScheduler.Default);
foreach (var (callback, state) in context.queue.GetConsumingEnumerable())
{
callback(state);
}
task.GetAwaiter().GetResult();
}
finally
{
SetSynchronizationContext(previous);
}
}
} Output
GetPriceAsync(ABC) resumes on the main thread, context OneThreadContext fix 1, the handler awaits: 412.50; the handler resumes on the main thread, context OneThreadContext GetPriceNoContextAsync(ABC) resumes on a pool thread, context none fix 2, the handler blocks on a library that uses ConfigureAwait(false): 412.50
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run fixes.cs
Fix 1 removes the block: the handler awaits, so the UI thread stays free to run the rest of GetPriceAsync, then
the rest of the handler. Fix 2 removes the capture: ConfigureAwait with false tells that
await not to post to the context, so the library resumes on a pool thread and the blocked handler gets 412.50.
Microsoft’s guidance puts each fix where it belongs: application code awaits all the way up, and library code calls
ConfigureAwait(false) on every await unless it needs the caller’s context.
ConfigureAwait(false) in the click handler
After await LoadAsync().ConfigureAwait(false) in a handler, the rest of the handler runs on a pool thread, so the
next line that updates the window is no longer on the UI thread. Leave the handler’s awaits plain;
ConfigureAwait(false) belongs in the library code it calls.
Awaiting protects the UI thread you own; ConfigureAwait(false) protects every caller of a library you publish,
including those who block.
Key idea
An await sends the rest of its method to the context it found, so never block that context’s thread on the task:
await all the way up in application code, and use ConfigureAwait(false) in library code.
Checkpoint
Check: The sync-over-async deadlock and its two fixes
What ConfigureAwait changes, and what flows through every await Pro: opens with a Pro, Premium or Ultimate pass
ConfigureAwait acts at one await, and only when that await really waits. This part runs every setting of ConfigureAwait and ConfigureAwaitOptions on a context’s thread, then shows the state that reaches the code after an await whatever the setting.
In this part:
- ConfigureAwait(false) does nothing at an await that does not wait
- AsyncLocal values flow through every await, and the context does not
2 runnable examples (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Synchronous wrappers over async code: what hangs, what holds and what it costs Pro: opens with a Pro, Premium or Ultimate pass
Sometimes synchronous code must call an async method, because an interface or a framework callback leaves no choice. This part reproduces how one missing ConfigureAwait three calls deep still hangs a blocking caller, and measures what each workaround costs the thread that blocks.
In this part:
- One await without ConfigureAwait(false), three calls deep, hangs the caller
- The workarounds side by side, with what each one costs
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
A lab: a price client that hangs callers on a UI thread, made safe Pro: opens with a Pro, Premium or Ultimate pass
A library offers GetPrice for callers that cannot await, and it hangs every caller on a UI thread. This lab makes it safe for blocking and awaiting callers alike, with five checks that run on a UI-like thread.
In this part:
- The rules and the five checks
- Where ConfigureAwait goes, and why the wrapper stays one line
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The self-check covers the first two sections: where code resumes after an await, and the deadlock with its fixes. Questions on what you know least come first, and each choice is explained as soon as you answer:
Practice that adapts to you
Practise this lesson
5 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does ConfigureAwait(false) do?
It tells that one await not to come back to the captured context. Without it, an await of an unfinished task that finds a SynchronizationContext, such as a UI thread’s, posts the rest of the method there; with it, the rest runs on a pool thread. Library code uses it on every await, so that a caller who blocks on its task from a single-threaded context cannot deadlock. Application code that updates the window after the await leaves it out. Console apps and ASP.NET Core have no context, so there it changes nothing.
-
Fresher to mid
A WPF button handler calls customerClient.GetCustomerAsync(42).Result, and the window freezes for good on the first click. The same client works in a console tool and in ASP.NET Core. Explain step by step why, and fix it in the handler and in the library.
-
Mid to senior
A teammate wants a rule that every await in the solution, including the WPF app's view models and click handlers, gets ConfigureAwait(false) to prevent deadlocks. Do you agree? Say where it helps, where it breaks code, and what you would put in the coding guidelines.
-
Senior to staff
A plugin host calls your plugin through a synchronous interface method, string Load(string key), on its UI thread, and the only storage client you have is async. Design the safest Load, say what can still go wrong, and how you would test it before shipping.
Key takeaways
- After an
awaitof an unfinished task, the rest of the method is posted to the current SynchronizationContext, or runs on the thread pool when there is none, as in console apps and ASP.NET Core. - Blocking the thread of a single-threaded context on a task whose continuation was posted to that context is a deadlock: the thread waits for the task, and the task waits for the thread.
- Await all the way up in application code, and put
ConfigureAwait(false)on every await of library code.
Terms in this lesson
- deadlock
- A state in which pieces of work each wait for another to go first, so none of them can go on. Two threads that each hold a lock the other needs are one case; a thread blocked on a task whose continuation needs that same thread is another.
- sync-over-async
- Calling async code from synchronous code by blocking on its task with .Result, .Wait() or GetAwaiter().GetResult(). The thread waits for the whole call, and on a thread whose context runs one callback at a time it can deadlock.
- synchronization context
- The object in SynchronizationContext.Current that decides where callbacks run. An await that finds one posts the rest of its method to it; UI threads have one that runs callbacks on the UI thread, while console apps and ASP.NET Core have none.
References
- ExecutionContext and SynchronizationContext (Microsoft Learn)
- SynchronizationContext and console apps (Microsoft Learn)
- Synchronous wrappers for asynchronous methods (Microsoft Learn)
- Common async/await bugs (Microsoft Learn)
- ConfigureAwaitOptions enum (Microsoft Learn)
- Task<TResult>.ConfigureAwait method (Microsoft Learn)
- CA2261: Do not use ConfigureAwaitOptions.SuppressThrowing with Task<TResult> (Microsoft Learn)
- CA2007: Do not directly await a Task (Microsoft Learn)
- Awaitable completion and continuation (Unity Technologies)
Report a problem with this lesson
Kept only in this browser. Your Learn progress