Asynchronous C#: Below the Surface

In my last two posts, we've covered C#'s async programming model and also using async/await for asynchronous execution. In the final post of this series on async programming in C#, we're going to take a look behind the scenes at all the work the compiler does for us to make code execute asynchronously. Let's dive in!

Aaron Bos | Friday, June 4, 2021


In this post, we'll be taking a look at some of the code generated by the C# compiler that allows code to execute asynchronously. It's safe to say that we can be thankful that the compiler takes care of a lot of the boilerplate and also sharp edges that go along with writing async code.

Before jumping in, if you'd like to learn more about the Task-based Asynchronous Pattern used by C# to implement asynchronous programming check out the first post of this series. If you're familiar with the pattern, but would like to learn more about how to use async and await definitely check out my previous post that covers those fundamentals.

In order to give some consistency and a point of reference for the code shown throughout this post, I'll be working with a (contrived) simple async method that contains an await expression. The simplicity will make it much easier to discuss the key points of the post, but should provide a solid foundation to explore more complex code beyond this post. Here is the sample code.

using System;
using System.Threading.Tasks;

public class AsyncExample
{
    public static async Task DoSomethingAsync(int input)
    {
        Console.WriteLine("Before awaiting.");
        await Task.Delay(input);
        Console.WriteLine("Task has been awaited.");
    }
}

In order to decompile the example code, I used the ILSpy VS Code extension to decompile a Debug build of the example code. There may be some slight differences between generated code of Release and Debug builds, but concepts of what is being implemented are very similar. I've also cleaned up some of the naming (fields and variables) that the compiler generates so that the snippets are more readable. With that being said I think we are ready to take a look at potentially the most fundamental piece of compiler-generated code for async methods and that's the state machine. Let's dive in!

The State Machine

In order to successfully navigate the different phases of async execution, the compiler generates a state machine that wraps our async code. If you're unfamiliar with the concept of a state machine, it's essentially a way for a program to transition from one state to another based on a program's input arguments and a set of predetermined states. In terms of the state machine that we're concerned with, there are four states.

  1. Not started
  2. Executing
  3. Paused
  4. Complete (this state includes both successful or faulted completions)

The compiler doesn't seem to care much about the exact value that represents each of these states. Meaning that "not started" and "executing" states are represented by -1 and "complete" (either successful or faulted) is represented by -2. Any other state value is considered "paused" (most likely at an await expression). Throughout this post, we'll slowly build up our code snippets to eventually resemble what is generated for an async method (at least my slightly doctored, more readable version). The first code snippet is just going to show our class declaration implementing the important IAsyncStateMachine interface.

private sealed class DoSomethingAsync_Compiler : IAsyncStateMachine
{
    // ...
}

IAsyncStateMachine Methods

As you can see the compiler generates a class with a name resembling the name of our async method. The docs don't have much to say about IAsyncStateMachine except that is used by the compiler to represent a state machine for async methods. Let's bring in the methods defined by IAsyncStateMachine and they are MoveNext() and SetStateMachine(IAsyncStateMachine stateMachine).

private sealed class DoSomethingAsync_Compiler : IAsyncStateMachine
{
    private void MoveNext()
    {
        // ... implementation covered in next section :)
    }

    void IAsyncStateMachine.MoveNext()
    {
        //ILSpy generated this explicit interface implementation from .override directive in MoveNext
        this.MoveNext();
    }

    [DebuggerHidden]
    private void SetStateMachine(IAsyncStateMachine stateMachine)
    {
    }

    void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
    {
        //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
        this.SetStateMachine(stateMachine);
    }
}

As you can see the compiler generates methods that explicitly implement the IAsyncStateMachine interface as well as private methods that are called from the explicit implementations. Since we are looking at the generated code of a Debug build the SetStateMachine implementation is empty. Had this been a Release build there would be a bit of code in there to make sure the state machine is available on the managed heap (in Release builds the state machine is implemented as a struct and not a class). The importance of this is to make sure that the necessary objects are available when continuations are created during async execution (more on this in a bit).

MoveNext() is "Where the Magic Happens"

The other (and potentially more important) method implementation that we'll be going into next is the MoveNext() method. Before jumping right into MoveNext() let's take a look at the fields that are generated as part of the state machine. The comment after each field indicates its use case and importance to the class. The overall importance of these fields is for the state machine to be able to keep track of the information between state transitions.

private sealed class DoSomethingAsync_Compiler : IAsyncStateMachine
{
    public int state; // Represents the current state of the state machine

    public AsyncTaskMethodBuilder builder; // Key to starting the state machine & propagating the result

    public int input; // Input to the method

    private TaskAwaiter awaiter1; // Important for keeping track of the awaiter on continuations

    // Method implementations from last snippet...

}

With that in place, we're ready to take a look at and discuss the MoveNext() method's purpose and implementation. The first thing to note about the MoveNext() method is that is called when the state machine is started and again every time the state machine resumes after being paused (most likely at an await expression). To begin the state machine is actually started via a call to AsyncTaskMethodBuilder.Start(), which I noted in the comment on the builder field in the previous snippet. That Start() method is what initially calls MoveNext(). Now let's look at the compiler-generated code (using clean names for readability) for the MoveNext method. Notice that I've added numbered comments on lines that we'll discuss in a numbered list below the code snippet.

private void MoveNext()
{
    int num = state;
    try
    {
        TaskAwaiter awaiter;
        if (num != 0) // 1
        {
            Console.WriteLine("Before awaiting.");
            awaiter = Task.Delay(input).GetAwaiter();
            if (!awaiter.IsCompleted) // 2
            {
                num = (state = 0);
                awaiter1 = awaiter;
                DoSomethingAsync_Compiler stateMachine = this;
                builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); // 3
                return;
            }
        }
        else
        { // 4
            awaiter = awaiter1; 
            awaiter1 = default(TaskAwaiter);
            num = (state = -1); // 5
        }
        awaiter.GetResult(); // 6
        Console.WriteLine("Task has been awaited.");
    }
    catch (Exception exception)
    {
        state = -2;
        builder.SetException(exception); // 7
        return;
    }
    state = -2;
    builder.SetResult(); // 8
}

MoveNext() Breakdown

  1. Initial check on the state (num is kept in sync with state throughout)
  2. Whether or not the Task is completed determines how the state machine will proceed
  3. Schedules the state machine to proceed to the next action when the specified awaiter completes.
  4. Resuming from a continuation
  5. Set the state indicating that the task is started and/or executing
  6. Get result via awaiter which makes it available for the builder since it was passed to AwaitUnsafeOnCompleted by reference.
  7. An exception occurred and faulted Task is propagated via the SetException() method instead of throwing.
  8. The method is completed and the Task is returned with completed status. If return type is Task<TResult>, the result is set as well.

At this point we've broken down the compiler-generated code for async methods piece by piece. Hopefully we now have a better understanding of the complexity involved in even a simple example (think back to our simple example with a single await and two Console.WriteLine statements). I can assure you that this code gets much more complicated when there are multiple awaits, loops, try/catch blocks, etc. I tried to keep this example relatively simple so to provide a solid foundation for further exploration. If you'd like to see the entire class of compiler-generated code that covered, check out this GitHub gist.

I also can't finish this post without giving a big shout out to Jon Skeet and his amazing C# in Depth (4th Edition) book. The book was instrumental in helping me understand a lot of what is covered in this post.

I hope you've enjoyed the last few posts about asynchronous programming in C#. I've certainly enjoyed writing them and learning quite a bit along the way 🤓.


ShareSubscribe
As always thank you for taking the time to read this blog post!