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:
- Wraps your method body in a state machine.
- Rewrites every
awaitas a continuation. - Returns a
Task(orTask<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
awaitis turned into a continuation. - The method returns immediately with a
Taskrepresenting 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.,
AsyncTaskMethodBuilderorAsyncVoidMethodBuilder) 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
awaitcreates a continuation. asyncturns 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.


