the source
Two statements and an await. One local is used before the await and again after it, which is the
only interesting thing about it.
public static async Task<int> FetchAsync(int id)
{
int local = id * 2;
int fetched = await StepAsync(local);
return fetched + local;
}
static async Task<int> StepAsync(int x) { await Task.Delay(1); return x + 1; }Nothing below is transcribed by hand. bench/threads-and-scheduling/async-state-machine.cs reads
its own assembly at run time: the [AsyncStateMachine] attribute names the type Roslyn generated,
reflection lists its fields, MethodBody.GetILAsByteArray returns the bytes Roslyn wrote, and a
small decoder walks them against the runtime’s own opcode table. Run it with
dotnet run bench/threads-and-scheduling/async-state-machine.cs -c Release.
what the machine got
First, the type that appeared out of nowhere:
=== 1. the type Roslyn generated for FetchAsync ===
[AsyncStateMachine(typeof(<FetchAsync>d__0))]
full name : Machine+<FetchAsync>d__0
kind : struct, private nested, implements IAsyncStateMachine
fields :
int32 <>1__state
AsyncTaskMethodBuilder`1 <>t__builder
int32 id
int32 <local>5__2
TaskAwaiter`1 <>u__1
method : MoveNext (171 bytes of IL)
method : SetStateMachine (13 bytes of IL)
Then what is left of FetchAsync itself — the body is gone; this is a constructor call:
.method Machine::FetchAsync // 55 bytes of IL, 1 locals
.locals [0] <FetchAsync>d__0
IL_0000: ldloca.s 0
IL_0002: call AsyncTaskMethodBuilder`1::Create
IL_0007: stfld <FetchAsync>d__0::<>t__builder
IL_000c: ldloca.s 0
IL_000e: ldarg.0
IL_000f: stfld <FetchAsync>d__0::id
IL_0014: ldloca.s 0
IL_0016: ldc.i4.m1
IL_0017: stfld <FetchAsync>d__0::<>1__state
IL_001c: ldloca.s 0
IL_001e: ldflda <FetchAsync>d__0::<>t__builder
IL_0023: ldloca.s 0
IL_0025: call AsyncTaskMethodBuilder`1::Start
IL_002a: ldloca.s 0
IL_002c: ldflda <FetchAsync>d__0::<>t__builder
IL_0031: call AsyncTaskMethodBuilder`1::get_Task
IL_0036: ret
And the method your body actually became. All 171 bytes, nothing removed:
.method <FetchAsync>d__0::MoveNext // 171 bytes of IL, 4 locals
.locals [0] int32
.locals [1] int32
.locals [2] TaskAwaiter`1
.locals [3] Exception
IL_0000: ldarg.0
IL_0001: ldfld <FetchAsync>d__0::<>1__state
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: brfalse.s IL_0052
IL_000a: ldarg.0
IL_000b: ldarg.0
IL_000c: ldfld <FetchAsync>d__0::id
IL_0011: ldc.i4.2
IL_0012: mul
IL_0013: stfld <FetchAsync>d__0::<local>5__2
IL_0018: ldarg.0
IL_0019: ldfld <FetchAsync>d__0::<local>5__2
IL_001e: call Machine::StepAsync
IL_0023: callvirt Task`1::GetAwaiter
IL_0028: stloc.2
IL_0029: ldloca.s 2
IL_002b: call TaskAwaiter`1::get_IsCompleted
IL_0030: brtrue.s IL_006e
IL_0032: ldarg.0
IL_0033: ldc.i4.0
IL_0034: dup
IL_0035: stloc.0
IL_0036: stfld <FetchAsync>d__0::<>1__state
IL_003b: ldarg.0
IL_003c: ldloc.2
IL_003d: stfld <FetchAsync>d__0::<>u__1
IL_0042: ldarg.0
IL_0043: ldflda <FetchAsync>d__0::<>t__builder
IL_0048: ldloca.s 2
IL_004a: ldarg.0
IL_004b: call AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompleted
IL_0050: leave.s IL_00aa
IL_0052: ldarg.0
IL_0053: ldfld <FetchAsync>d__0::<>u__1
IL_0058: stloc.2
IL_0059: ldarg.0
IL_005a: ldflda <FetchAsync>d__0::<>u__1
IL_005f: initobj TaskAwaiter`1
IL_0065: ldarg.0
IL_0066: ldc.i4.m1
IL_0067: dup
IL_0068: stloc.0
IL_0069: stfld <FetchAsync>d__0::<>1__state
IL_006e: ldloca.s 2
IL_0070: call TaskAwaiter`1::GetResult
IL_0075: ldarg.0
IL_0076: ldfld <FetchAsync>d__0::<local>5__2
IL_007b: add
IL_007c: stloc.1
IL_007d: leave.s IL_0096
IL_007f: stloc.3
IL_0080: ldarg.0
IL_0081: ldc.i4.s -2
IL_0083: stfld <FetchAsync>d__0::<>1__state
IL_0088: ldarg.0
IL_0089: ldflda <FetchAsync>d__0::<>t__builder
IL_008e: ldloc.3
IL_008f: call AsyncTaskMethodBuilder`1::SetException
IL_0094: leave.s IL_00aa
IL_0096: ldarg.0
IL_0097: ldc.i4.s -2
IL_0099: stfld <FetchAsync>d__0::<>1__state
IL_009e: ldarg.0
IL_009f: ldflda <FetchAsync>d__0::<>t__builder
IL_00a4: ldloc.1
IL_00a5: call AsyncTaskMethodBuilder`1::SetResult
IL_00aa: ret
ldarg.0 in MoveNext is this — the state machine — so almost every pair of lines is “load
this, touch one of its fields”. That is why the listing looks so repetitive: your locals are
fields now, and IL has no way to say “field of this” in one instruction.
line by line
The generated fields first, because everything else refers to them:
| field | what it is | where it came from |
|---|---|---|
<>1__state |
which await we are suspended at: -1 not suspended, 0 at the first await, -2 finished |
the compiler’s bookkeeping |
<>t__builder |
AsyncTaskMethodBuilder<int> — owns the returned Task and the resume plumbing |
the async Task<int> in the signature |
id |
the parameter, copied in before anything runs | FetchAsync(int id) |
<local>5__2 |
local, promoted to a field because it is read after the await |
int local = id * 2; |
<>u__1 |
the TaskAwaiter<int> being waited on, parked here while suspended |
await StepAsync(local) |
Notice what is not there: fetched. It is written and read entirely after the resume point, so
it never has to survive a suspension — and in Release it does not even get a slot: the value
GetResult() returns is added straight to local on the evaluation stack at IL_0070–IL_007b.
(In Debug, where nothing is optimised, it is hoisted, which is why that build has seven fields
including <fetched>5__2.) The compiler promotes exactly the variables whose lifetime crosses an
await, which is the same rule that decides what a lambda’s closure captures on
stack vs heap.
The stub that is left of FetchAsync:
| IL | what it does | source |
|---|---|---|
IL_0002 call Create / stfld <>t__builder |
build the thing that will own the returned Task |
the async Task<int> signature |
IL_000e ldarg.0 / stfld id |
copy the parameter into the state machine | FetchAsync(int id) |
IL_0016 ldc.i4.m1 / stfld <>1__state |
state = −1: created, never suspended | — |
IL_0025 call Start |
run MoveNext synchronously, on this thread, until it suspends or finishes |
the first line of the body |
IL_0031 call get_Task / ret |
hand the caller the Task |
the method’s return type |
Nothing here is asynchronous. Calling an async method runs its body on your thread until the
first await that is not already complete. But it runs it inside the catch at IL_007f in the
listing above, so a throw before the first await does not reach your call site — it faults the
returned Task, and surfaces only when somebody awaits it. That is exactly why eager argument
validation is written in a non-async wrapper that calls an async local function: a plain
method’s throw propagates at the call. The half that does hold is the blocking half — an expensive loop before the first await
runs on the caller’s thread no matter how many async keywords surround it.
MoveNext, in the order the CPU meets it:
| IL | what it does | source line |
|---|---|---|
IL_0000-0008 |
load <>1__state, and if it is 0 jump to IL_0052 — the resume path |
the compiler’s switch |
IL_000a-0013 |
id * 2 stored into the field <local>5__2 |
int local = id * 2; |
IL_0018-0023 |
call StepAsync(local), then GetAwaiter() on the Task it returned |
await StepAsync(local) |
IL_0029-0030 |
if (awaiter.IsCompleted) goto IL_006e — the fast path: no suspension at all |
await, when the result is already there |
IL_0032-0036 |
state = 0: “suspended at await number zero” |
await, slow path |
IL_003b-003d |
park the awaiter in the field <>u__1, because this frame is about to vanish |
await, slow path |
IL_0043-004b |
AwaitUnsafeOnCompleted(ref awaiter, ref this) — register “call MoveNext when done” |
await, slow path |
IL_0050 leave.s IL_00aa |
return. The thread is free; the method is not finished | await, slow path |
IL_0052-0058 |
resume: take the awaiter back out of <>u__1 |
resuming after await |
IL_0059-005f |
initobj on the field — clear it, so the completed Task is not kept alive |
resuming after await |
IL_0065-0069 |
state = -1: running again |
resuming after await |
IL_006e-0070 |
awaiter.GetResult() — the value, or the exception rethrown here |
the value of the await expression |
IL_0075-007c |
add <local>5__2 to it, into the local that becomes the result |
return fetched + local; |
IL_007f-008f |
the catch: state = -2, builder.SetException(e) — the Task faults, nothing is thrown to the caller |
every line of the body |
IL_0096-00a5 |
state = -2 (finished), builder.SetResult(result) — the Task completes and its continuations run |
return |
Both exits — SetResult and SetException — set the state to -2 first. There is no third exit.
An async method never throws to its caller after the first suspension; it completes a Task
either way, and the exception is stored in the Task until somebody looks at it.
why it looks like that
The shape is forced by one fact: a frame does not survive await. IL_0050 is a ret. The
stack frame that held local is gone the instant it executes, and something has to call MoveNext
again minutes later. This is the Release build’s path — where the state machine starts out as a
struct living inside the caller’s own frame (the why a struct in Release paragraph below says
why) and only moves to the heap at the moment it actually has to suspend:
before the first await after the suspending await returns
caller's stack caller's stack
┌─────────────────────┐ ┌─────────────────────┐
│ FetchAsync frame │ │ (gone — FetchAsync │
│ id = 4 │ suspend │ already returned │
│ local = 8 │ ────────► │ a Task to it) │
│ ... │ └─────────────────────┘
└─────────────────────┘
the heap
┌─────────────────────┐
│ <FetchAsync>d__0 │
│ <>1__state = 0 │
│ id = 4 │
│ <local>5__2 = 8 │
│ <>u__1 = {awaiter} │
└─────────────────────┘
registered as the
awaiter's continuation
id and local do not move or get copied at the moment of suspension — they were fields of the
state-machine object from the start, so there is nothing left on the stack to lose. What changes is
that the stack frame around them disappears while the object holding them lives on. Everything
else follows from that one fact:
- Locals that cross the await become fields, because fields live in an object and objects outlive frames.
- The method becomes a switch on a state field, because “resume in the middle” is not something a machine can do; you can only re-enter at the top and jump.
- The awaiter is stored rather than kept on the evaluation stack, for the same reason.
- The method returns a
Taskbuilt by a builder, because the caller needs something to hold on to and the result does not exist yet.
Why a struct in Release and a class in Debug. The same file, built the other way, generates
a different type:
Release (-c Release) |
Debug (dotnet run file.cs) |
|
|---|---|---|
| generated type | struct |
class |
| fields | 5 | 7 (fetched and a temporary are hoisted too) |
MoveNext |
171 bytes of IL | 208 bytes |
SetStateMachine |
13 bytes | 1 byte |
The struct is the optimisation: while the method never suspends, the state machine lives in the
caller’s frame and costs nothing. The moment it does suspend, AwaitUnsafeOnCompleted boxes it
onto the heap — that is what SetStateMachine is for, telling the copy where its new home is.
Debug uses a class unconditionally so the debugger can watch fields at a stable address, which is
why the byte counts below differ by more than a rounding error between the two columns — Debug
is not a scaled-down version of the same program — it allocates a different shape, not merely more of it.
That design shows up directly in the allocation counter (GC.GetAllocatedBytesForCurrentThread,
marginal bytes per call, exact rather than sampled — bench/threads-and-scheduling/async-state-machine.cs):
| what the method does | Release | Debug |
|---|---|---|
| plain synchronous method | 0 B | 0 B |
async Task<int>, awaits an already-completed task |
72 B | 120 B |
async ValueTask<int>, awaits an already-completed task |
0 B | 56 B |
async Task<int>, actually suspends |
103 B | 128 B |
async ValueTask<int>, actually suspends |
112 B | 136 B |
Task.Run(() => ...) capturing a local |
160 B | 160 B |
Read the table against the IL and the design falls out. An await that does not suspend costs,
for a ValueTask, zero bytes — the IsCompleted branch at IL_0030 skips everything, so
nothing is ever allocated for the state machine at all. An await that does suspend costs 103
bytes for Task<int>, which is the state-machine object boxed onto the heap plus its captured
continuation. Task.Run costs more (160 bytes, the last row of the table) for the same underlying
reason — something has to be parked on the heap for a callback to find later — plus the delegate
closure and the queued work item wrapping it, which the plain await case does not need.
ValueTask<T> earns its zero only on the synchronous path, and the suspending row shows the price
when it is wrong: 112 bytes versus Task’s 103, because a ValueTask that has to suspend
allocates a box anyway and gets no help from the runtime’s cache of completed Task objects. Use
it where the common case is “the value is already here” — a cache hit, a buffered read — and not
as a blanket replacement.
what this looks like in prod
Async stack traces are not the mess they were. Three async frames deep, throwing after the
await — so that every frame in the trace has already returned once — and the runtime still
reconstructs it (bench/threads-and-scheduling/async-stack-trace.cs):
System.InvalidOperationException: boom
at Program.<<Main>$>g__Level3|0_0(Int32 x) in .../async-stack-trace.cs:line 6
at Program.<<Main>$>g__Level2|0_1(Int32 x) in .../async-stack-trace.cs:line 7
at Program.<<Main>$>g__Level1|0_2(Int32 x) in .../async-stack-trace.cs:line 8
at Program.<Main>$(String[] args) in .../async-stack-trace.cs:line 10
--- the same failure, reached through .Result ---
AggregateException: One or more errors occurred. (boom)
inner: InvalidOperationException: boom
The first trace is the honest one: it names your methods and your line numbers, because the runtime
walks the chain of continuations to rebuild it. What broke it is the second block — blocking with
.Result wraps the real exception in an AggregateException, so every catch (InvalidOperationException)
above it stops matching. await unwraps and rethrows the original; .Result and .Wait() do not.
That is a second, quieter reason not to block, on top of
the starvation.
Where the state machine shows up in a memory profile. Search a dump for type names containing
d__ and you are looking at suspended async methods — one object per in-flight operation, each
holding every local that crosses its await. A leak of them means operations that never complete: a
TaskCompletionSource nobody ever sets, a CancellationToken that never fires, an unbounded queue
of pending work. And because each one holds your locals, one leaked state machine can pin a whole
object graph — a request context, a buffer, a DbContext — which is a
GC problem caused by an async bug.
The practical consequences of “your locals became heap fields”: a ref struct such as
Span<T> cannot cross an await (error CS4007 — there is nowhere to put it), a stackalloc
buffer cannot either, and a local that crosses an await is genuinely shared state that another
thread can reach. It is no longer thread-private just because it was declared inside a method.
the same idea in other languages
| language | what it’s called | the trap |
|---|---|---|
| Python | the same transform: an async def becomes a coroutine object with a resume point, driven by send() from the event loop |
the coroutine object does nothing until it is awaited or scheduled — calling an async def and dropping the result runs no code at all, whereas C# runs the body up to the first await immediately. Opposite defaults, same syntax |
| JavaScript | async/await over promises, transformed the same way (and visibly so in any transpiler’s output) |
an async function also starts running synchronously up to the first await, like C# — but there is one thread, so a long synchronous prologue freezes the whole process instead of one pool thread |
| Rust | async fn compiles to a generated enum implementing Future, one variant per suspension point — the same state field, with the type system tracking it |
Rust’s futures are lazy: nothing runs until an executor polls them, so an un-awaited future is dead code rather than work in flight. And the compiler makes “does this local cross an await” a visible, checked property, which is the same rule C# applies silently |
| C++ | C++20 coroutines: co_await and a compiler-generated frame, with a promise type you write yourself |
the coroutine frame is heap-allocated by default and the language gives you no scheduler at all — you supply the thing that resumes it, which is the job .NET’s thread pool is quietly doing in AwaitUnsafeOnCompleted |
common bugs
- Believing an async method starts on another thread.
IL_0025 call StartrunsMoveNextsynchronously on the calling thread. Everything before the first suspendingawaitis your caller’s time, including a slow validation loop or a file read you forgot was synchronous. async void. There is no builder-owned Task to put an exception in, soSetExceptionhas nowhere to go and the runtime rethrows it on the pool — an unhandled exception that kills the process rather than faulting a Task somebody could catch. Event handlers are the only legitimate use.- Judging async’s cost from a Debug build. The state machine changes from a struct to a class, so a method that allocates 0 bytes in Release allocates 56 in Debug — any conclusion about “how expensive async is” drawn from an unoptimized build is a conclusion about the debugger’s build, not about the code that will actually ship.
- Adding
async/awaitto a method that only forwards.return await Foo();builds a whole state machine to do whatreturn Foo();does for free — 72 to 103 bytes per call, for nothing. (Keep theawaitwhen you need the exception inside your owntry, or when ausingmust outlive the call; drop it otherwise.) - Assuming
ValueTaskis a free upgrade. It is 0 bytes when it completes synchronously and more expensive thanTaskwhen it does not, and it comes with rulesTaskdoes not have: aValueTaskmay be awaited only once, and must not be blocked on with.Result. - Capturing something huge in a local that crosses an
await. It becomes a field of a heap object that lives until the operation completes, so a 4 MB buffer declared before anawaitis 4 MB pinned for the duration of the I/O, times your concurrency.