The async/await keywords in C# don’t magically make code run on a separate thread; they transform your method into a state machine.

Let’s see this in action. Imagine a simple asynchronous method:

public async Task<string> GetDataAsync(int id)
{
    Console.WriteLine($"Fetching data for id: {id}");
    await Task.Delay(1000); // Simulate network latency
    Console.WriteLine($"Data fetched for id: {id}");
    return $"Data for {id}";
}

When you call GetDataAsync(1), the compiler doesn’t just execute it top-to-bottom. Instead, it rewrites GetDataAsync into a state machine. This state machine is an object, generated behind the scenes, that holds the state of your asynchronous operation.

Here’s a conceptual breakdown of what the compiler generates:

  1. The State Machine Class: A hidden class is created, often named something like GetDataAsyncd__d (where d__d signifies a state machine for a d-method). This class implements IAsyncStateMachine.

  2. State Variables: This generated class will have fields to store:

    • The this reference if GetDataAsync is an instance method.
    • Any local variables that need to persist across await points (like id in our example).
    • The TaskCompletionSource<T> or Task<T> that represents the overall operation’s completion.
    • An integer field, let’s call it state, which tracks the current execution point. state = -1 typically means the method is starting.
  3. The MoveNext() Method: This is the heart of the state machine. It’s called repeatedly to advance the execution.

    • When MoveNext() is called, it checks the state variable.
    • Based on the state, it executes a specific block of code.
    • If an await is encountered, the method doesn’t block. Instead, it sets up a continuation (a callback) to be executed when the awaited operation completes. It then sets the state to the next logical step and returns. The MoveNext() method is then scheduled to be called again by the continuation.
    • If the await completes, the MoveNext() method is invoked again, picking up execution from where it left off, advancing the state to the next step.

Let’s trace GetDataAsync(1):

  1. Initial Call: GetDataAsync(1) is called. The state machine object is instantiated. state is initialized to -1. MoveNext() is called.
  2. State -1: The code before the first await runs: Console.WriteLine($"Fetching data for id: {id}");.
  3. Encounter await Task.Delay(1000):
    • The Task.Delay(1000) is initiated.
    • A continuation is attached to this Task.Delay. This continuation will eventually call MoveNext() again.
    • The current state is updated to 0 (representing the point after Task.Delay).
    • The MoveNext() method returns. The caller receives a Task<string> representing the ongoing operation. The thread is now free to do other work.
  4. Task.Delay Completes (after 1000ms): The continuation is triggered, which calls MoveNext() on the state machine object.
  5. State 0: The code after the await runs: Console.WriteLine($"Data fetched for id: {id}");. The return value "Data for {id}" is prepared.
  6. Method Completion: The state machine marks the overall Task<string> as completed with the returned value.

The crucial part is that await doesn’t pause the thread. It yields control back to the caller and schedules the rest of the method to run later. The state machine object is what holds all the context (local variables, current position) between these asynchronous yields.

The TaskCompletionSource<T> (or similar mechanisms) is what allows the state machine to signal completion to the original caller. When the state machine finishes, it calls SetResult() on its TaskCompletionSource, which in turn completes the Task<string> that was returned initially.

The most surprising thing about async/await is that the await keyword itself doesn’t schedule anything. It merely yields control and sets up a continuation that will later cause MoveNext() to be called again. The actual scheduling of the continuation happens via the awaitable’s GetAwaiter().OnCompleted() method, which is often implemented by the underlying Task or ValueTask infrastructure, potentially interacting with thread pools or synchronization contexts.

The fundamental problem async/await solves is managing complex sequences of I/O-bound operations without blocking threads, which is critical for scalability in applications like web servers. It makes asynchronous code look and feel synchronous, drastically reducing the complexity of callbacks and continuations. You control the flow by structuring your code linearly, and the compiler handles the state management.

A common misconception is that async methods always run on a different thread. This is only true if the awaited operation itself requires a different thread (e.g., Task.Run) or if a SynchronizationContext (like in UI applications) forces continuations onto a specific thread. By default, if an await completes synchronously, the rest of the async method will run on the same thread that awaited it.

The next concept you’ll likely encounter is how ConfigureAwait(false) impacts the execution context of continuations.

Want structured learning?

Take the full Csharp course →