Knowing the machinery explains deadlocks, allocations and when ValueTask or ConfigureAwait matter.
The compiler rewrites an async method into a state machine struct implementing IAsyncStateMachine. Local variables become fields; each await becomes a state. At an await, the code calls GetAwaiter() on the task; if IsCompleted is already true it continues synchronously with no allocation. Otherwise it registers a continuation via the method builder (AsyncTaskMethodBuilder), boxes the state machine to the heap once, and returns to its caller.
When the awaited operation completes, the continuation is scheduled according to the captured context:
- If a
SynchronizationContextwas present (WinForms, WPF, MAUI UI thread), the continuation is posted back to it. Blocking that thread with.Resultwhile the continuation waits for it is the classic deadlock. - Otherwise (ASP.NET Core, console apps), continuations run on the thread pool.
ConfigureAwait(false)says "don't capture the context". Library code that might run under a UI context uses it; ASP.NET Core application code doesn't need it.
ValueTask<T> avoids allocating a Task when results are often available synchronously (cached reads, buffered streams) and can be backed by pooled IValueTaskSource objects. Rules: await it exactly once, never concurrently, and don't call .Result before completion; call .AsTask() if you need more.
Exceptions are captured into the task (ExceptionDispatchInfo preserves the stack). Task.WhenAll faults with all exceptions in AggregateException.InnerExceptions, but await rethrows only the first.
Example
static async Task<int> GetAsync(bool cached)
{
if (cached) return 42; // completes synchronously
await Task.Delay(10);
return 7;
}
Task<int> t = GetAsync(true);
Console.WriteLine(t.IsCompleted); // True: no suspension happened
await Task.Delay(10).ConfigureAwait(false); // resume on any pool thread
Console.WriteLine(SynchronizationContext.Current is null); // True (console app)
var both = Task.WhenAll(Task.Run(() => throw new InvalidOperationException("a")),
Task.Run(() => throw new ArgumentException("b")));
try { await both; }
catch (Exception ex)
{
Console.WriteLine(ex.GetType().Name); // InvalidOperationException (first)
Console.WriteLine(both.Exception!.InnerExceptions.Count); // 2
}
static ValueTask<int> CachedAsync(int key) =>
key == 0 ? ValueTask.FromResult(0) : new ValueTask<int>(GetAsync(false));
Console.WriteLine(await CachedAsync(0) + await CachedAsync(1)); // 7Remember
- async methods compile to state machines that only allocate when they truly suspend
- Captured SynchronizationContext + blocking = deadlock
- ConfigureAwait(false) matters for libraries, not ASP.NET Core app code
- Await a ValueTask once; use it only when sync completion is common
Watch out for
- Awaiting the same ValueTask twice, which is undefined behavior for pooled sources.