DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Decompiling async iterators: when await and yield share one state machine

.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
    }
}
Enter fullscreen mode Exit fullscreen mode

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) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Un-hoists the fields (<page>5__1 → local page, <>3__ct → the [EnumeratorCancellation] parameter) and recovers <>2__current as the yielded value.
  2. Folds the dispatch back into one linear body, classifying each resume point by how it was parked: a state reached through AwaitUnsafeOnCompleted becomes an await; a state reached through promise.SetResult(true) becomes a yield return; the terminal SetResult(false) becomes the implicit yield break.
  3. On the consumer side, recognizes the GetAsyncEnumerator / while (await MoveNextAsync()) / Current / finally { await DisposeAsync() } shape and rebuilds it as await 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)