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

Wozu async/await dekompiliert: die generierte Zustandsmaschine lesen

Dekompilieren Sie eine async-Methode, und Sie sehen kein async/await — Sie sehen eine vom Compiler generierte Struktur mit einem switch in MoveNext(). Hier steht, wie der C#-Compiler async in eine Zustandsmaschine überführt, wie man den Typ <Methode>d__N liest, den ein Decompiler zeigt, und wie ein guter Decompiler das Original rekonstruiert.

Schreiben Sie eine kleine async-Methode, kompilieren Sie sie, öffnen Sie die Assembly in einem Decompiler — und Sie finden Ihre Methode nicht. An ihrer Stelle steht eine Struktur mit einem unaussprechbaren Namen und eine MoveNext()-Methode, aufgebaut um einen switch. Das ist weder Beschädigung noch Obfuskierung: Es ist genau das, was async/await ist. Die Runtime kennt kein await; der C#-Compiler implementiert es, indem er Ihre Methode in eine Zustandsmaschine umschreibt. Diese Umschreibung zu verstehen ist der Unterschied zwischen Verwirrung über einen Decompiler und flüssigem Lesen.

Die Methode, die Sie geschrieben haben

public async Task<int> GetLengthAsync(HttpClient http, string url)
{
    HttpResponseMessage resp = await http.GetAsync(url);
    string body = await resp.Content.ReadAsStringAsync();
    return body.Length;
}

Zwei awaits, ein paar lokale Variablen. Leicht zu lesen — weil await den schwierigen Teil verbirgt: die Methode an jedem await anhalten, zum Aufrufer zurückkehren und später auf dem Thread fortsetzen, der die erwartete Task abschließt, mit allen lokalen Variablen intakt.

Was der Compiler generiert

Der Compiler teilt GetLengthAsync in zwei Teile. Die Methode, die Sie deklariert haben, wird zu einem dünnen Stub, der eine Zustandsmaschine einrichtet und startet:

public Task<int> GetLengthAsync(HttpClient http, string url)
{
    var sm = new <GetLengthAsync>d__0();
    sm.http = http;
    sm.url = url;
    sm.<>t__builder = AsyncTaskMethodBuilder<int>.Create();
    sm.<>1__state = -1;
    sm.<>t__builder.Start(ref sm);
    return sm.<>t__builder.Task;
}

Die eigentliche Arbeit wandert in den generierten Typ. Sein Name — <GetLengthAsync>d__0 — nutzt spitze Klammern gerade deshalb, weil das kein gültiger C#-Bezeichner ist, sodass er nie mit etwas kollidieren kann, das Sie schreiben. Er trägt Ihre Parameter und lokalen Variablen als Felder, eine Zustands-Ganzzahl, den Builder, dem die zurückgegebene Task<int> gehört, und ein Awaiter-Feld pro await:

private struct <GetLengthAsync>d__0 : IAsyncStateMachine
{
    public int <>1__state;
    public AsyncTaskMethodBuilder<int> <>t__builder;
    public HttpClient http;
    public string url;
    private HttpResponseMessage <resp>5__1;
    private TaskAwaiter<HttpResponseMessage> <>u__1;
    private TaskAwaiter<string> <>u__2;

    void IAsyncStateMachine.MoveNext() { /* your method, rewritten */ }
    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine s) =>
        <>t__builder.SetStateMachine(s);
}

Beachten Sie, dass resp zu einem Feld wurde (<resp>5__1), nicht zu einer lokalen Variable. Alles, dessen Wert ein await überleben muss, muss auf die Zustandsmaschine gehoben werden (Hoisting), denn der Stack-Frame der Methode ist weg, während sie angehalten ist.

MoveNext: Ihre Methode, an jedem await geschnitten

MoveNext() ist der Kern. Jedes await wird zu einem Punkt, an dem die Methode anhalten und später wieder eintreten kann, also ist der Rumpf als switch über das Zustandsfeld organisiert. Ein dekompiliertes MoveNext sieht ungefähr so aus (vereinfacht, mit der Ausnahmebehandlung, die der Builder verlangt):

void IAsyncStateMachine.MoveNext()
{
    int state = <>1__state;
    int result;
    try
    {
        TaskAwaiter<HttpResponseMessage> awaiter1;
        if (state != 0)
        {
            awaiter1 = http.GetAsync(url).GetAwaiter();
            if (!awaiter1.IsCompleted)
            {
                <>1__state = 0;                 // remember where we are
                <>u__1 = awaiter1;
                <>t__builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this); // suspend
                return;
            }
        }
        else                                    // resumed into state 0
        {
            awaiter1 = <>u__1;
            <>1__state = -1;
        }
        <resp>5__1 = awaiter1.GetResult();      // the awaited value

        // ... second await on ReadAsStringAsync() follows the same shape, state 1 ...

        result = /* body.Length */ 0;
    }
    catch (Exception ex)
    {
        <>1__state = -2;                        // final
        <>t__builder.SetException(ex);
        return;
    }
    <>1__state = -2;
    <>t__builder.SetResult(result);             // completes the Task<int>
}

Lesen Sie die Zustandswerte, und alles wird lesbar. -1 bedeutet „läuft, nicht angehalten”. 0 und 1 markieren die zwei awaits: Wenn eine erwartete Task nicht bereits abgeschlossen ist, speichert die Maschine ihren Zustand, parkt den Awaiter, ruft AwaitUnsafeOnCompleted auf (was MoveNext zur erneuten Ausführung einplant, sobald die Task fertig ist) und kehrt zurück. Bei der Fortsetzung springt der switch/if direkt zum passenden Zweig, liest das Ergebnis des Awaiters und macht weiter. -2 ist der Endzustand, in dem dem Builder das Endergebnis oder die Ausnahme mitgeteilt wird — so wird Ihre Task<int> abgeschlossen.

-1await #10await #21return-2SetResult / SetException

Warum der Decompiler Ihnen async/await zurückgeben kann

Weil dieses Muster so regelmäßig ist — ein Typ, der IAsyncStateMachine implementiert, ein Builder-Feld, ein Zustands-switch, Awaiter-Felder — kann ein Decompiler es erkennen und die Umschreibung rückwärts ausführen, sodass er das GetLengthAsync rekonstruiert, das Sie tatsächlich geschrieben haben, samt await-Ausdrücken. Diese rekonstruierte Ansicht wollen Sie meistens: Sie zeigt die Absicht. Glass.NET tut das und lässt Sie außerdem zum rohen generierten <GetLengthAsync>d__0 hinabsteigen, wenn Sie genau sehen müssen, was der Compiler erzeugt hat.

Beide Ansichten zahlen sich aus. Das rekonstruierte async ist, wie man Logik schnell liest. Die rohe Zustandsmaschine ist, wie man über Kosten nachdenkt: Jedes await ist ein potenzielles Anhalten und eine eingeplante Fortsetzung, jede gehobene lokale Variable ist ein Feld, das darüber hinweg am Leben gehalten wird, und eine Methode, die in einer engen Schleife await verwendet, erzeugt bei jedem Durchlauf einen Zustandsübergang. Wenn Sie einer Allokation oder einem unerwarteten Thread-Wechsel nachjagen, liegt die Antwort im Lesen der Maschine — nicht des aufgeräumten async.

Wenn Ihnen also das nächste Mal ein Decompiler eine <Etwas>d__N-Struktur mit einem switch in MoveNext zeigt, sehen Sie nichts Kaputtes. Sie sehen async/await ohne seine Kleider — und jetzt können Sie es lesen.

Nebula.NET testen

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