Part 17: Deep Dive into Async/Await: Building It from Scratch in C#
Async/await is one of the most powerful features in C#, yet it often feels like “magic” to many developers. After exploring the deep internals of how the .NET runtime handles asynchronous operations—inspired by the brilliant insights from Stephen Toub—it’s clear that there’s a lot of engineering under the hood to make this feature so seamless.
In this post, we’ll peel back the layers and understand how to build the async/await machinery from scratch.
1. The Async/Await “Magic”
When you mark a method with async and use the await keyword, the C# compiler doesn’t just “pause” the thread. Instead, it transforms your method into a State Machine.
This transformation allows the thread to be released back to the thread pool while waiting for an operation (like an I/O request) to complete, and then resume execution exactly where it left off. But before we look at the state machine, we need to understand where these “released” threads come from and how they are managed.
2. Building a Simple ThreadPool
To understand how tasks are scheduled, we can build a primitive ThreadPool. But first, we need to understand the fundamental concept of a Thread and its building blocks: Delegates, Action, and BlockingCollection.
The Workforce: What is a Thread?
For a junior developer, a Thread can be thought of as a single “worker” inside your computer.
- The Worker (Thread): It follows a set of instructions (your code) one line at a time.
- The Kitchen (CPU Core): The place where the worker does the actual work. A computer with 8 cores is like a kitchen with 8 stations.
- The Problem: In a standard synchronous program, if a worker is waiting for the “oven” (a database or a file) to finish, they just stand there doing nothing. They are “blocked.”
In the context of async/await, we want to make our workers as efficient as possible. Instead of standing around, we want them to:
- Start the “oven” (the asynchronous request).
- Set a “timer” (the Task) on the counter.
- Go help someone else with another order (another task).
- Come back only when the timer “dings” (the completion signal).
This is why we use a ThreadPool—a group of workers waiting to pick up these “timers” and finish the work. Let’s see how they work together:
The Building Blocks: Delegates and Actions
In C#, a delegate is a type that represents a reference to a method with a particular parameter list and return type. It allows you to treat a method as a variable—you can pass it as a parameter, return it from a function, or store it in a field.
A specialized version of this is the Action delegate. It is a built-in type in .NET that points to a method that takes no parameters and returns void. In our ThreadPool, every “work item” we want to execute is represented as an Action.
The Container: BlockingCollection
The BlockingCollection<T> is a thread-safe collection class that implements the Producer-Consumer pattern. It handles the “wait if empty” and “signal when added” logic automatically. When a thread tries to consume from an empty BlockingCollection, it is efficiently “blocked” (suspended) until another thread adds an item.
Now, let’s see how they work together in our pool:
public static class MyThreadPool
{
private static readonly BlockingCollection<Action> _workItems = new();
static MyThreadPool()
{
// Start a few long-running threads to process the queue
for (int i = 0; i < Environment.ProcessorCount; i++)
{
new Thread(() =>
{
foreach (var action in _workItems.GetConsumingEnumerable())
{
action();
}
}) { IsBackground = true }.Start();
}
}
public static void QueueUserWorkItem(Action action) => _workItems.Add(action);
}
This pool of threads is the engine that drives asynchronous continuations. When a task completes, it doesn’t necessarily resume on the same thread; it just needs a thread to continue its work.
3. A Bare-Bones Task Implementation
A Task is essentially a state container that holds either the result of an operation or the reason it failed. Most importantly, it stores a continuation—a piece of code to run once the task is finished.
public class MyTask
{
private bool _completed;
private Action _continuation;
private ExecutionContext _context;
public bool IsCompleted => _completed;
public void SetResult()
{
_completed = true;
if (_continuation != null)
{
// Execute the continuation on our custom ThreadPool
if (_context != null)
{
MyThreadPool.QueueUserWorkItem(() =>
ExecutionContext.Run(_context, _ => _continuation(), null));
}
else
{
MyThreadPool.QueueUserWorkItem(_continuation);
}
}
}
public void OnCompleted(Action continuation)
{
_continuation = continuation;
// Capture the current context to flow it to the thread pool
_context = ExecutionContext.Capture();
}
}
4. ExecutionContext: Flowing the State
One of the most complex parts of the .NET runtime is ensuring that “ambient” state—like AsyncLocal<T>, security identities, and culture—follows the execution across thread boundaries. This is the job of the ExecutionContext.
What is it?
Think of ExecutionContext as a container that captures all the relevant environment state of a thread. In a synchronous world, this state just lives on the thread. But in an asynchronous world, a single logical operation might start on Thread A, pause at an await, and resume on Thread B. Without ExecutionContext, any data in an AsyncLocal would be lost when switching threads.
How it’s used in the ThreadPool
In the real .NET ThreadPool, ExecutionContext flow is handled automatically by default. When you call ThreadPool.QueueUserWorkItem, the runtime performs the following:
- Captures the
ExecutionContextfrom the calling thread. - Stores this context alongside the delegate in the queue.
- When a
ThreadPoolthread is ready to execute the delegate, it first restores the capturedExecutionContextonto the new thread. - Executes the delegate.
- Clears the context after the delegate finishes, so the thread is “clean” for the next work item.
In our “scratch” implementation in Section 3, we mirrored this behavior manually:
// 1. Capture the context
_context = ExecutionContext.Capture();
// 2. Later, run the continuation within that context
ExecutionContext.Run(_context, _ => _continuation(), null);
This “Capture and Run” pattern is the secret sauce that makes AsyncLocal<T> work. It ensures that your CorrelationId, UserToken, or TransactionScope is always available, no matter how many times your code “hops” between different threads.
5. The Awaiter Pattern
The await keyword is not tied specifically to the Task class. You can await anything that follows the Awaiter Pattern. To be “awaitable”, a type must have a GetAwaiter() method that returns an object (the “awaiter”) with the following members:
bool IsCompleted { get; }: Tells the compiler if the operation is already finished.void OnCompleted(Action continuation): Tells the awaiter what to do when the operation completes.T GetResult(): Returns the result of the operation or throws any exceptions that occurred.
A Simple Custom Awaiter
Here is how you can make a simple integer awaitable:
public static class IntExtensions
{
public static MyIntAwaiter GetAwaiter(this int seconds) => new MyIntAwaiter(seconds);
}
public struct MyIntAwaiter : System.Runtime.CompilerServices.INotifyCompletion
{
private readonly int _seconds;
public MyIntAwaiter(int seconds) => _seconds = seconds;
public bool IsCompleted => false;
public void OnCompleted(Action continuation)
{
Task.Delay(TimeSpan.FromSeconds(_seconds)).ContinueWith(_ => continuation());
}
public void GetResult() { }
}
// Now you can do this:
// await 5;
6. The State Machine
The heart of the async/await concept is the generated state machine. When the compiler sees an async method, it wraps your code into a struct that implements IAsyncStateMachine.
Why a struct?
If the method completes synchronously (the “Fast Path”), the struct remains on the stack, avoiding a heap allocation. If the operation truly becomes asynchronous, the AsyncMethodBuilder will box this struct onto the heap to preserve the state until the task completes.
The Anatomy of the State Machine
The generated state machine handles several critical tasks:
- State Management: It uses an internal
intfield (often called<>1__state) to keep track of where it is.-1: Running / Not yet started.0, 1, 2...: Suspended at anawaitpoint.-2: Completed.
- Variable Lifting: Local variables are no longer stored on the stack. They are “lifted” into fields of the state machine struct so their values persist across
awaitpoints. - The
MoveNext()Method: This is the entry point called every time the state machine needs to advance. It contains a giantswitchstatement based on the current state.
How it Works Together
When an await is encountered:
- The state machine checks
awaiter.IsCompleted. - If true, it continues executing synchronously (Fast Path).
- If false, it:
- Updates the state field to a new value (e.g.,
0). - Calls
builder.AwaitOnCompleted(ref awaiter, ref this). - Returns from
MoveNext, releasing the current thread.
- Updates the state field to a new value (e.g.,
- When the awaited operation completes, the awaiter calls the continuation, which eventually calls
MoveNextagain. Theswitchstatement jumps to the state0, and the method resumes exactly where it left off.
7. Method Builders
The AsyncMethodBuilder is the bridge between the state machine and the resulting task. Its role is to:
Create(): Instantiate the builder.Start(ref TStateMachine stateMachine): Begin executing the state machine.SetResult(TResult result)/SetException(Exception exception): Complete the task with a value or failure.AwaitOnCompleted/AwaitUnsafeOnCompleted: Hook up the continuation when anawaitis hit.
The builder also handles the “heavy lifting” of catching exceptions from the state machine’s MoveNext method and ensuring they are correctly propagated to the Task. In modern .NET, builders are highly optimized, often using object pooling to avoid allocations for common return types.
8. SynchronizationContext and ConfigureAwait(false)
While ExecutionContext captures “who” is running (state, security, culture), SynchronizationContext captures “where” the continuation should run.
- In UI Apps (WinForms/WPF): The
SynchronizationContextensures that after anawait, the code resumes on the UI thread so you can safely update the UI. - In ASP.NET (Legacy): It ensured the request context was available and serialized requests.
- In .NET Core/5+ Console/Web: There is usually no
SynchronizationContext, so continuations run on the ThreadPool.
The Power of .ConfigureAwait(false)
When you use .ConfigureAwait(false), you are telling the awaiter: “I don’t need to resume on the captured SynchronizationContext. Just pick any available thread from the ThreadPool.”
Why use it?
- Performance: Switching back to a specific context (like the UI thread) has overhead. If the rest of your method doesn’t need that context, you’re wasting resources.
- Deadlock Prevention: In environments with a
SynchronizationContextthat only allows one thread at a time (like legacy ASP.NET or UI apps), blocking the “captured” thread while waiting for a task that is trying to resume on that same thread causes a deadlock.
When to use it?
- ✅ In Libraries: General-purpose libraries should almost always use
.ConfigureAwait(false). You don’t know if your library will be used in a UI app, and you want to be as efficient and “neutral” as possible. - ✅ In Backend Services (.NET Core+): While not strictly required for deadlock prevention in modern ASP.NET Core, it’s still a good habit for performance and for code that might be shared with other environments.
When NOT to use it?
- ❌ In UI Code: If you need to update a
LabelorButtonafter anawait, you must resume on the UI thread. Do NOT use.ConfigureAwait(false)there. - ❌ When you need the Context: If your code relies on
HttpContext.Current(in legacy ASP.NET), you need the context to follow you.
How to use it wisely
A common pattern in libraries is to apply it to every await:
public async Task<string> GetResultAsync()
{
var data = await _api.FetchAsync().ConfigureAwait(false);
return Process(data);
}
Remember: .ConfigureAwait(false) only affects the await it is attached to. If you have multiple await calls in a method, you typically want to use it on all of them in library code.
9. Task vs. ValueTask: Choosing the Right Tool
While Task<T> is the workhorse of asynchronous programming in .NET, it is a class, which means every time you return one, an object is allocated on the heap. In high-performance scenarios, these allocations add up and put pressure on the Garbage Collector.
What is ValueTask?
ValueTask<T> is a discriminated union struct that can represent either a completed result or an ongoing Task. Because it’s a struct, it can often be handled on the stack, avoiding heap allocation when the operation completes synchronously (the “Fast Path”).
When to use Task
- The default choice: If you are unsure, use
Task<T>. - Multiple awaits: If you need to await the same object multiple times.
- Concurrent execution: If you need to store tasks in a collection and await them later (e.g., with
Task.WhenAll). - Memory doesn’t matter: In most application-level code where extreme performance isn’t the primary goal.
When to use ValueTask
- High-frequency calls: In loops or methods called thousands of times per second.
- Frequent synchronous completion: If the method often returns immediately (e.g., from a cache or a pre-filled buffer).
- Asynchronous interface methods: When implementing an interface where some implementations might be synchronous.
⚠️ Warning:
ValueTaskhas strict usage rules. You cannot await it twice, callAsTask()multiple times, or use it withTask.WhenAlldirectly without converting it to aTaskfirst. Doing so can lead to undefined behavior or race conditions.
10. Best Practices for Async/Await
To write robust and maintainable asynchronous code, follow these industry-standard best practices:
- Async All the Way: Don’t mix synchronous and asynchronous code. Avoid using
.Resultor.Wait(), as they can cause deadlocks (especially in environments with aSynchronizationContext). - Avoid
async void: Only useasync voidfor event handlers. For everything else, return aTask. Exceptions in anasync voidmethod cannot be caught by the caller and will often crash the process. - Use
Task.WhenAllfor Parallelism: If you have multiple independent tasks, start them all and then await them together. This is much faster than awaiting them one by one.// Better var task1 = DoWorkA(); var task2 = DoWorkB(); await Task.WhenAll(task1, task2); - Always Provide a
CancellationToken: Allow your asynchronous methods to be canceled. This is vital for maintaining a responsive UI and for cleaning up resources in web requests (see Section 12). - Configure Continuations Wisely: Use
.ConfigureAwait(false)in library code to avoid the overhead of theSynchronizationContextand prevent deadlocks (see Section 8 for details).
11. Advanced Optimizations
Once you understand the basics, you can further tune your code for maximum efficiency:
Return the Task Directly
If the last line of your method is an await, and you don’t have any code after it (including using blocks or try-catch), you can sometimes return the Task directly and remove the async keyword. This avoids the overhead of the state machine.
// Instead of:
public async Task<Data> GetDataAsync() => await _repo.FetchAsync();
// Consider:
public Task<Data> GetDataAsync() => _repo.FetchAsync();
Task.Yield()
In some cases, a method might be “too fast” and block the calling thread longer than expected. await Task.Yield() forces the method to become asynchronous, returning control to the caller and scheduling the rest of the work on the ThreadPool.
Pooling and Reuse
Modern .NET uses IValueTaskSource and object pooling internally to reuse the objects that back ValueTask. This is how Socket and Stream operations in .NET 6+ achieve near-zero allocations for I/O.
12. Cancellation: The Cooperative Pattern
In a real-world application, asynchronous operations don’t always run to completion. A user might close a window, navigate away from a page, or a timeout might occur. To handle these scenarios gracefully, .NET uses the CancellationToken pattern.
What is a CancellationToken? (For Beginners)
Imagine you hire a contractor to paint your house (the asynchronous task). You give them a walkie-talkie (the CancellationToken).
- If you decide you don’t want the house painted anymore, you shout into your base station (the
CancellationTokenSource) “Stop painting!”. - The contractor occasionally checks their walkie-talkie. If they hear the stop signal, they pack up their brushes and leave.
This is called Cooperative Cancellation. The task isn’t “killed” forcefully from the outside (which is dangerous); instead, the task is asked to stop, and it chooses to stop itself at a safe point.
The Two Parts of Cancellation
CancellationTokenSource(CTS): This is the object that creates the token and triggers the cancellation signal usingcts.Cancel(). It’s the “remote control.”CancellationToken: This is the lightweight struct you pass into your methods. It can only “listen” for the signal; it cannot trigger it.
How to Use It
Here is the standard pattern for implementing cancellation in your code:
public async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 100; i++)
{
// 1. Check if cancellation was requested
token.ThrowIfCancellationRequested();
// 2. Pass the token down to other async methods
await Task.Delay(1000, token);
Console.WriteLine($"Progress: {i}%");
}
}
Key Methods to Know
token.ThrowIfCancellationRequested(): The most common way to stop. It throws anOperationCanceledExceptionif the signal was sent.token.IsCancellationRequested: A boolean property you can check if you want to perform custom cleanup before stopping.token.Register(Action callback): Allows you to run a specific piece of code the moment cancellation occurs (useful for wrapping legacy non-cancellable APIs).
By always accepting and honoring a CancellationToken, you ensure your application remains responsive and doesn’t waste resources on work that is no longer needed.
13. Real-World Scenarios: Where Async/Await Shines
To wrap up, let’s look at how these concepts translate into the code you write every day. Most of your asynchronous work will fall into two categories: I/O-Bound (waiting for something external) and CPU-Bound (performing heavy calculations).
A. Calling a Web API (HttpClient)
This is the most common use case. When you call an external service, your thread shouldn’t just sit there waiting for the bytes to come back over the network.
public async Task<User> GetUserAsync(int id, CancellationToken ct)
{
// HttpClient is designed for async.
// The thread is released back to the pool during the network wait.
var response = await _httpClient.GetAsync($"https://api.example.com/users/{id}", ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<User>(cancellationToken: ct);
}
B. Database Operations (EF Core)
Database queries often take time due to disk I/O or network latency. Modern ORMs like Entity Framework Core provide asynchronous versions of almost every method.
public async Task<List<Order>> GetRecentOrdersAsync(int customerId)
{
using var context = new MyDbContext();
// ToListAsync() allows the thread to handle other requests while the DB processes.
return await context.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.OrderDate)
.Take(10)
.ToListAsync();
}
C. UI Responsiveness (WPF/WinForms)
In UI applications, if you perform a long-running task on the main thread, the app “freezes.” async/await allows you to offload that work and resume on the UI thread automatically.
private async void OnUploadButtonClick(object sender, EventArgs e)
{
StatusLabel.Text = "Uploading...";
UploadButton.IsEnabled = false;
try
{
// Offload heavy processing to a background thread
await Task.Run(() => ProcessLargeFile());
// Resumes here on the UI thread thanks to SynchronizationContext
StatusLabel.Text = "Upload Complete!";
}
catch (Exception ex)
{
StatusLabel.Text = "Error: " + ex.Message;
}
finally
{
UploadButton.IsEnabled = true;
}
}
D. High-Throughput Web APIs (ASP.NET Core)
In a web server, threads are a limited resource. If every request blocks a thread while waiting for a database, the server will quickly run out of threads (Thread Pool Starvation). By using async/await, a single server can handle thousands of concurrent requests with just a few dozen threads.
14. Why Does This Matter?
Understanding these internals helps you write better code:
- Avoid Deadlocks: By understanding how
SynchronizationContextandConfigureAwait(false)interact. - Minimize Allocations: By using
ValueTaskand understanding the “Fast Path” optimization. - Debug Complex Flows: Knowing how
ExecutionContextpreserves state across thread boundaries makes it easier to traceAsyncLocalvalues. - Write Better APIs: By correctly implementing cancellation and choosing between
TaskandValueTask.
Conclusion
Async/await is a masterpiece of compiler and runtime engineering. By transforming imperative code into a state-driven asynchronous flow, C# allows us to write highly scalable applications without the complexity of manual callback management.
The next time you type await, remember the state machine working tirelessly behind the scenes!
References & Further Reading
- How Async/Await Really Works — Stephen Toub’s deep dive on the Microsoft .NET Blog.
- Deep .NET: Writing async/await from scratch in C# with Stephen Toub and Scott Hanselman — A comprehensive video tutorial on YouTube.
- The Microsoft .NET Documentation — Asynchronous programming with async and await.
Leave a comment