~/handys11
Browse the docs

dotnet/Runtime & Internals

async/await under the hood

advanced

TL;DR

// This method...
async Task<int> GetStatusAsync(HttpClient http, string url)
{
    var response = await http.GetAsync(url); // suspension point: state 0
    return (int)response.StatusCode;         // resumes here, runs to completion
}

// ...compiles to a struct implementing IAsyncStateMachine whose MoveNext()
// switches on a _state field and registers itself as the continuation.

// The cheat rules that fall out of that machinery:
await task.ConfigureAwait(false); // libraries: don't capture the context
await Task.WhenAll(a, b);         // compose; don't await sequentially
_ = Task.Run(CpuBound);           // offload CPU work, never wrap I/O

How it works

async/await is not a runtime feature — it’s a compiler transform. Roslyn rewrites an async method body into a state machine struct (a class in Debug builds, for easier stepping) implementing IAsyncStateMachine, with the method’s local variables hoisted into fields so they survive across suspensions.

MoveNext() and the awaiter fast path

All the code between awaits ends up inside one method: MoveNext(). A _state field records where the method was suspended, so each call to MoveNext() branches straight to the right resumption point instead of replaying earlier code — that’s the “state 0” in the TL;DR, the marker the compiler leaves so it knows which await it’s resuming from.

Every await calls GetAwaiter() on its operand and checks awaiter.IsCompleted. If the awaited operation is already done (a completed Task, a synchronous cache hit), execution just falls through to the next line in the same MoveNext() call — no suspension, and, because the struct hasn’t needed to outlive this stack frame, no heap allocation for the state machine either. Only when IsCompleted is false does the method actually suspend: it boxes the state machine (if it’s a struct) so it can outlive the current call, then calls awaiter.OnCompleted(continuation) (or UnsafeOnCompleted, when the awaiter doesn’t need ExecutionContext flow) on that boxed instance to register it as the callback, and returns the incomplete Task to the caller.

Where the continuation resumes

When an awaited operation completes, something has to decide which thread runs the continuation. await captures the current SynchronizationContext (or, absent one, the current TaskScheduler) before suspending, and posts the continuation back through it. That’s why a WPF or WinForms handler resumes on the UI thread after an await without you doing anything special — and why ASP.NET Core, which never installs a SynchronizationContext, doesn’t care which thread picks the continuation back up.

Independently of that, ExecutionContext — the carrier for AsyncLocal<T> and similar ambient state — flows across every await automatically, so code resuming on a different thread still sees the same logical call context it started with.

ValueTask: skipping the allocation

A Task/Task<T> is a heap object; returning one from every async call means an allocation even when the operation is going to complete synchronously nearly every time (a cache-backed lookup, a buffered stream read). ValueTask<T> is a struct that wraps either the result directly or a backing Task<T>/IValueTaskSource<T>, so the synchronous-completion path costs nothing extra — at the price of stricter usage rules (see Gotchas).

Gotchas

  • async void lets exceptions escape. With no Task for the caller to observe, an exception thrown inside an async void method is rethrown directly on the captured context — often crashing the process. Reserve async void for event handlers, where the delegate signature leaves no choice.
  • Sync-over-async deadlocks. Blocking on .Result or .Wait() from a thread that owns a SynchronizationContext (a UI thread, classic ASP.NET) deadlocks: the continuation needs that same thread to resume, but it’s stuck waiting. Anywhere else, it still ties up a thread pool thread while the awaited work needs another one — a fast way to starve the pool under load.
  • Suspension allocates. A state machine that actually suspends boxes itself (if a struct) and allocates the Task it returns. On genuinely hot paths where synchronous completion dominates, reach for ValueTask or restructure to avoid the state machine outright.
  • ValueTask may be awaited exactly once, never concurrently. No caching it, no awaiting it twice, no two callers racing to read the same one — unlike Task, its backing storage isn’t safe for repeat or concurrent consumption.
  • ConfigureAwait(false) only works if every await in the chain uses it. A single await further down the call stack without it re-captures the context and marshals back — one miss anywhere in a library’s internal call chain reintroduces the cost (and, under sync-over-async upstream, the deadlock risk) ConfigureAwait(false) was meant to remove.

Further reading