27 Dec, 202415 min read
Async Under the Hood: The State Machine

Async Under the Hood: The State Machine

#C##.NET#Async#Await#State Machine#Deep Dive

Async and await can feel like “magic”, but it is just compiler work. The compiler rewrites your method into a small state machine (tiny program that can pause and later continue). If you understand this picture, debugging async issues becomes much easier.

In this post, we'll go step by step. First, we'll understand what await really needs (the awaitable pattern). Then, we'll look at the state machine the compiler generates, visualizing how execution pauses and resumes. By building a simplified "hand-made" state machine, we'll demystify the magic behind async/await.

Part 1: The Awaitable Pattern (what await is looking for)

When the compiler sees:

await something;

it tries to treat something like an “awaitable”. Awaitable just means, “this thing can tell me if it is finished, and if not, how to continue later”.

Conceptually the compiler does something like this:

  1. Ask the value for an awaiter (GetAwaiter()).
  2. Check if it already finished (IsCompleted).
  3. If finished, take the result (GetResult()) and keep going.
  4. If not finished, register a continuation (an Action that will run later), then return to the caller.

A small awaitable example (sync sometimes, async sometimes)

This example completes immediately or after a short delay. You can see how the same await sometimes continues synchronously, and sometimes later.

using System.Runtime.CompilerServices;
 
public sealed class SometimesAsync
{
    private readonly Task<int> _task;
 
    public SometimesAsync(bool completeNow)
    {
        _task = completeNow
            ? Task.FromResult(5)
            : Task.Delay(300).ContinueWith(_ => 5);
    }
 
    public TaskAwaiter<int> GetAwaiter() => _task.GetAwaiter();
}

Usage:

Console.WriteLine("Before #1");
var a = await new SometimesAsync(completeNow: true);
Console.WriteLine($"After #1: {a}");
 
Console.WriteLine("Before #2");
var b = await new SometimesAsync(completeNow: false);
Console.WriteLine($"After #2: {b}");

What members does an awaiter need?

In practice, the compiler looks for GetAwaiter(), and then on the awaiter it expects the shape:

  • bool IsCompleted { get; }
  • void OnCompleted(Action continuation) (or UnsafeOnCompleted)
  • T GetResult() (or void GetResult())

You do not need to memorize the interfaces, but it is useful to know the pattern exists.

Part 2: Where “states” come from

Every await is a potential pause. If you have two awaits, you basically have multiple “resume points”.

Example:

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

When the first delay is not completed yet, the method cannot keep executing. It must return to its caller, and it must remember:

  • the parameters (firstDelay, secondDelay)
  • where it stopped (which await)
  • anything it needs later (like an awaiter)

That “remembering” is done with fields on a generated state machine.

Compilers usually start the state with -1 (meaning “not started / running”), then 0, 1, etc for each await, and -2 for “finished”. The exact numbers can vary across versions, but the idea is the same.

If both awaits complete immediately (rare for real I/O, but possible), it runs “like sync code” in a single call to MoveNext.

Part 3: A conceptual “hand made” state machine (to see the idea)

This is not exactly what the compiler generates (real state machines are optimized structs to avoid allocations), but it is close enough to understand the mechanics. It shows:

  • a _state field
  • fields to store parameters and awaiters
  • returning early when an await is not completed
  • continuing later by calling MoveNext again
public static Task<int> MyAsyncMethod(int firstDelay, int secondDelay)
{
    var sm = new MyAsyncMethodStateMachine(firstDelay, secondDelay);
    sm.MoveNext();
    return sm.Task;
}
 
private sealed class MyAsyncMethodStateMachine
{
    private readonly TaskCompletionSource<int> _tcs = new();
    private readonly int _firstDelay;
    private readonly int _secondDelay;
 
    private int _state = -1;
 
    private TaskAwaiter _awaiter;
 
    public MyAsyncMethodStateMachine(int firstDelay, int secondDelay)
        => (_firstDelay, _secondDelay) = (firstDelay, secondDelay);
 
    public Task<int> Task => _tcs.Task;
 
    public void MoveNext()
    {
        try
        {
            if (_state == -1)
            {
                Console.WriteLine("Before first await.");
                _awaiter = Task.Delay(_firstDelay).GetAwaiter();
 
                if (!_awaiter.IsCompleted)
                {
                    _state = 0;
                    _awaiter.OnCompleted(MoveNext);
                    return;
                }
 
                _state = 0;
            }
 
            if (_state == 0)
            {
                _awaiter.GetResult();
 
                Console.WriteLine("Before second await.");
                _awaiter = Task.Delay(_secondDelay).GetAwaiter();
 
                if (!_awaiter.IsCompleted)
                {
                    _state = 1;
                    _awaiter.OnCompleted(MoveNext);
                    return;
                }
 
                _state = 1;
            }
 
            if (_state == 1)
            {
                _awaiter.GetResult();
 
                Console.WriteLine("Done.");
                _tcs.SetResult(42);
                _state = -2;
            }
        }
        catch (Exception ex)
        {
            _tcs.SetException(ex);
            _state = -2;
        }
    }
}

The “early return” is the main trick. The method stops, gives back a Task, and later continues when the continuation calls MoveNext again.

How to inspect generated code yourself (practical steps)

If you want to really see it:

  1. Take a small async method with 1 to 2 awaits. Smaller is easier to follow.
  2. Paste it into SharpLab (or decompile with ILSpy).
  3. Find the generated type that implements IAsyncStateMachine.
  4. Open MoveNext(). That method is the story of your async method.

After doing this once or twice, the compiler output stops looking scary.

Key resources

Thanks for reading and on the next blog post, we’ll move from the “toy but accurate” model into what the compiler and runtime actually do in real apps. We’ll look at why the generated state machine is usually a struct, how it ends up living on the heap once the method really pauses, and what AsyncTaskMethodBuilder is doing behind the scenes. After that, we’ll talk about SynchronizationContext, why UI apps can deadlock, and how ConfigureAwait(false) behaves when you have nested async calls. Finally, we’ll cover a few practical topics, like exception wrapping (AggregateException).