Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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).

  • Advanced
  • 20 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: After an await, the SynchronizationContext decides where the method resumes · Blocking the context's only thread on its own task is a deadlock. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

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:

where.cs: the same await under a one-thread context and without one C# · where/where.cs
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:

Interactive · step by step Where the rest of a method runs after one await Step through the questions one await answers when its task is not finished yet. The left column is the path of a UI thread, the right one the path to the thread pool.
Where the rest of a method runs after one await, step 7 of 7. Showing await Task.Delay(50), ConfigureAwait(false)?, a context is current?, posted to the context, its thread runs the rest, a pool thread runs it. In focus: ConfigureAwait(false)?, a pool thread runs it.noyesyesnoneawait Task.Delay(50)the task is not finishedConfigureAwait(false)?asked firsta context is current?SynchronizationContextposted to the contextits queue keeps itits thread runs the restthe same thread as beforea pool thread runs itwhichever is free

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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".
  7. 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

  1. Question 1 of 2 In a plain console app, a method prints its thread, awaits Task.Delay(50) and prints its thread again. What does the second line report?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A pool thread, usually not the one the method started on

    An await that meets an unfinished task posts the rest of the method to SynchronizationContext.Current. In a console app that is null, so the continuation runs on whichever pool thread completes the delay.

  2. Question 2 of 2 In which of these places is SynchronizationContext.Current not null?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In a click handler on the UI thread of a WPF app

    UI frameworks, Unity and the old ASP.NET on .NET Framework install a context; console apps, worker services and ASP.NET Core do not.

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:

deadlock.cs: .Result on the thread of a one-thread context C# · deadlock/deadlock.cs
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:

Interactive · message by message The deadlock of deadlock.cs, step by step Step through the run. The UI thread is on the left, the task of GetPriceAsync in the middle, the UI context's queue on the right.
The deadlock of deadlock.cs, step by step, step 6 of 6. Messages so far: UI thread to GetPriceAsync: GetPriceAsync("ABC"); GetPriceAsync to UI thread: a task, not done; GetPriceAsync to UI queue: Post(the rest).UI threadblocked in .ResultGetPriceAsyncWaitingForActivationUI queue1 callback waitingGetPriceAsync("ABC")a task, not donePost(the rest)

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

fixes.cs: the handler awaits, or the library skips the context C# · fixes/fixes.cs
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

  1. Question 1 of 2 Which three things together made deadlock.cs hang?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The handler blocking that same thread with .Result
    • An await in GetPriceAsync that captured that context
    • A context that runs its callbacks on one thread

    Remove any one of the three and the hang goes: no context, as in a console app; ConfigureAwait(false) in the library; or a caller that awaits instead of blocking.

  2. Question 2 of 2 A WPF handler blocks with .Result on GetCustomerAsync from a library your team also owns, and the window freezes. Which change is right?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Make the handler await the call, and add ConfigureAwait(false) to every await inside the library

    Application code awaits all the way up; library code uses ConfigureAwait(false) on every await. Each fix protects a different caller.

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.

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.

  1. Question 1 of 5 An await finds a SynchronizationContext and its task is not finished. What happens to the rest of the method?

    Choose one answer.

    Show the answer: Question 1 of 5

    Answer: It is posted to that context when the task completes

    With a context, the continuation goes to that context; without one, to the thread pool.

  2. Question 2 of 5 An ASP.NET Core action calls repository.GetAsync(id).Result. Does it deadlock the way a WPF click handler does?

    Choose one answer.

    Show the answer: Question 2 of 5

    Answer: No: ASP.NET Core has no context, so the continuation runs on a pool thread, but the call holds a pool thread while it waits

    Where there is no context, sync-over-async does not deadlock, but each blocked call still holds a pool thread.

  3. Question 3 of 5 The code of deadlock.cs finishes in a plain console app. Why?

    Choose one answer.

    Show the answer: Question 3 of 5

    Answer: There is no context, so the rest of GetPriceAsync runs on a pool thread while the main thread waits

    Console apps hide the deadlock, which is why code tested there can still freeze a UI app.

  4. Question 4 of 5 In deadlock.cs, .Result is replaced by GetAwaiter().GetResult(). What happens?

    Choose one answer.

    Show the answer: Question 4 of 5

    Answer: It still hangs: GetResult blocks the same thread that the continuation needs

    Every synchronous wait (.Result, .Wait(), GetAwaiter().GetResult()) blocks the thread, and that thread is the one the continuation needs.

  5. Question 5 of 5 A WPF handler runs var c = await client.GetAsync(id).ConfigureAwait(false); and then sets nameBox.Text = c.Name. What is wrong?

    Choose one answer.

    Show the answer: Question 5 of 5

    Answer: The second line runs on a pool thread, not the UI thread, where the text box must be used

    Leave the awaits of UI code plain, so the code after them is back on the UI thread.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • After an await of 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.