.NET has two famous compiler rewrites: async/await becomes a state machine, and yield return becomes an iterator. Async iterators — async IAsyncEnumerable<T> with await foreach on the consuming side — are what happens when you use both in one method. The compiler can't pick one rewrite; it generates a single state machine that is both an async state machine and an iterator at once. Understanding that fusion is the key to reading a decompiled await foreach, and it ties together the async state machine and the yield iterator patterns you may already know.
The method that awaits and yields
Here is a textbook async iterator — it awaits I/O and yields results as they arrive:
public async IAsyncEnumerable<int> ReadBatchesAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
for (int page = 0; ; page++)
{
var batch = await FetchPageAsync(page, ct); // suspend on a Task
if (batch.Count == 0)
yield break;
foreach (var item in batch)
yield return item; // suspend on the consumer
}
}
Two different suspensions live in one method. await parks until a task completes; yield return parks until the consumer calls for the next element. A plain async method only has the first; a plain iterator only has the second. An async iterator has to handle both — and it does so with one state field and one dispatch loop.
The fused state machine
Decompiled without recognition, ReadBatchesAsync is a stub that constructs a generated struct and returns it. That struct — conventionally <ReadBatchesAsync>d__0 — implements the whole async and iterator surface:
[CompilerGenerated]
private sealed class <ReadBatchesAsync>d__0 :
IAsyncStateMachine, // async machinery
IAsyncEnumerable<int>, // can hand out an enumerator
IAsyncEnumerator<int>, // IS the enumerator
IValueTaskSource<bool>, IValueTaskSource // backs the ValueTasks
{
public int <>1__state; // drives BOTH await and yield resumes
public AsyncIteratorMethodBuilder <>t__builder; // runs MoveNext, coordinates completion
public ManualResetValueTaskSourceCore<bool> <>v__promise; // the ValueTask<bool> the consumer awaits
private CancellationToken <>3__ct;
private int <>2__current; // the value Current returns
public int <page>5__1; // hoisted local
private TaskAwaiter<Batch> <>u__1; // a parked awaiter
int IAsyncEnumerator<int>.Current => <>2__current;
ValueTask<bool> IAsyncEnumerator<int>.MoveNextAsync() { /* kicks the builder → MoveNext */ }
void IAsyncStateMachine.MoveNext() { /* the rewritten body — the dispatch loop */ }
ValueTask IAsyncEnumerator<int>.DisposeAsync() { /* ... */ }
IAsyncEnumerator<int> IAsyncEnumerable<int>.GetAsyncEnumerator(CancellationToken ct) { /* ... */ }
}
Note the field set: <>1__state from the iterator world, <>t__builder and a parked TaskAwaiter from the async world, <>2__current for the yielded value, and a ManualResetValueTaskSourceCore<bool> that backs the ValueTask<bool> every MoveNextAsync returns. This one type carries the machinery of both rewrites.
One state field, two kinds of suspension
The heart of it is MoveNext, and the thing to understand is that <>1__state encodes resume points for both await and yield. A simplified shape:
void IAsyncStateMachine.MoveNext()
{
try
{
switch (<>1__state)
{
case 0: goto resume_after_await; // woke up because the awaited task finished
case 1: goto resume_after_yield; // woke up because the consumer asked for the next item
default: <page>5__1 = 0; break; // first entry
}
// ... run the loop body ...
// await FetchPageAsync(page, ct):
var awaiter = FetchPageAsync(<page>5__1, <>3__ct).GetAwaiter();
if (!awaiter.IsCompleted)
{
<>1__state = 0; // park HERE; resume at case 0
<>u__1 = awaiter;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return; // give the thread back
}
// resume_after_await: value is ready, keep going...
// yield return item:
<>2__current = item; // publish the value
<>1__state = 1; // park HERE; resume at case 1
<>v__promise.SetResult(true); // tell the consumer "got one"
return; // give control back to await foreach
// resume_after_yield: consumer called MoveNextAsync again, continue the loop...
}
catch (Exception ex) { /* fault the promise */ }
// fell off the end → <>v__promise.SetResult(false) (that's yield break / done)
}
Read the two suspension styles side by side. At an await, the method stores the awaiter, sets a state, registers a continuation with the builder, and returns — it will be re-entered when the task completes. At a yield return, it sets Current, sets a different state, completes the promise with true, and returns — it will be re-entered when the consumer calls MoveNextAsync again. Same MoveNext, same state field, two reasons to leave and two ways to come back. Falling off the end completes the promise with false, which is how await foreach learns the sequence is finished — the async-iterator equivalent of a plain iterator returning false from MoveNext.
How a decompiler rebuilds it
Reconstruction is pattern recognition on the fused fingerprint. A nested [CompilerGenerated] type that implements both IAsyncStateMachine and IAsyncEnumerator<T>, with an AsyncIteratorMethodBuilder field and a ManualResetValueTaskSourceCore<bool> promise, is unambiguously an async iterator — it can't be a plain async method (no enumerator surface) or a plain iterator (no builder/awaiter). Given that, the decompiler:
-
Un-hoists the fields (
<page>5__1→ localpage,<>3__ct→ the[EnumeratorCancellation]parameter) and recovers<>2__currentas the yielded value. -
Folds the dispatch back into one linear body, classifying each resume point by how it was parked: a state reached through
AwaitUnsafeOnCompletedbecomes anawait; a state reached throughpromise.SetResult(true)becomes ayield return; the terminalSetResult(false)becomes the implicityield break. - On the consumer side, recognizes the
GetAsyncEnumerator/while (await MoveNextAsync())/Current/finally { await DisposeAsync() }shape and rebuilds it asawait foreach.
The result is your ReadBatchesAsync method and a clean await foreach at the call site — not a 150-line enumerator. Glass.NET does this reconstruction by default and lets you drop to the raw <ReadBatchesAsync>d__0 when you need to see the actual machinery.
Why it's worth seeing the machine
The reconstructed source is almost always what you want, but the fused state machine explains behaviour that trips people up. Deferred, lazy execution: like a plain iterator, nothing runs until the first MoveNextAsync, so an exception "in" the method surfaces only once you start the await foreach. Cancellation plumbing: the [EnumeratorCancellation] token becomes a field threaded into every await, which is why forgetting that attribute silently drops the token that WithCancellation passes in — visible immediately once you see the field wiring. And single consumption: the enumerator is the state machine instance, so iterating it twice reuses a spent machine. When an async stream misbehaves, recognizing the IAsyncStateMachine-plus-IAsyncEnumerator pair and reading the two suspension styles in MoveNext turns "why did my stream do that?" into something you can simply read off the generated type.
Top comments (0)