Skip to content
← Alle Beiträge
· Delta1 Labs Dekompilierung.NETTiefenanalyse

Async-Iteratoren dekompilieren: wenn await und yield sich eine Zustandsmaschine teilen

Eine async-IAsyncEnumerable-Methode mischt await und yield return — der Compiler erzeugt also eine einzige Zustandsmaschine, die zugleich eine Async-Zustandsmaschine und ein Iterator ist. Hier steht die Form, zu der sie abgesenkt wird, wie das Zustandsfeld sowohl Await-Fortsetzungen als auch Yield-Punkte treibt und wie ein Decompiler das Muster erkennt, um await foreach wiederaufzubauen.

.NET hat zwei berühmte Compiler-Umschreibungen: async/await wird zu einer Zustandsmaschine, und yield return wird zu einem Iterator. Async-Iteratoren — async IAsyncEnumerable<T> mit await foreach auf der Verbraucherseite — sind das, was passiert, wenn Sie beide in einer Methode verwenden. Der Compiler kann nicht eine Umschreibung wählen; er erzeugt eine einzige Zustandsmaschine, die zugleich eine Async-Zustandsmaschine und ein Iterator ist. Diese Verschmelzung zu verstehen ist der Schlüssel zum Lesen eines dekompilierten await foreach und verbindet die Muster der Async-Zustandsmaschine und des Yield-Iterators, die Sie vielleicht schon kennen.

Die Methode, die awaited und yieldet

Hier ein Lehrbuch-Async-Iterator — er awaited E/A und yieldet Ergebnisse, sobald sie eintreffen:

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
    }
}

Zwei verschiedene Aussetzungen leben in einer Methode. await parkt, bis ein Task fertig ist; yield return parkt, bis der Verbraucher das nächste Element verlangt. Eine einfache Async-Methode hat nur die erste; ein einfacher Iterator nur die zweite. Ein Async-Iterator muss beide behandeln — und tut das mit einem Zustandsfeld und einer Verteilungsschleife.

Die verschmolzene Zustandsmaschine

Ohne Erkennung dekompiliert, ist ReadBatchesAsync ein Stub, der eine generierte Struct konstruiert und zurückgibt. Diese Struct — konventionell <ReadBatchesAsync>d__0 — implementiert die gesamte Async-und-Iterator-Oberfläche:

[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) { /* ... */ }
}

Beachten Sie die Feldmenge: <>1__state aus der Iteratorwelt, <>t__builder und ein geparkter TaskAwaiter aus der Async-Welt, <>2__current für den yieldeten Wert und ein ManualResetValueTaskSourceCore<bool>, das den ValueTask<bool> unterlegt, den jedes MoveNextAsync zurückgibt. Dieser eine Typ trägt die Maschinerie beider Umschreibungen.

Ein Zustandsfeld, zwei Arten der Aussetzung

Das Herz ist MoveNext, und das Entscheidende ist, dass <>1__state Fortsetzungspunkte für beide — await und yield — codiert. Eine vereinfachte Form:

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)
}

Lesen Sie die zwei Aussetzungsstile nebeneinander. Bei einem await speichert die Methode den Awaiter, setzt einen Zustand, registriert eine Fortsetzung beim Builder und kehrt zurück — sie wird wieder betreten, wenn der Task fertig ist. Bei einem yield return setzt sie Current, setzt einen anderen Zustand, schließt die Promise mit true ab und kehrt zurück — sie wird wieder betreten, wenn der Verbraucher erneut MoveNextAsync aufruft. Dasselbe MoveNext, dasselbe Zustandsfeld, zwei Gründe zu gehen und zwei Wege zurückzukommen. Aus dem Ende herauszufallen schließt die Promise mit false ab, und so erfährt await foreach, dass die Sequenz beendet ist — das Async-Iterator-Äquivalent eines einfachen Iterators, der false aus MoveNext zurückgibt.

MoveNextswitch Zustandawait → parkt am Builderstate=0; setzt bei Task-Ende fortyield return → Currentstate=1; ValueTask=trueerneut betretennächstes MoveNextEndefalse

Wie ein Decompiler es wiederaufbaut

Die Rekonstruktion ist Mustererkennung auf dem verschmolzenen Fingerabdruck. Ein verschachtelter [CompilerGenerated]-Typ, der sowohl IAsyncStateMachine als auch IAsyncEnumerator<T> implementiert, mit einem AsyncIteratorMethodBuilder-Feld und einer ManualResetValueTaskSourceCore<bool>-Promise, ist eindeutig ein Async-Iterator — er kann keine einfache Async-Methode (keine Enumerator-Oberfläche) und kein einfacher Iterator (kein Builder/Awaiter) sein. Damit:

  1. Zieht der Decompiler die Felder herunter (<page>5__1 → Locale page, <>3__ct → der [EnumeratorCancellation]-Parameter) und gewinnt <>2__current als yieldeten Wert zurück.
  2. Klappt die Verteilung in einen linearen Körper zurück und klassifiziert jeden Fortsetzungspunkt danach, wie er geparkt wurde: ein über AwaitUnsafeOnCompleted erreichter Zustand wird ein await; ein über promise.SetResult(true) erreichter Zustand wird ein yield return; das abschließende SetResult(false) wird das implizite yield break.
  3. Auf der Verbraucherseite erkennt er die Form GetAsyncEnumerator / while (await MoveNextAsync()) / Current / finally { await DisposeAsync() } und rekonstruiert sie als await foreach.

Das Ergebnis ist Ihre ReadBatchesAsync-Methode und ein sauberes await foreach an der Aufrufstelle — kein 150-zeiliger Enumerator. Glass.NET macht diese Rekonstruktion standardmäßig und lässt Sie zum rohen <ReadBatchesAsync>d__0 hinabsteigen, wenn Sie die tatsächliche Maschinerie sehen müssen.

Warum es sich lohnt, die Maschine zu sehen

Der rekonstruierte Code ist fast immer das, was Sie wollen, aber die verschmolzene Zustandsmaschine erklärt Verhalten, über das Leute stolpern. Verzögerte, faule Ausführung: Wie ein einfacher Iterator läuft nichts vor dem ersten MoveNextAsync, also taucht eine „in” der Methode geworfene Ausnahme erst auf, wenn Sie das await foreach beginnen. Abbruch-Verdrahtung: Das [EnumeratorCancellation]-Token wird ein Feld, das in jedes await eingewoben ist — weshalb das Vergessen dieses Attributs das Token, das WithCancellation übergibt, stillschweigend verwirft, sichtbar sofort, sobald Sie die Feldverdrahtung sehen. Und einmaliger Verbrauch: Der Enumerator ist die Zustandsmaschinen-Instanz, also verwendet zweimaliges Iterieren eine verbrauchte Maschine wieder. Wenn ein Async-Stream sich schlecht verhält, verwandelt das Erkennen des IAsyncStateMachine-plus-IAsyncEnumerator-Paars und das Lesen der zwei Aussetzungsstile in MoveNext das „Warum hat mein Stream das getan?” in etwas, das Sie einfach vom generierten Typ ablesen können.

Nebula.NET testen

Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.