dotnet/Runtime & Internals
async/await under the hood
advancedTL;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 voidlets exceptions escape. With noTaskfor the caller to observe, an exception thrown inside anasync voidmethod is rethrown directly on the captured context — often crashing the process. Reserveasync voidfor event handlers, where the delegate signature leaves no choice.- Sync-over-async deadlocks. Blocking on
.Resultor.Wait()from a thread that owns aSynchronizationContext(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
Taskit returns. On genuinely hot paths where synchronous completion dominates, reach forValueTaskor restructure to avoid the state machine outright. ValueTaskmay be awaited exactly once, never concurrently. No caching it, no awaiting it twice, no two callers racing to read the same one — unlikeTask, its backing storage isn’t safe for repeat or concurrent consumption.ConfigureAwait(false)only works if every await in the chain uses it. A singleawaitfurther 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
- How async/await really works (Stephen Toub)
- Async guidance (David Fowler)
- Stephen Cleary’s blog