📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What does the compiler generate for an async method? Explain the async state machine.

IAsyncStateMachine, MoveNext, the state field, hoisted locals, the method builder, boxing on first suspension and the cost model of async in .NET 10.

Senior Theory Expert async compiler performance state-machine

🎯 Key points

  • The compiler generates a stub and an IAsyncStateMachine type with MoveNext
  • A state integer records which await to resume from; -1 running, -2 finished
  • Locals used across awaits are hoisted into fields
  • The builder type depends on the return type and produces the task
  • In Release the machine is a struct boxed only on first suspension
  • Synchronously completing async methods avoid the box
  • Runtime async is an experimental alternative, not the .NET 10 default

⏱ 30-second answer

The compiler turns an async method into a small stub plus a generated type implementing IAsyncStateMachine. The type holds a state integer, a method builder (such as AsyncTaskMethodBuilder<T>), awaiter fields and any locals that live across an await. The stub initialises it and calls builder.Start, which runs MoveNext synchronously. MoveNext is a switch on state: it runs until an incomplete await, stores the awaiter, sets state, and asks the builder to call MoveNext again on completion. In Release builds the machine is a struct that is only boxed to the heap the first time the method actually suspends, so synchronously completing async methods are cheap.

🗣 Full answer (senior)

How I would answer this in an interview

"The compiler turns an async method into two pieces. There's a small stub with the original signature, and a generated state machine type implementing IAsyncStateMachine. The state machine holds a state integer, a method builder chosen by the return type (for example AsyncTaskMethodBuilder<T>), fields for awaiters, and any locals or parameters whose lifetime crosses an await (they're 'hoisted' into fields). The stub initialises the machine and calls builder.Start, which runs MoveNext() synchronously on the caller's thread. MoveNext is the original body split at each await, with a switch on state to jump back in. At an incomplete await it stores the awaiter, sets state, asks the builder to call MoveNext again on completion, and returns. In Release builds the machine is a struct and is only boxed onto the heap the first time the method actually suspends; that box also is the returned task and carries the ExecutionContext. So synchronously completing async methods are cheap, and each suspending call costs about one allocation plus whatever it awaits."


1. What gets generated

Piece Contents
Stub method Same signature; creates the state machine, sets state = -1, creates the builder, calls builder.Start(ref sm), returns builder.Task
State machine type struct in Release, class in Debug (for the debugger)
<>1__state -1 running / not started, 0..n suspended at await n, -2 finished
<>t__builder Builder for the return type
Hoisted fields this, parameters and locals used after an await
Awaiter fields (<>u__1, ...) One per distinct awaiter type
MoveNext() Body split at awaits, wrapped in try/catch that calls SetException
[AsyncStateMachine(typeof(...))] Attribute on the stub pointing at the generated type

2. The flow

Step What happens
1 Caller calls the stub → builder.Start → MoveNext runs synchronously
2 At await: GetAwaiter(); if IsCompleted → GetResult() and continue
3 If not complete: save awaiter, set state = n, builder.AwaitUnsafeOnCompleted(ref awaiter, ref this), return
4 First suspension only: builder boxes the struct into an AsyncStateMachineBox<TStateMachine> (which derives from Task<T> and holds the ExecutionContext)
5 Stub returns builder.Task, the box itself, to the caller
6 Awaited operation completes → box's MoveNext runs (on context or pool) → switch on state → GetResult() → continue
7 return x → builder.SetResult(x); exception → builder.SetException(ex)

3. Builders by return type

Return type Builder
Task / Task<T> AsyncTaskMethodBuilder / AsyncTaskMethodBuilder<T>
ValueTask / ValueTask<T> AsyncValueTaskMethodBuilder / <T>
Pooled ValueTask<T> PoolingAsyncValueTaskMethodBuilder<T> (.NET 6+, opt-in per method)
void AsyncVoidMethodBuilder (notifies SynchronizationContext; exceptions rethrown there)
IAsyncEnumerable<T> AsyncIteratorMethodBuilder + a combined iterator/state machine class

4. Cost model

Case Allocations
Completes synchronously, returns Task None for the box; Task.CompletedTask reused
Completes synchronously, returns Task<T> Task allocated unless the value is one the runtime caches
Completes synchronously, returns ValueTask<T> None
Suspends at least once One box (state machine + task + context), reused for later awaits in the same call
Large struct locals live across awaits Box grows by their size

5. Runtime async (awareness)

The .NET team has been building "runtime async", where the runtime/JIT handles suspension and resumption instead of compiler-generated state machines, aiming at lower overhead and better stack traces. In .NET 10 it's an opt-in experimental/preview feature; production code uses the compiler model above. Worth mentioning to show you follow the platform, not something to depend on yet.

Production example

Our [request-routing middleware] ran at [30,000 requests/second], and an allocation profile showed unusually large AsyncStateMachineBox objects coming from it, with a high Gen 0 GC rate. Decompiling the method with ILSpy showed a large routing struct local had been hoisted into the state machine because it was still used after an await. I moved the post-await logic into a separate method that took just the two small values it needed, so the struct was no longer live across the suspension point. Allocated bytes per request dropped by [about a third], and I added a BenchmarkDotNet memory benchmark for the middleware to CI.

🔍 What the interviewer is checking

An expert-level check of compiler internals and their performance impact.

Green flags

  • Describes the state field, builder, hoisted locals and MoveNext.
  • Knows struct versus class, and boxing on first suspension.
  • Connects builders to return types.
  • Can link it to diagnostics such as dumpasync.

Red flags

  • "The compiler creates a thread per await."
  • No idea where locals live across awaits.

📚 Learn in depth

1. A simplified state machine

// async Task<int> GetAsync() { int a = await A(); int b = await B(a); return a + b; }
[CompilerGenerated]
private struct GetAsyncSM : IAsyncStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder<int> builder;
    private int a;                    // hoisted: used after the second await
    private TaskAwaiter<int> awaiter; // reused for both awaits (same awaiter type)

    public void MoveNext()
    {
        int result;
        try
        {
            TaskAwaiter<int> aw;
            switch (state)
            {
                case 0: aw = awaiter; awaiter = default; state = -1; goto ResumeA;
                case 1: aw = awaiter; awaiter = default; state = -1; goto ResumeB;
            }
            aw = A().GetAwaiter();
            if (!aw.IsCompleted) { state = 0; awaiter = aw; builder.AwaitUnsafeOnCompleted(ref aw, ref this); return; }
        ResumeA:
            a = aw.GetResult();
            aw = B(a).GetAwaiter();
            if (!aw.IsCompleted) { state = 1; awaiter = aw; builder.AwaitUnsafeOnCompleted(ref aw, ref this); return; }
        ResumeB:
            result = a + aw.GetResult();
        }
        catch (Exception ex) { state = -2; builder.SetException(ex); return; }
        state = -2;
        builder.SetResult(result);
    }

    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine sm) => builder.SetStateMachine(sm);
}

// Stub
public static Task<int> GetAsync()
{
    var sm = new GetAsyncSM { builder = AsyncTaskMethodBuilder<int>.Create(), state = -1 };
    sm.builder.Start(ref sm);
    return sm.builder.Task;
}

2. Boxing on first suspension

Call 1 (completes synchronously):  struct on the stack -> no heap box
Call 2 (suspends):                 struct copied into AsyncStateMachineBox<GetAsyncSM> on the heap
                                   box : Task<int>   <- returned to the caller
                                   box.StateMachine  <- the struct, now on the heap
                                   box.Context       <- captured ExecutionContext
                                   later awaits in the same call reuse this box

Because the box is the task, an async method that suspends allocates one object for both the task and the state machine.

3. What AwaitUnsafeOnCompleted does

  • Ensures the box exists (first time).
  • Captures ExecutionContext into the box (once).
  • For TaskAwaiter, registers the box directly as a continuation on the awaited task (no extra delegate allocation in the common case).
  • Respects SynchronizationContext / TaskScheduler capture (or not, with ConfigureAwait(false)).

OnCompleted (non-"unsafe") would flow ExecutionContext itself; the builder uses the unsafe version because it already manages context flow.

4. Hoisting rules

Variable Hoisted?
Local used only before the first await No
Local used after an await Yes
this (instance method) Yes, if used after an await
Parameters used after an await Yes
using variables spanning awaits Yes
Span<T> / ref struct locals Cannot be live across an await (compile error); C# 13 allows them only between awaits

5. Reading async in dumps and traces

  • Because suspended methods aren't on any thread's stack, a normal stack dump shows almost nothing.
  • dotnet-dump analyze → dumpasync walks AsyncStateMachineBox objects and their continuations to reconstruct logical async call chains, including state values.
  • Visual Studio's Parallel Stacks → Tasks view does the same in a debugger.
  • Since .NET Core 2.1, exception stack traces are cleaned up to hide MoveNext noise.

6. Async iterators

async IAsyncEnumerable<T> methods generate a class that implements IAsyncEnumerable<T>, IAsyncEnumerator<T>, IAsyncDisposable and IValueTaskSource<bool>, using ManualResetValueTaskSourceCore<bool> so MoveNextAsync can return a reusable ValueTask<bool> without allocating per item.

7. Version notes

Version Change
C# 5 / .NET 4.5 State machines (class in early versions, then struct in Release)
.NET Core 2.1 AsyncStateMachineBox: one allocation for task + state machine; cleaner async stack traces
.NET Core 3.0 / C# 8 Async iterators
.NET 6 / C# 10 PoolingAsyncValueTaskMethodBuilder, method-level [AsyncMethodBuilder]
C# 13 ref/ref struct locals in async methods (not across awaits)
.NET 10 Runtime async available as an experimental opt-in

8. Try it yourself

  1. Run the code example in Debug and Release and compare IsValueType for the state machine type.
  2. Paste the method into SharpLab (C# view, Release) and find <>1__state, the builder and MoveNext.
  3. Add a large struct local used after the second await, check SharpLab for the new field, and benchmark allocated bytes.
  4. Benchmark an async method that always completes synchronously vs one that awaits Task.Yield() with [MemoryDiagnoser].
  5. Take a dump of an app with many pending async calls and run dumpasync.
  6. Decompile an async IAsyncEnumerable<int> method and find the IValueTaskSource<bool> implementation.

💻 Code

using System.Runtime.CompilerServices;

// Observe the state machine type at run time.
Task<int> t = Sample.GetAsync();
Console.WriteLine(await t);

// The attribute the compiler adds to the stub points at the generated type.
var attr = typeof(Sample).GetMethod(nameof(Sample.GetAsync))!
    .GetCustomAttributes(typeof(AsyncStateMachineAttribute), false)
    .Cast<AsyncStateMachineAttribute>()
    .Single();
Console.WriteLine($"State machine type: {attr.StateMachineType.Name}, struct: {attr.StateMachineType.IsValueType}");
// Release build: struct = True. Debug build: struct = False (class, for debugging).

static class Sample
{
    public static async Task<int> GetAsync()
    {
        int a = await StepAsync(1); // suspension point 0
        int b = await StepAsync(a); // suspension point 1
        return a + b;
    }

    private static async Task<int> StepAsync(int x)
    {
        await Task.Delay(10);
        return x + 1;
    }
}

⚠ Common mistakes

1. Thinking each await allocates a new object

What happens: developers avoid awaits "to save allocations". Why it is wrong: the box is created once per call on first suspension; later awaits reuse it. Do instead: count one box per suspending call plus the awaited operation's allocations.

2. Measuring allocations in Debug builds

What happens: benchmarks run in Debug show allocations everywhere. Why it is wrong: Debug uses a class state machine that always allocates. Do instead: measure in Release with BenchmarkDotNet.

3. Keeping large structs or buffers live across awaits

What happens: a big struct local is used after an await on a hot path. Why it is wrong: it's hoisted into the heap box, increasing per-call memory. Do instead: narrow scope; pass only what's needed past the await.

4. Hand-writing state machines in application code

What happens: "optimised" manual continuation code. Why it is wrong: error-prone, hard to maintain, rarely faster than the compiler. Do instead: ValueTask, splitting fast/slow paths, or pooling builders where measured.

5. Expecting synchronous stack traces to show async callers

What happens: engineers look at a thread dump and see no async methods. Why it is wrong: suspended methods live in heap boxes, not on thread stacks. Do instead: use dumpasync or Parallel Stacks (Tasks view).

6. Believing async void uses the same builder semantics

What happens: async void is treated like async Task without a return value. Why it is wrong: AsyncVoidMethodBuilder has no task; exceptions are rethrown on the context or pool and crash the process. Do instead: return Task except for event handlers.

7. Wrapping every small method in async

What happens: pass-through methods get async/await "for consistency" on extremely hot paths. Why it is wrong: each adds a state machine and potential box. Do instead: elide only for trivial pass-throughs without using/try; otherwise keep async for correctness.

8. Assuming the state machine runs on another thread from the start

What happens: code before the first await is expected to run in the background. Why it is wrong: builder.Start runs MoveNext synchronously on the caller's thread. Do instead: remember the synchronous prefix runs on the caller.

9. Depending on runtime async in production

What happens: a team enables the experimental feature for performance. Why it is wrong: it's preview/experimental in .NET 10; behaviour and support may change. Do instead: track it, test it, don't depend on it yet.

10. Ignoring ExecutionContext cost in the box

What happens: huge AsyncLocal graphs are set per request. Why it is wrong: the context is captured into every suspending box, keeping that data alive. Do instead: keep ambient data small and immutable.

🏛 Senior insight: trade-offs

Knowing the state machine lets me reason about cost and read async stack traces and dumps. I do not optimise it by hand; I use it to explain why synchronous completion is cheap, why locals across awaits cost memory, and why async void behaves differently.

Fact Practical consequence
Struct in Release, boxed on first suspend Synchronous completion is cheap
Locals across await are fields Narrow scope on hot paths
Builder chosen by return type ValueTask and pooling change allocation
ExecutionContext stored in the box AsyncLocal flows across awaits
Runtime async is experimental Know it exists; do not depend on it in .NET 10

🔗 Follow-ups the interviewer may ask

  1. When: Candidate describes MoveNext resuming Why: Checks the await sequence
    How to answer

    async tells the compiler to rewrite the method into a state machine and lets you use await inside it; it does not create a thread. At await x, the code calls x.GetAwaiter() and checks IsCompleted. If it is already complete, execution continues synchronously. If not, the method registers the rest of itself as a continuation, captures the current SynchronizationContext (or non-default TaskScheduler), and returns an incomplete task to its caller, releasing the thread. When the awaited work finishes, the continuation resumes, on the captured context if there was one, otherwise on the ThreadPool. Exceptions and results flow through the returned task.

  2. When: Candidate mentions builders and allocations Why: Probes ValueTask trade-offs
    How to answer

    ValueTask<T> is a struct that holds either a result, a Task<T> or an IValueTaskSource<T>. It avoids allocating a task when a method usually completes synchronously (cache hits, buffered reads) and lets libraries pool the async case. The cost is stricter rules: await it once, never await it concurrently, and never read .Result before it completes; call .AsTask() if you need any of that. Use Task by default and choose ValueTask for measured hot paths or when an interface already uses it, like Stream.ReadAsync(Memory<byte>) or IAsyncDisposable.DisposeAsync.

  3. When: Candidate mentions lowering of other constructs Why: Connects to general compiler lowering
    How to answer

    Roslyn parses C# into syntax trees, binds names and types into a semantic model, then lowers high-level constructs into simpler ones before emitting IL. foreach over an array becomes an index loop; over `List<T>` it uses the struct enumerator in a try/finally; using becomes try/finally with Dispose; lock becomes Monitor.Enter(obj, ref lockTaken) / Monitor.Exit (or Lock.EnterScope() for System.Threading.Lock); a + b + c on strings becomes one string.Concat; a switch on strings may become a length or hash-based jump. async methods and iterators become state machines. Roslyn does little optimisation; the JIT does the heavy work.

🛠 Real-world problems: how you handle them

Large hoisted struct inflating async allocations
Situation
A [request-routing middleware] at [30,000 requests per second] held a large routing struct in a local across an `await`.
Signals
Allocation profile showed large `AsyncStateMachineBox` objects from the middleware; gen0 GC rate was high.
Investigation
Decompiling with ILSpy showed the struct hoisted into the state machine because it was used after the await.
Resolution
Extracted the post-await work into a method that took only the two needed fields, so the struct was no longer live across the await.
Prevention
Added a BenchmarkDotNet memory benchmark for the middleware.

How to tell it (STAR)

In our [routing middleware], profiling showed large async boxes. I decompiled the method and saw a big struct hoisted because it was used after an await. I restructured the code so only two small values crossed the await, which cut allocated bytes per request by [about a third].

↩ Usually asked after

More in Tasks

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me