Why Asynchronous Programming Matters
Building responsive and scalable ASP.NET Core applications requires understanding how to handle concurrent operations efficiently. When your app processes database queries, calls external APIs, or performs file operations, proper async programming lets you handle more requests with fewer resources.
The async/await pattern and Task Parallel Library (TPL) are your tools for writing concurrent code that stays readable and maintainable. Instead of blocking threads while waiting for operations to complete, your app can handle other work, improving throughput and response times.
You'll learn how to use async/await correctly in controllers, implement parallel processing with TPL, avoid common pitfalls like deadlocks, and optimize your app's performance through smart concurrency patterns.
Understanding Async and Await
The async and await keywords let you write asynchronous code that reads like synchronous code. When you mark a method as async, you can use await to pause execution until an asynchronous operation completes, without blocking the thread.
Here's the key difference: when a thread hits an await on an incomplete task, it returns to the thread pool instead of waiting. Once the awaited operation finishes, the method resumes on an available thread. This frees up threads to handle other requests.
using Microsoft.AspNetCore.Mvc;
public class ProductsController : ControllerBase
{
private readonly ApplicationDbContext _context;
public ProductsController(ApplicationDbContext context)
{
_context = context;
}
// Synchronous version - blocks the thread
[HttpGet("sync")]
public IActionResult GetProductsSync()
{
var products = _context.Products.ToList();
return Ok(products);
}
// Asynchronous version - releases the thread while waiting
[HttpGet("async")]
public async Task<IActionResult> GetProductsAsync()
{
var products = await _context.Products.ToListAsync();
return Ok(products);
}
}
In the async version, when the database query runs, the thread handling this request returns to the pool. Other requests can use that thread while the database works. When results arrive, the method resumes on whatever thread is available.
Common Async Patterns in ASP.NET Core
You'll frequently encounter situations where you need to run multiple async operations. Understanding how to compose these operations efficiently makes a big difference in performance.
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
private readonly IInventoryService _inventoryService;
private readonly IShippingService _shippingService;
// Sequential execution - slow
public async Task<IActionResult> ProcessOrderSequential(int orderId)
{
var order = await _orderService.GetOrderAsync(orderId);
var inventory = await _inventoryService.CheckStockAsync(order.ProductId);
var shipping = await _shippingService.CalculateShippingAsync(order.Address);
return Ok(new { order, inventory, shipping });
}
// Parallel execution - fast
public async Task<IActionResult> ProcessOrderParallel(int orderId)
{
var order = await _orderService.GetOrderAsync(orderId);
// Start all operations simultaneously
var inventoryTask = _inventoryService.CheckStockAsync(order.ProductId);
var shippingTask = _shippingService.CalculateShippingAsync(order.Address);
// Wait for both to complete
await Task.WhenAll(inventoryTask, shippingTask);
return Ok(new
{
order,
inventory = inventoryTask.Result,
shipping = shippingTask.Result
});
}
}
The parallel version runs independent operations concurrently. If each service call takes 500ms, the sequential approach takes 1500ms total, while the parallel approach takes only 1000ms because the last two operations run simultaneously.
Working with Task Parallel Library
The Task Parallel Library (TPL) provides powerful tools for parallel processing of CPU-bound operations. While async/await handles I/O-bound work, TPL helps you process collections in parallel or execute multiple CPU-intensive operations simultaneously.
using System.Collections.Concurrent;
public class DataProcessingService
{
// Process items sequentially
public List<ProcessedData> ProcessDataSequential(List<RawData> items)
{
var results = new List<ProcessedData>();
foreach (var item in items)
{
var processed = PerformComplexCalculation(item);
results.Add(processed);
}
return results;
}
// Process items in parallel using Parallel.ForEach
public List<ProcessedData> ProcessDataParallel(List<RawData> items)
{
var results = new ConcurrentBag<ProcessedData>();
Parallel.ForEach(items, item =>
{
var processed = PerformComplexCalculation(item);
results.Add(processed);
});
return results.ToList();
}
// Process items in parallel with degree of parallelism control
public List<ProcessedData> ProcessDataWithOptions(List<RawData> items)
{
var results = new ConcurrentBag<ProcessedData>();
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount
};
Parallel.ForEach(items, options, item =>
{
var processed = PerformComplexCalculation(item);
results.Add(processed);
});
return results.ToList();
}
private ProcessedData PerformComplexCalculation(RawData data)
{
// Simulate CPU-intensive work
return new ProcessedData
{
Value = Math.Pow(data.Value, 2) * Math.PI
};
}
}
Use ConcurrentBag or other thread-safe collections when adding results from parallel operations. Regular collections like List aren't thread-safe and will throw exceptions if accessed from multiple threads simultaneously.
Leveraging PLINQ for Parallel Queries
Parallel LINQ (PLINQ) extends LINQ to automatically parallelize query execution. It's particularly useful when you need to transform or filter large collections using CPU-intensive operations.
public class ReportService
{
// Regular LINQ - sequential processing
public List<CustomerReport> GenerateReportsSequential(List<Customer> customers)
{
return customers
.Where(c => c.IsActive)
.Select(c => new CustomerReport
{
CustomerId = c.Id,
TotalRevenue = CalculateRevenue(c),
RiskScore = CalculateRiskScore(c)
})
.ToList();
}
// PLINQ - parallel processing
public List<CustomerReport> GenerateReportsParallel(List<Customer> customers)
{
return customers
.AsParallel()
.Where(c => c.IsActive)
.Select(c => new CustomerReport
{
CustomerId = c.Id,
TotalRevenue = CalculateRevenue(c),
RiskScore = CalculateRiskScore(c)
})
.ToList();
}
// PLINQ with ordered results
public List<CustomerReport> GenerateReportsOrdered(List<Customer> customers)
{
return customers
.AsParallel()
.AsOrdered()
.Where(c => c.IsActive)
.Select(c => new CustomerReport
{
CustomerId = c.Id,
TotalRevenue = CalculateRevenue(c),
RiskScore = CalculateRiskScore(c)
})
.ToList();
}
// PLINQ with degree of parallelism
public List<CustomerReport> GenerateReportsControlled(List<Customer> customers)
{
return customers
.AsParallel()
.WithDegreeOfParallelism(4)
.Where(c => c.IsActive)
.Select(c => new CustomerReport
{
CustomerId = c.Id,
TotalRevenue = CalculateRevenue(c),
RiskScore = CalculateRiskScore(c)
})
.ToList();
}
private decimal CalculateRevenue(Customer customer)
{
// Complex calculation
return customer.Orders.Sum(o => o.Total);
}
private double CalculateRiskScore(Customer customer)
{
// Complex risk algorithm
return customer.Orders.Count * 0.5;
}
}
PLINQ works best with CPU-intensive operations on large collections. For small collections or simple operations, the overhead of parallelization can actually make things slower. Always measure performance to ensure parallel processing helps your specific scenario.
Implementing Cancellation Tokens
Cancellation tokens let you cancel long-running async operations gracefully. This is important for scenarios where users navigate away or request timeouts occur. ASP.NET Core automatically provides cancellation tokens when requests are aborted.
public class DataController : ControllerBase
{
private readonly IDataService _dataService;
// ASP.NET Core provides the cancellation token automatically
[HttpGet("process")]
public async Task<IActionResult> ProcessDataAsync(CancellationToken cancellationToken)
{
try
{
var data = await _dataService.FetchLargeDatasetAsync(cancellationToken);
var processed = await ProcessAsync(data, cancellationToken);
return Ok(processed);
}
catch (OperationCanceledException)
{
return StatusCode(499, "Request cancelled by client");
}
}
private async Task<ProcessedData> ProcessAsync(
RawData data,
CancellationToken cancellationToken)
{
var results = new List<int>();
for (int i = 0; i < data.Items.Count; i++)
{
// Check if cancellation requested
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
results.Add(data.Items[i] * 2);
}
return new ProcessedData { Results = results };
}
}
public class DataService : IDataService
{
private readonly HttpClient _httpClient;
public async Task<RawData> FetchLargeDatasetAsync(CancellationToken cancellationToken)
{
// Pass cancellation token to HttpClient
var response = await _httpClient.GetAsync(
"https://api.example.com/data",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RawData>(cancellationToken);
}
}
Always pass cancellation tokens through your async call chain. When a user cancels a request, you want the entire operation to stop, not just the top-level method. This prevents wasted processing and frees up resources quickly.
Avoiding Common Deadlocks
Deadlocks occur when async code blocks waiting for a task while the task waits for a context that's blocked. This happens most often when you mix blocking calls like .Result or .Wait() with async code.
public class DeadlockExample
{
// WRONG - This can deadlock
public string GetDataSync()
{
var data = GetDataAsync().Result; // Blocks waiting for async
return data;
}
// CORRECT - Use async all the way
public async Task<string> GetDataAsync()
{
await Task.Delay(1000);
return "Data";
}
// WRONG - Mixing blocking and async
public void ProcessData()
{
var task = ProcessAsync();
task.Wait(); // Can deadlock
}
// CORRECT - Stay async
public async Task ProcessDataAsync()
{
await ProcessAsync();
}
private async Task ProcessAsync()
{
await Task.Delay(500);
}
}
// ASP.NET Core example - avoiding deadlocks
public class SafeController : ControllerBase
{
private readonly IDataService _dataService;
// Always use async endpoints
[HttpGet]
public async Task<IActionResult> GetDataAsync()
{
var data = await _dataService.GetDataAsync();
return Ok(data);
}
// If you must call async from sync, use this pattern carefully
[HttpGet("sync")]
public IActionResult GetDataFallback()
{
// Only use in ASP.NET Core where there's no SynchronizationContext
var data = _dataService.GetDataAsync().GetAwaiter().GetResult();
return Ok(data);
}
}
In ASP.NET Core, there's no SynchronizationContext by default, which makes deadlocks less common than in older frameworks. However, you should still avoid blocking on async code. Use async all the way through your call stack for the best results.
Best Practices for Async Programming
Following these guidelines will help you write efficient, maintainable async code:
Use async for I/O operations: Database queries, file access, and HTTP calls benefit most from async. These operations spend time waiting for external resources, making them perfect candidates for async/await.
Avoid async void: Use async Task instead of async void everywhere except event handlers. Async void methods don't let you handle exceptions properly and can crash your app.
Don't block on async code: Never use .Result or .Wait() on tasks. These can cause deadlocks and defeat the purpose of async programming. If you need to call async code from sync code, restructure your app to be async throughout.
Return tasks directly when possible: If you're just awaiting one async call and returning its result, you can often return the task directly without async/await. This avoids creating an extra state machine.
public class BestPracticesController : ControllerBase
{
// GOOD - Return task directly when just awaiting and returning
[HttpGet("products/{id}")]
public Task<Product> GetProductAsync(int id)
{
return _repository.GetByIdAsync(id);
}
// GOOD - Use async when doing additional work
[HttpGet("products")]
public async Task<IActionResult> GetProductsAsync()
{
var products = await _repository.GetAllAsync();
return Ok(products);
}
// BAD - Don't use async void
public async void ProcessDataBad()
{
await _service.ProcessAsync();
}
// GOOD - Use async Task
public async Task ProcessDataGood()
{
await _service.ProcessAsync();
}
// GOOD - Use ValueTask for high-performance scenarios
public async ValueTask<int> GetCachedValueAsync(string key)
{
if (_cache.TryGetValue(key, out int value))
{
return value; // Synchronous path avoids allocation
}
return await FetchFromDatabaseAsync(key);
}
}
Consider ValueTask for hot paths: If your async method often completes synchronously (like returning cached data), ValueTask can reduce allocations. Use it carefully though, as it has some restrictions compared to regular Task.
Use Task.WhenAll for independent operations: When you have multiple async operations that don't depend on each other, start them all and wait with Task.WhenAll. This runs them concurrently instead of sequentially.
Don't forget cancellation tokens: Pass CancellationToken through your async methods. This lets you cancel long-running operations when requests are aborted, saving resources.