Exploring the async/await State Machine – Main Workflow and State Transitions

Intro

In the previous article, I’ve described the Awaitable Pattern and mentioned that it plays a crucial role in the async/await workflow.

It’s time to investigate the async/await State Machine that the compiler generates for every async method. I’ll start with this article by presenting a comprehensive diagram where you’ll clearly see the main workflow and state transitions.

You will also gain an excellent intuition of how the different elements of the Awaitable Pattern come into play.

Bear in mind that the descriptions in this post are conceptual. The primary purpose is to show the high-level structure and program flow of the State Machine. This will lay the required foundations for exploring the specific implementation details in the upcoming posts.

Let’s dive in!

This article’s content is influenced by the async/await material in the book C# in Depth by Jon Skeet and the blog post Dissecting the async methods in C# by Sergey Tepliakov.

For additional in-depth materials on async/await in C#, you can check this Pluralsight course.

Sample Code

The whole discussion in this post will be based on the following simple async method:

public static async Task<int> MyAsyncMethod(int firstDelay, int secondDelay)
{
    Console.WriteLine("Before first await.");
    
    await Task.Delay(firstDelay);
    
    Console.WriteLine("Before second await.");
    
    await Task.Delay(secondDelay);
    
    Console.WriteLine("Done.");

    return 42;
}

You will be amazed by all the heavy lifting the compiler does for us, so this method’s structure looks pretty much like any synchronous method.

A specific example like this helps us to keep the discussion very concrete. However, all of the takeaways will be very much applicable to other types of async methods.

States

As you saw in the last post, the await keyword marks a very special checkpoint in an async method. At such a point, the method execution may pause until the async operation resumes on the continuation.

“Pausing” here means returning an incomplete task. I say “may pause” because if the async operation is already completed, the method will continue running synchronously.

These pauses define the different states of your method execution:

Workflow Diagram

Let’s see how the compiler transforms our simple MyAsyncMethod into a state-machine-based workflow.

Please bear in mind that exception handling is omitted for simplicity.

I’ll get into many more details in the next sections, but you can spend a minute on the diagram trying to get some intuition of the workflow.

There are two main players in this whole machinery:

  1. The MyAsyncMethod method, which is significantly transformed. Its’ new responsibility is to initialize and trigger the State Machine execution.
  2. The State Machine itself. It is a nested struct(or a class in Debug mode) that the compiler produces for us.

MyAsyncMethod as a Trigger of the State Machine

The new version of the MyAsyncMethod method implements the following logic:

  1. It initializes the state machine with the required parameters – the initial state plus all the method input parameters (in our case, those are firstDelay and secondDelay). Those values are stored as instance fields of the State Machine.
  2. Then, it triggers the State Machine by calling its’ primary method – MoveNext. This method coordinates the main workflow and states transition. You’ll learn a lot more about it soon.
  3. When MoveNext returns, the State Machine has produced a (most likely incomplete)Task, representing the asynchronous operation. MyAsyncMethod would then just return the task to the caller.

State 0

When invoked for the first time by MyAsyncMethod, the State Machine gets into State 0 (highlighted):

State 0 contains the following steps (matching the numbering in the diagram):

  1. It executes the original synchronous code before the first await. In our case, this is just the following printing statement:
public static async Task<int> MyAsyncMethod(int firstDelay, int secondDelay)
{
    Console.WriteLine("Before first await.");
    
    await Task.Delay(firstDelay);
    
    // …
}
  1. It’s now done with the logic in State 0, so it sets the state to State 1.

    Any data needed between states is kept as local fields in the State Machine. This is true for the variable that holds the state itself. In this way, the context is preserved and accessible in the continuation after an async call. This article on Closures can give you some intuition on how this happens.
  1. When the execution reaches the await point, it gets the Awaiter from the awaitable.

    Again, read the previous article if this doesn’t sound familiar.
  1. If the Awaiter is already completed, we just move to the State 1 workflow. Otherwise, we attach the MoveNext method as a continuation to the Awaiter and return. At some point, when the Awaiter is completed, the continuation will be invoked. The state will be State 1, so MoveNext will enter the State 1 workflow.

State 1

The workflow in State 1 does the following:

  1. First, it invokes Awaiter.GetResult()

    Notice that, at this point, the Awaiter is the one returned from the <Task.Delay> call in State 0 (the Awaiter is also stored as an instance field, so it “survives pauses”).

    You may be wondering why we need to call Awaiter.GetResult when Task.Delay doesn’t return any data. The reason is simple – this is how any potential exception would be propagated. I haven’t presented exception handling in this diagram for simplicity, but there’s nothing complex to it, and you’ll see it in future articles.
  1. Similarly to State 0, it then runs the synchronous code for State 1. In our example, that’s the printing statement between the two awaits:
public static async Task<int> MyAsyncMethod(int firstDelay, int secondDelay)
{
    // …
    
    await Task.Delay(firstDelay);
    
    Console.WriteLine("Before second await.");
    
    await Task.Delay(secondDelay);
    
    // …
}
  1. We’re done with State 1 logic, so the state is moved to State 2.
  2. Invoke Task.Delay and get the Awaiter.
  3. Similarly to State 0, if the Awaiter is completed, move to State 2 directly. Otherwise, attach MoveNext as a continuation to the Awaiter and return.

State 2

We don’t need an explicitly defined State 2 state. If we’re done with State 0 and State 1, there are no more await statements. This means the State Machine will just run synchronously until completion. There will be no more pauses and continuations; therefore, no need for additional states to resume on.

However, I’m calling this final part of the State Machine State 2 just for a reference point.

State 2 is quite simple. The first two steps should look familiar – first, we invoke GetResult() on the Awaiter, then print to the Console.

As the last step, the State Machine’s Task is assigned with the resulting value and marked as completed.

Summary

By now, you should have a decent high-level understanding of the async/await State Machine and its’ workflow.

This allows me to start exploring some actual implementation.

Stay tuned, and thanks for reading!

Resources

  1. C# in Depth: Fourth Edition
  2. Dissecting the async methods in C#

Site Footer

Subscribe To My Newsletter

Email address