Skip to content

Async/Await — State Machine

Key Points

  • async/await is compiler magic: each async method becomes a generated state machine struct (<MethodName>d__N) implementing IAsyncStateMachine. The runtime drives this struct via repeated MoveNext() calls.
  • The state machine captures locals as fields so they survive across awaits.
  • The async builder (AsyncTaskMethodBuilder / AsyncValueTaskMethodBuilder) creates the returned Task, schedules continuations, and propagates exceptions.
  • ExecutionContext flows automatically across awaits — that's what carries AsyncLocal<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 AsyncMethodBuilder cooperation. Modern Task stack traces read top-to-bottom across awaits.
  • async void is for event handlers only. Exceptions in async void crash 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:

  1. Per-method state machine struct with one field per captured local + state + _builder + the current _awaiter.
  2. MoveNext is a switch on state — entry point, then resume points after each await.
  3. AwaitUnsafeOnCompleted schedules the resumption on the awaiter's completion. It's the magic where the JIT-compiled state machine's MoveNext is registered as the continuation.
  4. The builder produces the returned Task<T>_builder.Task is 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) — invokes MoveNext once (kicks off the synchronous prefix).
  • Task — the returned Task<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 using or try/finally around 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

private async void OnClick(object? sender, EventArgs e)
{
    await DoAsync();
}

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

await foo.ConfigureAwait(false);

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" overrides for testing or instrumentation.


Code: correct vs wrong

❌ Wrong: async void on non-event-handler

public async void DoStuff()      // ❌ caller can't await; exceptions crash process
{
    await DoAsync();
}

✅ Correct: async Task

public async Task DoStuff()
{
    await DoAsync();
}

❌ 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

var result = await SomeAsync();   // make the caller async

❌ Wrong: catching exception OUTSIDE the await

try
{
    var task = DoAsync();
    return task.Result;   // ❌ AggregateException
}
catch (Exception ex) { /* ... */ }

✅ Correct

try
{
    return await DoAsync();   // unwrapped exception
}
catch (Exception ex) { /* ... */ }

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"

Pattern 3 — "Pass-through methods skip the state machine"

  • Intent: small win — public Task<T> X() => Y(); saves an async'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 own IThreadPoolWorkItem-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/await for 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 void except on event-handler signatures.
  • Avoid .Result / .Wait() — sync-over-async is the #1 deadlock cause in apps with sync contexts.
  • Avoid eliding async/await unless 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 void outside 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 await with lock — compile error in modern C# (good).
  • ⚠️ Eliding async/await around using — disposes happen synchronously.
  • ⚠️ Capturing huge state in async closures — every captured variable becomes a state-machine field.
  • ⚠️ async Task factory methods that throw before the first await — the throw becomes a faulted Task; callers must await to observe.

Further reading