Async Programming Deep Dive
Overview
Modern C# async/await is built on a sophisticated state machine pattern. Understanding how async works under the hood is crucial for writing high-performance, scalable applications.
Task vs ValueTask
Task<T>
Task is a reference type that represents an asynchronous operation. Every task allocation happens on the heap.
// ❌ Every call allocates a Task on the heap
public async Task<int> GetCountAsync()
{
await Task.Delay(10);
return 42;
}
When to use Task:
- Operation is truly asynchronous (I/O-bound)
- Result is different each time
- You need to await multiple times
- You need to access
.Resultor.Wait()
ValueTask<T>
ValueTask is a struct that can wrap either a T value or a Task\<T\>. Perfect for hot paths where results are often cached.
// ✅ No allocation when cached
public ValueTask<User> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return new ValueTask<User>(user); // No heap allocation!
return new ValueTask<User>(LoadUserFromDbAsync(id));
}
When to use ValueTask:
- Result might be immediately available (caching scenarios)
- Hot code paths (called frequently)
- You can guarantee single await
- Reducing allocation pressure is critical
ValueTask Constraints
⚠️ Critical Rules:
// ❌ DON'T: Await ValueTask multiple times
var task = GetUserAsync(1);
await task;
await task; // RUNTIME ERROR!
// ❌ DON'T: Store and await later
var task = GetUserAsync(1);
DoSomeWork();
await task; // Unsafe!
// ❌ DON'T: Use .Result
var result = GetUserAsync(1).Result; // Don't do this!
// ✅ DO: Await immediately
var user = await GetUserAsync(1);
Async State Machine
When you write async methods, the compiler transforms them into state machines:
// Your code
public async Task<string> DownloadAsync(string url)
{
var client = new HttpClient();
var response = await client.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
// Compiler generates (simplified)
public Task<string> DownloadAsync(string url)
{
var stateMachine = new <DownloadAsync>d__0
{
<>t__builder = AsyncTaskMethodBuilder<string>.Create(),
url = url,
<>1__state = -1
};
stateMachine.<>t__builder.Start(ref stateMachine);
return stateMachine.<>t__builder.Task;
}
Key points:
- Each
awaitis a state transition - Local variables are lifted to fields
- Try-catch blocks are transformed
- State is preserved across awaits
ConfigureAwait
ConfigureAwait(false) prevents capturing and resuming on the original synchronization context.
// ❌ In library code - captures context unnecessarily
public async Task<Data> LoadAsync()
{
return await _httpClient.GetFromJsonAsync<Data>("/api/data");
// Resumes on original context (UI thread, ASP.NET context, etc.)
}
// ✅ In library code - no context capture
public async Task<Data> LoadAsync()
{
return await _httpClient.GetFromJsonAsync<Data>("/api/data")
.ConfigureAwait(false);
// Can resume on any thread pool thread
}
When to use ConfigureAwait(false):
- ✅ Library code (no UI interaction)
- ✅ ASP.NET Core applications (no synchronization context by default)
- ✅ Performance-critical paths
When NOT to use:
- ❌ UI code (WPF, WinForms, MAUI) - need to return to UI thread
- ❌ When you need context (HttpContext in ASP.NET)
- ❌ Top-level application code
CancellationToken Best Practices
Pattern 1: Pass Through All Layers
public class UserService(IUserRepository repo)
{
public async Task<User> GetUserAsync(int id, CancellationToken ct = default)
{
// Pass to all async operations
return await repo.GetByIdAsync(id, ct);
}
}
public class UserRepository(DbContext db)
{
public async Task<User> GetByIdAsync(int id, CancellationToken ct = default)
{
return await db.Users
.FirstOrDefaultAsync(u => u.Id == id, ct);
}
}
Pattern 2: Check Cancellation in Loops
public async Task ProcessItemsAsync(List<int> items, CancellationToken ct)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested(); // Fast fail
await ProcessItemAsync(item, ct);
}
}
Pattern 3: Timeout Pattern
public async Task<Data> GetDataWithTimeoutAsync(string url)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
return await _httpClient.GetFromJsonAsync<Data>(url, cts.Token);
}
catch (OperationCanceledException)
{
throw new TimeoutException($"Request to {url} timed out");
}
}
Pattern 4: Linked Tokens
public async Task ProcessAsync(CancellationToken userToken)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userToken, timeoutCts.Token);
await DoWorkAsync(linkedCts.Token);
// Cancels if either user cancels OR timeout occurs
}
Async Void - The Danger Zone
// ❌ NEVER do this (except event handlers)
public async void ProcessDataAsync()
{
await LoadDataAsync();
// Exceptions here are unhandled and crash the app!
}
// ✅ Always return Task
public async Task ProcessDataAsync()
{
await LoadDataAsync();
// Exceptions can be caught by caller
}
// ✅ Only acceptable for event handlers
private async void Button_Click(object sender, EventArgs e)
{
try
{
await ProcessDataAsync();
}
catch (Exception ex)
{
// Must handle exceptions here!
ShowError(ex);
}
}
Parallel vs Concurrent Async
Sequential (Slow)
// Takes 3 seconds if each takes 1 second
var user = await GetUserAsync(1);
var orders = await GetOrdersAsync(1);
var products = await GetProductsAsync(1);
Concurrent (Fast)
// Takes ~1 second total
var userTask = GetUserAsync(1);
var ordersTask = GetOrdersAsync(1);
var productsTask = GetProductsAsync(1);
await Task.WhenAll(userTask, ordersTask, productsTask);
var user = userTask.Result; // Already completed
var orders = ordersTask.Result;
var products = productsTask.Result;
Parallel with Throttling
public async Task ProcessBatchAsync(List<int> ids, int maxConcurrency)
{
var semaphore = new SemaphoreSlim(maxConcurrency);
var tasks = ids.Select(async id =>
{
await semaphore.WaitAsync();
try
{
return await ProcessItemAsync(id);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
Common Pitfalls
Pitfall 1: Async Over Sync
// ❌ BAD: Blocking async code
public string GetData()
{
return GetDataAsync().Result; // DEADLOCK RISK!
}
// ✅ GOOD: Go async all the way
public async Task<string> GetDataAsync()
{
return await LoadDataAsync();
}
Pitfall 2: Fire and Forget
// ❌ BAD: Unobserved exception
public void StartProcess()
{
_ = ProcessDataAsync(); // Exception will be unhandled!
}
// ✅ GOOD: Track the task
public void StartProcess()
{
_backgroundTask = ProcessDataAsync();
}
public async Task StopAsync()
{
if (_backgroundTask != null)
await _backgroundTask;
}
Pitfall 3: Async Constructors
// ❌ Can't have async constructors
public class Service
{
public Service()
{
await InitializeAsync(); // Won't compile!
}
}
// ✅ Use factory pattern
public class Service
{
private Service() { }
public static async Task<Service> CreateAsync()
{
var service = new Service();
await service.InitializeAsync();
return service;
}
}
Performance Patterns
Pattern 1: ValueTask Caching
private static readonly ValueTask<bool> s_trueTask = new(true);
private static readonly ValueTask<bool> s_falseTask = new(false);
public ValueTask<bool> IsValidAsync(string input)
{
if (string.IsNullOrEmpty(input))
return s_falseTask; // No allocation
return ValidateAsync(input);
}
Pattern 2: Async Lazy
public class AsyncLazy\<T\>
{
private readonly Lazy<Task\<T\>> _instance;
public AsyncLazy(Func<Task\<T\>> factory)
{
_instance = new Lazy<Task\<T\>>(factory);
}
public Task\<T\> Value => _instance.Value;
}
// Usage
private readonly AsyncLazy<Config> _config = new(LoadConfigAsync);
public Task<Config> GetConfigAsync() => _config.Value;
Interview Questions
Q: What's the difference between Task and ValueTask?
A: Task is a reference type allocated on the heap. ValueTask is a struct that can wrap either a result or a Task, allowing zero-allocation returns when the result is immediately available. ValueTask can only be awaited once and shouldn't be stored.
Q: When should you use ConfigureAwait(false)?
A: Use it in library code and performance-critical paths where you don't need to return to the original synchronization context. Don't use it in UI code where you need to update UI controls, or when you need access to context-specific data.
Q: Why is async void dangerous?
A: Async void methods can't be awaited, exceptions can't be caught by callers, and unhandled exceptions crash the application. Only use for event handlers where you handle exceptions internally.
Q: How does the compiler transform async methods?
A: The compiler generates a state machine struct that implements IAsyncStateMachine. Each await point becomes a state, local variables are lifted to fields, and the AsyncTaskMethodBuilder orchestrates the state transitions.
Exercises
See exercises/day1-2.md for hands-on practice with:
- Implementing ValueTask caching
- Creating async retry with exponential backoff
- Building throttled parallel processing
- Proper cancellation handling
- ConfigureAwait demonstrations