Skip to content
← All posts
· Delta1 Labs Decompilation.NETGuide

What async/await Decompiles To: Reading the Generated State Machine

Decompile an async method and you do not see async/await — you see a compiler-generated struct with a MoveNext() switch. Here is how the C# compiler lowers async into a state machine, how to read the <Method>d__N type a decompiler shows you, and how a good decompiler reconstructs the original.

Write a small async method, compile it, open the assembly in a decompiler, and you will not find your method. In its place is a struct with an unpronounceable name and a MoveNext() method built around a switch. This is not corruption and not obfuscation — it is exactly what async/await is. The runtime has no notion of await; the C# compiler implements it by rewriting your method into a state machine. Understanding that rewrite is the difference between being confused by a decompiler and reading it fluently.

The method you wrote

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

Two awaits, a couple of locals. Straightforward to read — because await hides the hard part: suspending the method at each await, returning to the caller, and resuming later on whatever thread completes the awaited task, with all your locals intact.

What the compiler generates

The compiler splits GetLengthAsync into two pieces. The method you declared becomes a thin stub that sets up and starts a state machine:

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

The real work moves into the generated type. Its name — <GetLengthAsync>d__0 — uses angle brackets precisely because that is not a legal C# identifier, so it can never collide with anything you write. It carries your parameters and locals as fields, a state integer, the builder that owns the returned Task<int>, and one awaiter field per 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);
}

Note that resp became a field (<resp>5__1), not a local. Anything whose value must survive an await has to be hoisted onto the state machine, because the method’s stack frame is gone while it is suspended.

MoveNext: your method, cut at every await

MoveNext() is the heart of it. Each await becomes a point where the method can suspend and later re-enter, so the body is organized as a switch on the state field. A decompiled MoveNext looks roughly like this (simplified, with the exception handling the builder requires):

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

Read the state values and the whole thing becomes legible. -1 means “running, not suspended.” 0 and 1 mark the two awaits: when an awaited task is not already complete, the machine stores its state, parks the awaiter, calls AwaitUnsafeOnCompleted (which schedules MoveNext to run again when the task finishes) and returns. On resume, the switch/if jumps straight back to the matching branch, reads the awaiter’s result, and continues. -2 is the terminal state, where the builder is told the final result or the exception — which is how your Task<int> completes.

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

Why the decompiler can give you async/await back

Because this pattern is so regular — a type implementing IAsyncStateMachine, a builder field, a state switch, awaiter fields — a decompiler can detect it and run the rewrite in reverse, reconstructing the GetLengthAsync you actually wrote, await expressions and all. That reconstructed view is what you want most of the time: it shows intent. Glass.NET does this, and also lets you drop to the raw generated <GetLengthAsync>d__0 when you need to see exactly what the compiler produced.

Both views pay off. The reconstructed async is how you read logic quickly. The raw state machine is how you reason about cost: every await is a potential suspension and a scheduled continuation, every hoisted local is a field kept alive across it, and a method that awaits in a tight loop generates a state transition each time around. When you are chasing an allocation or an unexpected thread hop, reading the machine — not the tidy async — is where the answer lives.

So the next time a decompiler shows you a <Something>d__N struct with a MoveNext switch, you are not looking at something broken. You are looking at async/await with its clothes off — and now you can read it.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.