Skip to content

How Asynchronous Execution Works Internally

So far we have used async and await like magic. In this lesson we open the box and see what the C# compiler and the .NET runtime do behind the scenes. You don't need this to write async code, but it makes everything else easy to understand.

The big idea: the compiler rewrites your method

The CPU cannot "pause" a method in the middle and come back later. So the compiler rewrites every async method into a small object called a state machine. The state machine remembers:

  • Where the method stopped (which await), stored in a number called state.
  • The local variables, stored as fields so they survive the pause.
  • The Task that will be given to the caller, handled by a "builder".

What you write

static async Task<int> GetLengthAsync()
{
    Console.WriteLine("Start");
    string text = await File.ReadAllTextAsync("data.txt");
    Console.WriteLine("Got the text");
    return text.Length;
}

What the compiler generates (simplified)

The real generated code has strange names like <GetLengthAsync>d__0, but it works like this:

// 1. Your method becomes a small "starter" method
static Task<int> GetLengthAsync()
{
    var machine = new GetLengthStateMachine();
    machine.builder = AsyncTaskMethodBuilder<int>.Create();
    machine.state = -1;                 // -1 = "not started yet"
    machine.builder.Start(ref machine); // runs MoveNext() for the first time
    return machine.builder.Task;        // the caller gets this Task
}

// 2. The body of your method moves into MoveNext()
struct GetLengthStateMachine : IAsyncStateMachine
{
    public int state;
    public AsyncTaskMethodBuilder<int> builder;
    private TaskAwaiter<string> awaiter;

    public void MoveNext()
    {
        try
        {
            if (state == -1)
            {
                // Code before the await
                Console.WriteLine("Start");
                awaiter = File.ReadAllTextAsync("data.txt").GetAwaiter();

                if (!awaiter.IsCompleted)
                {
                    state = 0;                                              // remember: we stopped at await #0
                    builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);  // "call MoveNext again when done"
                    return;                                                 // give the thread back
                }
            }

            // We arrive here when the file has been read (state 0)
            string text = awaiter.GetResult();   // the result, or throws the exception
            Console.WriteLine("Got the text");
            builder.SetResult(text.Length);      // the Task becomes complete
        }
        catch (Exception ex)
        {
            builder.SetException(ex);            // the Task becomes faulted
        }
    }

    public void SetStateMachine(IAsyncStateMachine stateMachine) { }
}

Read MoveNext() carefully. It is your method cut into pieces at each await. The first call runs the first piece. When the file is ready, MoveNext() is called again and, because state is now 0, it jumps straight to the second piece.

In a Release build the state machine is a struct (no memory allocation if the method never actually pauses). It is moved to the heap only when the method really has to wait.

The complete journey of one await

Let's follow await File.ReadAllTextAsync("data.txt") from start to end:

  1. The caller calls GetLengthAsync(). The state machine is created and MoveNext() runs on the caller's thread. "Start" is printed.
  2. The read is started. ReadAllTextAsync asks the operating system to read the file and immediately returns an unfinished Task.
  3. The task is not complete, so the state machine saves state = 0 and registers itself as the "continuation" of that task.
  4. The method returns. The caller receives an unfinished Task<int>. The thread is free: in ASP.NET Core it goes back to the thread pool; in a desktop app it goes back to processing clicks and painting.
  5. Nobody is waiting. The disk controller reads the data on its own. No .NET thread is sitting idle for this read.
  6. The OS signals completion. On Windows this arrives through an I/O completion port; the runtime marks the file task as complete.
  7. The continuation is scheduled. The runtime calls MoveNext() again, either on the UI thread (if there was a SynchronizationContext) or on a thread-pool thread.
  8. The method resumes from state 0: gets the text, prints "Got the text" and calls SetResult.
  9. The caller's Task completes, and whoever was awaiting GetLengthAsync() resumes in the same way.
Caller thread   : [Start]----returns Task----> (free: serves other work)
Disk / OS       :        [ reading file ... no thread needed ... ]
Thread pool     :                                                [Got the text, SetResult]

"There is no thread"

This is the part that surprises most developers. While real I/O (a file read, a database query, a web request) is in progress, no thread is blocked waiting for it. The work is done by hardware (the disk, the network card) and the operating system. A thread is needed only for the tiny moments of starting the operation and running the code after await.

This is why one ASP.NET Core server with a small thread pool can have thousands of database calls in progress at the same time.

What is SynchronizationContext?

When await pauses, it captures the current context: "where should I continue?"

Application typeContextCode after await runs on
WinForms, WPF, MAUIUI SynchronizationContextThe UI thread (safe to update controls)
ASP.NET CoreNoneAny thread-pool thread
Console appNoneAny thread-pool thread
Old ASP.NET (.NET Framework)AspNetSynchronizationContextOne request at a time, on the request's context

ConfigureAwait(false) tells await: "don't capture the context, continue on any thread". Library authors use it so their code never needs to wait for the UI thread.

The states of a Task

A Task object tracks its own progress in the Status property:

StatusMeaning
WaitingForActivationAn async method's task that has started but is not finished (very common).
RunningThe task's code is currently running on a thread (for Task.Run work).
RanToCompletionFinished successfully; the result is available.
FaultedFinished with an exception.
CanceledStopped because of a cancellation request.
Task<int> task = GetLengthAsync();
Console.WriteLine(task.Status);        // WaitingForActivation
await task;
Console.WriteLine(task.Status);        // RanToCompletion

Summary

  • The compiler turns every async method into a state machine with a MoveNext() method.
  • Each await is a point where the method can be cut, saved (state) and resumed later.
  • If the awaited task is already done, the method continues immediately without pausing.
  • While real I/O is in progress, no thread is waiting for it; the OS notifies .NET when it completes.
  • The code after await resumes on the UI thread (desktop apps) or a thread-pool thread (ASP.NET Core, console).
  • A Task tracks its progress: WaitingForActivation, RanToCompletion, Faulted, Canceled.