Async / Await
What is Async/Await?
The async/await pattern in C# enables asynchronous programming - a way to write non-blocking code that improves application responsiveness and scalability. Instead of blocking a thread while waiting for I/O operations (like database calls, file access, or HTTP requests) to complete, asynchronous code frees up that thread to handle other work.
In the context of ASP.NET APIs, this is particularly important. Each incoming request is handled by a thread from the thread pool. If these threads are blocked waiting for I/O operations, you quickly exhaust the pool and the API can't handle new requests - even though the CPU is largely idle.
The Mechanics
The Task Type
At the heart of async/await is the Task type (and its generic counterpart Task<T>). A Task represents an asynchronous operation that may or may not return a value:
Task- represents an operation that doesn't return a valueTask<T>- represents an operation that returns a value of typeT
The async Keyword
The async keyword marks a method as asynchronous. It enables the use of the await keyword within that method and changes how the method's return type works:
// Synchronous method returning an int
public int GetNumber() => 42;
// Asynchronous method returning a Task<int>
public async Task<int> GetNumberAsync()
{
await Task.Delay(1000);
return 42;
}
Note that the async method returns Task<int>, but the method body returns int. The compiler handles wrapping the result in a Task.
The await Keyword
The await keyword is where the magic happens. When you await a Task:
- The method pauses execution at that point
- Control returns to the caller
- The thread is freed to do other work
- When the awaited operation completes, execution resumes from where it left off
public async Task<ActionResult<Platform>> GetPlatformById(int id)
{
// This line doesn't block the thread
var platform = await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
// Execution resumes here when the database query completes
if (platform == null)
return NotFound();
return Ok(platform);
}
Why Use Async in APIs?
Consider a traditional synchronous API endpoint handling a database query:
Request arrives → Thread assigned → Database query (thread blocked) → Response sent
That thread sits idle, consuming resources while waiting for the database. Under load, you run out of threads.
With async/await:
Request arrives → Thread assigned → Database query (thread released) →
Other requests handled by thread → Query completes → Thread assigned → Response sent
The thread can handle multiple requests during I/O operations, dramatically improving throughput.
Practical Benefits
- Scalability: Handle more concurrent requests with the same resources
- Resource efficiency: Avoid thread pool starvation
- Responsiveness: Application remains responsive during long-running operations
- Performance under load: Better CPU utilization when operations are I/O-bound
Common Patterns
Database Operations
Entity Framework Core provides async methods for all I/O operations:
// ❌ Synchronous - blocks the thread
var platforms = _context.Platforms.ToList();
// ✅ Asynchronous - frees the thread
var platforms = await _context.Platforms.ToListAsync();
Multiple Async Operations
When you need to perform multiple async operations, consider whether they can run in parallel:
// Sequential - waits for each operation to complete before starting the next
var platforms = await _context.Platforms.ToListAsync();
var commands = await _context.Commands.ToListAsync();
// Parallel - starts both operations simultaneously
var platformsTask = _context.Platforms.ToListAsync();
var commandsTask = _context.Commands.ToListAsync();
await Task.WhenAll(platformsTask, commandsTask);
var platforms = platformsTask.Result;
var commands = commandsTask.Result;
Use Task.WhenAll() when operations are independent and can execute concurrently.
SaveChangesAsync
When modifying data, always use SaveChangesAsync():
public async Task<ActionResult> CreatePlatform(Platform platform)
{
await _context.Platforms.AddAsync(platform);
await _context.SaveChangesAsync(); // Required to persist changes
return CreatedAtRoute(nameof(GetPlatformById), new { Id = platform.Id }, platform);
}
Common Pitfalls
Don't Mix Sync and Async
Avoid using .Result or .Wait() on Tasks - this blocks the thread and defeats the purpose:
// ❌ Anti-pattern - blocks the thread
var platform = _context.Platforms.FirstOrDefaultAsync(p => p.Id == id).Result;
// ✅ Correct - uses await
var platform = await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
Async All The Way
If you use async at any level, propagate it up the call stack. Don't break the chain:
// ❌ Breaking the async chain
public ActionResult<Platform> GetPlatform(int id)
{
var platform = GetPlatformFromDb(id).Result; // Blocks!
return Ok(platform);
}
// ✅ Async all the way
public async Task<ActionResult<Platform>> GetPlatform(int id)
{
var platform = await GetPlatformFromDb(id);
return Ok(platform);
}
private async Task<Platform> GetPlatformFromDb(int id)
{
return await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
}
Visual Representation
Here's a simplified view of what happens during an async database call:
Time →
Thread T1: [Request A arrives]--[await DB query A]---------[Resume A]--[Send response A]
Thread T1: [Handle Request B]--[...]
↑
|
Thread freed to handle other work
Without async/await, Thread T1 would be blocked during the entire database query, unable to handle Request B.
When NOT to Use Async
While async/await is powerful, it's not always necessary:
- CPU-bound operations: If the work is computational rather than I/O, async won't help (consider
Task.Run()for offloading to a background thread instead) - Simple synchronous operations: Property getters, simple calculations, or in-memory operations don't benefit from async
- Performance overhead: There is a small overhead to async operations - though negligible in most scenarios
Further Reading
For comprehensive documentation on asynchronous programming in C#, refer to the official Microsoft documentation: