Fundamentals – Part 13

11/02/2025
4 minute read

Async/Await, Under the Hood

C# made asynchronous programming approachable. With just async and await, developers could write non-blocking code that looks sequential. But behind this simplicity lies a sophisticated transformation the compiler performs — one that’s worth understanding if you care about performance, scalability, or debugging.

Let’s explore how async/await actually work, what they generate under the hood, and whether it’s possible to write an “async” method without either keyword.


Why Asynchronous Programming Exists

Before async/await, developers relied on threads or callbacks to keep applications responsive.

Threads

A thread represents an independent path of execution. You can start one manually:

new Thread(() => Console.WriteLine("Hello")).Start();

This works — but threads are expensive:

  • Each thread has its own stack (1MB+ by default).
  • Too many threads waste memory and context-switching time.
  • Threads block while waiting (I/O-bound work).

Tasks

To address this, .NET introduced Task and the ThreadPool — lightweight, reusable worker threads that handle asynchronous work efficiently:

Task.Run(() => Console.WriteLine("Hello"));

Still, chaining asynchronous work required callbacks or continuations:

DownloadAsync("file.txt")
    .ContinueWith(t => ProcessFile(t.Result))
    .ContinueWith(t => SaveResults(t.Result));

Readable? Barely. Maintainable? Definitely not.


The Arrival of async and await

C# 5 introduced async/await (2012, .NET 4.5) — a syntactic revolution.
You could now write asynchronous code that looked just like synchronous code:

public async Task<string> DownloadAndProcessAsync(string url)
{
    string content = await DownloadAsync(url);
    return await ProcessAsync(content);
}

But this code doesn’t block the thread. Instead, the compiler transforms it into a state machine — a structure that tracks where execution should continue once each awaited operation completes.


What Does the Compiler Actually Generate?

When you mark a method async, the compiler:

  1. Wraps your method body in a state machine.
  2. Rewrites every await as a continuation.
  3. Returns a Task (or Task<T>).

Here’s a simplified example:

public async Task<int> ExampleAsync()
{
    await Task.Delay(1000);
    return 42;
}

The compiler roughly translates it to something like this:

public Task<int> ExampleAsync()
{
    var tcs = new TaskCompletionSource<int>();
    Task.Delay(1000).ContinueWith(_ =>
    {
        tcs.SetResult(42);
    });
    return tcs.Task;
}

This isn’t exactly what happens (the real transformation uses an internal struct implementing IAsyncStateMachine), but conceptually, it’s the same idea:

  • Each await is turned into a continuation.
  • The method returns immediately with a Task representing the future result.
  • When the awaited operation completes, the continuation runs — often on a captured synchronization context (like the UI thread).

The Hidden Machinery: State Machines and Continuations

When compiled, an async method turns into:

  • A generated struct implementing IAsyncStateMachine.
  • A builder (e.g., AsyncTaskMethodBuilder or AsyncVoidMethodBuilder) that manages Task completion.
  • A MoveNext() method containing all your code — broken into states for each await.

Each await captures the current state, and when the awaited Task finishes, it calls MoveNext() again to continue execution. This is why async/await feels synchronous: the compiler handles the plumbing.


Can You Write an Async Method Without async/await?

Yes — in fact, that’s what the compiler does internally.
If your logic doesn’t require multiple awaits or local state between them, you can return a Task directly. Here’s a simple manual example:

public Task<int> ManualAsync()
{
    var tcs = new TaskCompletionSource<int>();
    Task.Delay(1000).ContinueWith(_ =>
    {
        tcs.SetResult(42);
    });
    return tcs.Task;
}

This function:

  • Starts an asynchronous delay.
  • Schedules a continuation to set the result.
  • Returns a Task<int> immediately.

This behaves just like an async method with await Task.Delay(1000); return 42;, but without compiler-generated machinery.

You can even use Task.Run directly if you’re just offloading CPU work:

public Task<int> ComputeAsync() => Task.Run(() => ExpensiveComputation());

This approach works well when your method has only one asynchronous operation and you don’t need multiple awaits.


When to Use async/await vs Manual Tasks

Scenario Recommended Approach
Single asynchronous operation Return the Task directly
Multiple awaits / stateful logic Use async/await
Performance-critical low-level code Consider manual continuations
Public APIs or app-level code Always prefer async/await for clarity

Manual task handling (e.g., using TaskCompletionSource) is occasionally used inside frameworks, pipelines, and performance-critical libraries — but for 99% of scenarios, async/await provides clarity and correctness.

Final Thoughts

async/await aren’t just syntactic sugar — they’re compiler-generated state machines that let you write asynchronous code that reads like synchronous code. But under the hood:

  • Each await creates a continuation.
  • async turns your method into a state machine.
  • You can achieve the same behavior manually using TaskCompletionSource, though it’s rarely worth the tradeoff.

Understanding how async/await works helps you reason about performance, deadlocks, and context switches — and lets you design APIs that scale efficiently.

References & Further Reading

An error has occurred. This application may no longer respond until reloaded. Reload x