Deadlocks, ConfigureAwait and ValueTask
Why blocking on async code can hang forever, what ConfigureAwait(false) changes and when it does not help, and the rules ValueTask comes with.
After this lesson you can
- Explain step by step how .Result on a single-threaded context deadlocks
- Say what ConfigureAwait(false) changes, and why it must be on every await in library code
- Choose between .Result and GetAwaiter().GetResult(), and between Task and ValueTask
When an await has to wait, it captures the current
SynchronizationContext (if there is one) and later asks that
context to run the rest of the method. A UI framework (WPF, WinForms,
MAUI) installs a context whose job is to run continuations back on
the UI thread; classic ASP.NET's context similarly lets only one
thread at a time work on a request. ASP.NET Core and console
apps have no such context, so continuations just run on the thread
pool.
The classic deadlock
- Code on the UI thread calls an async method and then blocks on it
with
.Resultor.Wait(). - The async method awaits something that is not finished, so it captures the UI context and returns an incomplete task.
- The awaited operation completes and the continuation is posted to the UI thread.
- The UI thread is blocked in step 1, waiting for the task, so it never runs the continuation, and the task never completes.
The example builds a minimal single-threaded context that only queues posted work, which is the essential behaviour of a UI thread that is blocked. Rather than hang forever, the blocked thread wakes as soon as either the task finishes or a continuation is posted to its own queue. Work queued behind a thread that never pumps can never run, so that moment is the deadlock, and no timeout is involved:
using System.Collections.Concurrent; // A stand-in for a UI thread: work posted here runs only when the owning// thread pumps the queue, and a thread blocked on a task never pumps.public sealed class SingleThreadContext : SynchronizationContext{ private readonly ConcurrentQueue<(SendOrPostCallback, object?)> _queue = new(); private readonly TaskCompletionSource _posted = new(TaskCreationOptions.RunContinuationsAsynchronously); public Task Posted => _posted.Task; // completes when anything is posted here public override void Post(SendOrPostCallback d, object? state) { _queue.Enqueue((d, state)); _posted.TrySetResult(); }} public static class Solution{ static async Task<int> GetAsync(bool continueOnCapturedContext) { await Task.Delay(50).ConfigureAwait(continueOnCapturedContext); return 42; } static string BlockOn(bool continueOnCapturedContext) { var previous = SynchronizationContext.Current; var context = new SingleThreadContext(); SynchronizationContext.SetSynchronizationContext(context); try { var task = GetAsync(continueOnCapturedContext); // Block this thread, as .Result would, until the task finishes or its // continuation is queued to this thread, which will never pump it. Task.WaitAny(task, context.Posted); return task.IsCompleted ? $"finished ({task.Result})" : $"continuation queued to the blocked thread, task {task.Status}: deadlock"; } finally { SynchronizationContext.SetSynchronizationContext(previous); } } public static string Describe() => $"default await: {BlockOn(true)} | ConfigureAwait(false): {BlockOn(false)}";}With a normal await the continuation was queued to the blocked thread
and the task could never finish. With ConfigureAwait(false) the await
did not capture the context, the continuation ran on the thread pool,
and the task finished.
What ConfigureAwait(false) does and does not do
ConfigureAwait(false) affects one await: if that await has to
wait, its continuation will not be posted back to the captured
context. Two consequences:
- If the awaited task is already complete, the method simply
continues on the same thread, still inside the context. The next
awaitwithoutConfigureAwait(false)captures the context again. That is why library code puts it on every await, not just the first. - It is a fix for library code, not for the caller. The real fix for
the deadlock is not to block: be
asyncall the way up andawaitinstead of.Result.
.Result versus GetAwaiter().GetResult()
Both block. They differ in how they report a failure:
public static class Solution{ static async Task<int> FailAsync() { await Task.Yield(); throw new InvalidOperationException("inner problem"); } public static string Describe() { string viaResult, viaGetResult; try { _ = FailAsync().Result; viaResult = "no exception"; } catch (Exception e) { viaResult = e.GetType().Name + " -> " + e.InnerException?.GetType().Name; } try { _ = FailAsync().GetAwaiter().GetResult(); viaGetResult = "no exception"; } catch (Exception e) { viaGetResult = e.GetType().Name; } ValueTask<int> fast = new ValueTask<int>(5); // already has its result: no Task allocated return $".Result throws {viaResult} | GetAwaiter().GetResult() throws {viaGetResult} | " + $"ValueTask completed={fast.IsCompletedSuccessfully} value={fast.Result}"; }}.Result and .Wait() wrap the failure in an AggregateException;
GetAwaiter().GetResult() rethrows the original exception, as await
would. When you truly must block (a Main method, a legacy
synchronous interface), prefer the latter.
ValueTask
ValueTask<T> is a struct that holds a result, a Task<T>, or a
reusable IValueTaskSource<T> that the producer may recycle for its
next operation. A method that usually completes synchronously, such as
a cache hit, can return a value without allocating a Task object. The
cost is a set of rules: consume a ValueTask once (one await, or
one .AsTask()), never await it concurrently, and do not read
.Result or call GetAwaiter().GetResult() before it has completed.
If you need to await it twice or hand it to Task.WhenAll, call
.AsTask() once and use the task. Default to Task; reach for
ValueTask when profiling shows the allocations matter.
Common mistakes
Exercise · Pro
Library code that cannot deadlock its caller
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade