IAsyncStateMachine, MoveNext, the state field, hoisted locals, the method builder, boxing on first suspension and the cost model of async in .NET 10.
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.
"The compiler turns an
asyncmethod into two pieces. There's a small stub with the original signature, and a generated state machine type implementingIAsyncStateMachine. The state machine holds astateinteger, a method builder chosen by the return type (for exampleAsyncTaskMethodBuilder<T>), fields for awaiters, and any locals or parameters whose lifetime crosses anawait(they're 'hoisted' into fields). The stub initialises the machine and callsbuilder.Start, which runsMoveNext()synchronously on the caller's thread.MoveNextis the original body split at eachawait, with a switch onstateto jump back in. At an incomplete await it stores the awaiter, setsstate, asks the builder to callMoveNextagain 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 theExecutionContext. So synchronously completing async methods are cheap, and each suspending call costs about one allocation plus whatever it awaits."
| 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 |
| 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) |
| 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 |
| 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 |
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.
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.
An expert-level check of compiler internals and their performance impact.
Green flags
MoveNext.dumpasync.Red flags
// 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;
}
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.
AwaitUnsafeOnCompleted doesExecutionContext into the box (once).TaskAwaiter, registers the box directly as a continuation on the awaited task (no extra delegate allocation in the common case).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.
| 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 |
dotnet-dump analyze → dumpasync walks AsyncStateMachineBox objects and their continuations to reconstruct logical async call chains, including state values.MoveNext noise.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.
| 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 |
IsValueType for the state machine type.<>1__state, the builder and MoveNext.await, check SharpLab for the new field, and benchmark allocated bytes.Task.Yield() with [MemoryDiagnoser].dumpasync.async IAsyncEnumerable<int> method and find the IValueTaskSource<bool> implementation.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;
}
}
await allocates a new objectWhat 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.
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.
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.
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.
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).
async void uses the same builder semanticsWhat 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.
asyncWhat 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.
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.
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.
ExecutionContext cost in the boxWhat 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.
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 |
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.
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.
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.
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].
Last updated 31 Oct 2026.