Async/Await — State Machine
Key Points
async/awaitis compiler magic: eachasyncmethod becomes a generated state machine struct (<MethodName>d__N) implementingIAsyncStateMachine. The runtime drives this struct via repeatedMoveNext()calls.- The state machine captures locals as fields so they survive across awaits.
- The async builder (
AsyncTaskMethodBuilder/AsyncValueTaskMethodBuilder) creates the returnedTask, schedules continuations, and propagates exceptions. ExecutionContextflows automatically across awaits — that's what carriesAsyncLocal<T>,Activity.Current, and ambient transactions.- In release builds, the state machine stays a struct. In debug builds, it's boxed for richer debugging. .NET 9 reduced boxing-related allocations further on the cold path.
- Async stack traces are stitched by the runtime via
AsyncMethodBuildercooperation. ModernTaskstack traces read top-to-bottom across awaits. async voidis for event handlers only. Exceptions inasync voidcrash the process.
Concepts (deep dive)
What the compiler generates
Source:
public async Task<int> SumAsync(int a, int b)
{
var x = await ComputeAsync(a);
var y = await ComputeAsync(b);
return x + y;
}
Effective compilation (simplified):
public Task<int> SumAsync(int a, int b)
{
var sm = new SumStateMachine
{
a = a, b = b, _builder = AsyncTaskMethodBuilder<int>.Create(), state = -1
};
sm._builder.Start(ref sm);
return sm._builder.Task;
}
[StructLayout(LayoutKind.Auto)]
private struct SumStateMachine : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder<int> _builder;
public int a, b; // captured locals
public int x, y; // captured locals
private TaskAwaiter<int> _awaiter;
public void MoveNext()
{
try
{
switch (state)
{
case -1: // initial
var t1 = ComputeAsync(a);
_awaiter = t1.GetAwaiter();
if (!_awaiter.IsCompleted)
{
state = 0;
_builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return; // suspend
}
goto case 0;
case 0: // resume after first await
x = _awaiter.GetResult();
var t2 = ComputeAsync(b);
_awaiter = t2.GetAwaiter();
if (!_awaiter.IsCompleted)
{
state = 1;
_builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return; // suspend
}
goto case 1;
case 1: // resume after second await
y = _awaiter.GetResult();
_builder.SetResult(x + y);
return;
}
}
catch (Exception ex)
{
_builder.SetException(ex);
}
}
public void SetStateMachine(IAsyncStateMachine sm) => _builder.SetStateMachine(sm);
}
The pattern:
- Per-method state machine struct with one field per captured local +
state+_builder+ the current_awaiter. MoveNextis a switch onstate— entry point, then resume points after each await.AwaitUnsafeOnCompletedschedules the resumption on the awaiter's completion. It's the magic where the JIT-compiled state machine'sMoveNextis registered as the continuation.- The builder produces the returned
Task<T>—_builder.Taskis what the caller awaits.
Why a struct?
A struct stays on the stack as long as you don't await — zero allocation in the synchronously-completing path. If the awaiter is incomplete, the runtime boxes the struct (lifts it to the heap) so it survives across the suspension. After that, it lives on the heap until the method completes.
Method invoked:
┌────────────────┐
│ stack frame │
│ [state machine│ ← struct, on stack
│ struct] │
└────────────────┘
If first await is incomplete:
┌────────────────┐ ┌────────────────┐
│ stack frame │ ──────►│ heap-allocated │
│ (returning) │ │ box of state │
└────────────────┘ │ machine │
└────────────────┘
(resumes here on continuation)
This is why "fast path" async — methods that complete synchronously most of the time — are nearly free: no allocation. ValueTask makes this even more aggressive (no Task allocation either).
IAsyncStateMachine
public interface IAsyncStateMachine
{
void MoveNext();
void SetStateMachine(IAsyncStateMachine stateMachine);
}
The runtime invokes MoveNext on every step. SetStateMachine is called by the builder when boxing happens — it gives the boxed copy a way to store a reference to itself for re-boxing avoidance.
AsyncTaskMethodBuilder
The builder is the bridge between your state machine and the Task it returns:
Create()— initial builder.Start(ref state)— invokesMoveNextonce (kicks off the synchronous prefix).Task— the returnedTask<T>.AwaitOnCompleted/AwaitUnsafeOnCompleted— schedule continuation when an awaiter completes.SetResult(value)— completes the task successfully.SetException(ex)— faults the task.
AsyncValueTaskMethodBuilder<T> is the builder for ValueTask<T> returns — same shape, different return type. AsyncMethodBuilder is the no-result variant for async Task (no <T>).
C# 11+ added [AsyncMethodBuilder] so you can write custom builders — used by IAsyncEnumerable (its builder is a state machine that yields), and by hot-path libraries with custom pooling.
ExecutionContext flow
private static AsyncLocal<string?> _correlationId = new();
public async Task DoAsync()
{
_correlationId.Value = "abc";
await Task.Yield();
Console.WriteLine(_correlationId.Value); // "abc" — preserved across await
}
ExecutionContext (System.Threading.ExecutionContext) carries AsyncLocal<T> values, the current SynchronizationContext, ambient transactions, etc. The async builder's AwaitUnsafeOnCompleted captures ExecutionContext.Capture() and wraps the continuation to restore it: ExecutionContext.Run(captured, action, state).
This is what makes diagnostic propagation work seamlessly:
Activity.Current— distributed tracing context flows through awaits.AsyncLocal<MyContext>— your application's request-scoped state.- The current
CultureInfo(subject to flow flags).
ExecutionContext.SuppressFlow() can prevent capture for a scope — rarely needed in app code; some BCL internals use it for fire-and-forget scenarios.
Async stack traces
public async Task A() { await B(); }
public async Task B() { await C(); }
public async Task C() => throw new InvalidOperationException();
// When A is awaited and throws:
//
// at MyApp.C() in MyApp.cs:line 5
// at MyApp.B() in MyApp.cs:line 3 ← stitched by AsyncMethodBuilder
// at MyApp.A() in MyApp.cs:line 1 ← stitched
The async builder cooperates with Task to stitch stack traces across awaits, so you see the full async chain. This works because each MoveNext re-throws via ExceptionDispatchInfo preserving the original stack, and the builder records the await sites.
Pre-2017, async stack traces were unreadable. Modern .NET (and Visual Studio) make them clean.
Eliding async/await — when it's safe
public Task<int> Foo() => BarAsync(); // pass-through; no state machine generated
public async Task<int> Foo2() => await BarAsync(); // generates a state machine
The first version doesn't generate a state machine — saves a tiny allocation per call. But:
- Don't elide if you have a
usingortry/finallyaround the await. The state machine is what makes those work across the async boundary. - Don't elide if you need to convert exceptions — the builder catches them; pass-through doesn't.
Stephen Toub's general advice: don't elide unless you've measured a benefit. The cost is small; the bug surface from a missed elision rule is real.
async void — only for event handlers
async void doesn't return a Task, so callers can't await it. The compiler still generates a state machine. Exceptions thrown from async void are sent to SynchronizationContext.UnhandledException or AppDomain.UnhandledException — usually crashing the process.
Use only for the few event-handler signatures that require it. Everywhere else, return Task.
ConfigureAwait and the state machine
ConfigureAwait(false) returns a ConfiguredTaskAwaitable — a wrapper around the awaiter that, on OnCompleted, does not capture SynchronizationContext. The continuation runs on the threadpool, not the original sync context.
This is why library code uses ConfigureAwait(false) — it doesn't force the consumer back onto a UI/request thread. ASP.NET Core has no sync context, so app code there usually doesn't need it. See Async/Await — SyncContext & Deadlocks.
Custom awaiters
Anything with the right shape can be awaited:
public struct YieldAwaiter : INotifyCompletion
{
public bool IsCompleted => false;
public void OnCompleted(Action continuation) => Task.Run(continuation);
public void GetResult() { }
}
public static class Yield
{
public static YieldAwaiter Instance => default;
}
public static YieldAwaiter GetAwaiter(this Yield _) => Yield.Instance;
Task.Yield() is implemented this way — a custom awaiter that always says "not complete" and posts the continuation back. Use case: yield to the scheduler so others can run.
How it works under the hood
The runtime side: AsyncTaskMethodBuilder<T> is a struct with the Task<T> it'll return as a field. When boxing happens, the box is the MoveNextRunner — a wrapper that holds the state machine and an Action that calls MoveNext.
AwaitUnsafeOnCompleted performs: 1. Capture ExecutionContext (if needed). 2. Box the state machine if not already boxed. 3. Call the awaiter's UnsafeOnCompleted(action) — the awaiter (typically a Task) registers action as a continuation.
When the awaiter completes (e.g., the inner Task finishes), it calls the registered action, which runs MoveNext on the boxed state machine. The state machine resumes at the right case.
For very-hot paths, .NET 9 introduced async pool improvements — reusing state-machine boxes across multiple invocations of the same method to reduce gen0 pressure. Mostly invisible to user code.
The [AsyncMethodBuilder] attribute (C# 11+) lets you swap in a custom builder. Used by frameworks for things like: - IAsyncEnumerable<T> — a builder that yields from an iterator. - Custom pooling builders. - "AsyncTaskMethodBuilder
Code: correct vs wrong
❌ Wrong: async void on non-event-handler
✅ Correct: async Task
❌ Wrong: forgetting to await
public async Task FireAndForget()
{
DoAsync(); // ❌ returns Task; not awaited; exceptions silently lost
}
✅ Correct
public async Task DoStuff()
{
await DoAsync();
}
// If you genuinely want fire-and-forget:
public void FireAndForget()
{
_ = Task.Run(async () =>
{
try { await DoAsync(); }
catch (Exception ex) { Logger.LogError(ex, "fire-and-forget failed"); }
});
}
❌ Wrong: blocking on async with .Result in a sync context
// In a UI app or classic ASP.NET (with sync context):
var result = SomeAsync().Result; // ❌ deadlock if continuation needs the captured context
✅ Correct
❌ Wrong: catching exception OUTSIDE the await
try
{
var task = DoAsync();
return task.Result; // ❌ AggregateException
}
catch (Exception ex) { /* ... */ }
✅ Correct
Design patterns for this topic
Pattern 1 — "Always async Task, never async void"
- Intent: propagate failures; let callers compose.
- Exception: event handler signatures.
Pattern 2 — "ValueTask<T> for hot synchronous paths"
- Intent: avoid
Task<T>allocation when the result is usually available synchronously. - Code sketch: see Async/Await — ValueTask & IAsyncEnumerable Perf.
Pattern 3 — "Pass-through methods skip the state machine"
- Intent: small win —
public Task<T> X() => Y();saves anasync's state-machine generation. - When to use it: when method body is exactly one return.
- When NOT to use it: if you have
using,try/finally, or need to wrap exceptions.
Pattern 4 — "Custom awaiter for cooperative scheduling"
- Intent: post continuations to a specific scheduler.
- Code sketch:
Task.Yield()itself; or your ownIThreadPoolWorkItem-aware awaiter.
Pattern 5 — "Capture sync context with ContinueWith for advanced control"
- Intent: explicit control over where a continuation runs.
- Code sketch: rarely needed — but
Task.ContinueWith(callback, scheduler)lets you pin the continuation to a specific scheduler.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| State-machine struct | Zero allocation on sync-fast path | Box on first incomplete await |
| ExecutionContext flow | Clean propagation of async context | Heavy-state ECs hurt perf |
| Async stack stitching | Readable across awaits | Slightly heavier than legacy Task.ContinueWith |
| Custom awaiter | Cooperative scheduling | Hard to get right |
| ValueTask | Avoid Task allocation | "Await once" rule is fragile |
When to use / when to avoid
- Use
async/awaitfor any I/O-bound work. - Use
Task<T>as your default return type. - Use
ValueTask<T>in hot paths where synchronous completion is common. - Avoid
async voidexcept on event-handler signatures. - Avoid
.Result/.Wait()— sync-over-async is the #1 deadlock cause in apps with sync contexts. - Avoid eliding
async/awaitunless you've measured the win and know the rules.
Interview Q&A
Q1. What does the compiler generate for an async method? A struct implementing IAsyncStateMachine with one field per captured local + a state integer + an awaiter field + a builder. The original method body becomes a MoveNext() switch over state. The original method shell creates the builder, kicks off Start(), and returns _builder.Task.
Q2. Why is the state machine a struct? To stay on the stack when the method completes synchronously — zero allocation. The runtime boxes it only on the first incomplete await.
Q3. How does ExecutionContext flow across await? The async builder captures ExecutionContext.Capture() before suspending; on resumption, it runs MoveNext inside ExecutionContext.Run(captured, ...). This flows AsyncLocal<T>, current Activity, etc.
Q4. Why are async stack traces readable in modern .NET? The runtime stitches them via the async builder. Each await records its location; on rethrow, ExceptionDispatchInfo preserves stacks. The result is a top-to-bottom reading across the async chain.
Q5. What's the difference between AsyncTaskMethodBuilder and AsyncValueTaskMethodBuilder? The first builds a Task<T>; the second builds a ValueTask<T>. ValueTask avoids the Task allocation when the method completes synchronously.
Q6. What is [AsyncMethodBuilder] for? Lets you specify a custom builder for an async method's return type. Used by IAsyncEnumerable<T> (whose builder yields from an iterator), and by frameworks needing custom pooling or instrumentation.
Q7. Why does async void fail badly? Because it returns no Task, callers can't await failures. Exceptions thrown from async void are routed to SynchronizationContext.UnhandledException or AppDomain.UnhandledException — usually crashing the process.
Q8. What does Task.Yield() do? Returns a custom awaiter that always reports "not complete", scheduling the continuation back on the threadpool/synchronization context. Useful to break a long synchronous prefix and let other work run.
Q9. Can you await something other than Task? Yes — anything with a GetAwaiter() method returning a type with IsCompleted, OnCompleted/UnsafeOnCompleted, and GetResult. Task, ValueTask, and YieldAwaitable all qualify.
Q10. What's the cost of await when the awaitable completes synchronously? Negligible — no state-machine box, no continuation scheduling. The state machine's IsCompleted check returns true and execution falls through to the next case.
Q11. Why might ExecutionContext.SuppressFlow() matter? For fire-and-forget work where you don't want the captured context (e.g., a request-scoped AsyncLocal) to survive into the spawned task. Niche; mostly used by BCL internals.
Q12. How do you propagate Activity.Current (OpenTelemetry trace context) across Task.Run? Task.Run captures ExecutionContext by default — Activity.Current flows automatically. If you ExecutionContext.SuppressFlow() first, the activity won't propagate.
Gotchas / common mistakes
- ⚠️
async voidoutside event handlers — exceptions crash the process. - ⚠️ Ignoring returned
Task— fire-and-forget without error handling silently swallows. - ⚠️
.Result/.Wait()in code with a sync context — deadlock. - ⚠️ Mixing
awaitwithlock— compile error in modern C# (good). - ⚠️ Eliding
async/awaitaroundusing— disposes happen synchronously. - ⚠️ Capturing huge state in async closures — every captured variable becomes a state-machine field.
- ⚠️
async Taskfactory methods that throw before the firstawait— the throw becomes a faulted Task; callers mustawaitto observe.