The async/await pattern in C# simplifies asynchronous programming, making code easier to read and maintain. However, improper use can lead to bugs, performance issues, and deadlocks. Here are some best practices to ensure you’re using async/await effectively:
Use async All the Way
Always propagate async methods throughout your call stack. Avoid blocking calls like .Result or .Wait() on asynchronous operations, which can cause deadlocks.
Bad example:
var result = GetDataAsync().Result;
Good example:
var result = await GetDataAsync();
Return Task, Not void
Asynchronous methods should return Task or Task<T> except for event handlers. Using void makes error handling difficult because it doesn’t return a task for the caller to observe.
Bad example:
async void ProcessDataAsync() {}
Good example:
async Task ProcessDataAsync() {}
Avoid Fire-and-Forget
Unobserved exceptions in fire-and-forget tasks can lead to silent failures. If you need fire-and-forget behavior, log errors or handle them explicitly.
Bad example:
ProcessDataAsync(); // Task not awaited, potential issues ignored
Good example:
ProcessDataAsync()
.ContinueWith(t => LogError(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
Use ConfigureAwait(false) Where Appropriate
By default, await captures the current synchronization context, which is unnecessary in libraries or non-UI code. Use ConfigureAwait(false) to improve performance and prevent deadlocks.
Example:
await ProcessDataAsync().ConfigureAwait(false);
Modern ASP.NET Core applications do not have a synchronization context, as the framework is designed to optimize for asynchronous operations. Because of this, using ConfigureAwait(false) is generally not required in ASP.NET Core apps.
If you’re writing reusable libraries (mainly ones used outside ASP.NET Core, like in desktop apps or legacy frameworks), using ConfigureAwait(false) is still a good practice to avoid inadvertently tying your code to a synchronization context.
Avoid Async Void Lambdas
Use async lambdas that return Task instead of void for better exception handling.
Bad example:
var _ = async () => { await ProcessDataAsync(); };
Good example:
var _ = async () => await ProcessDataAsync();
Respect Cancellation Token
When working with asynchronous operations, respect CancellationToken to allow graceful task termination.
async Task GetDataAsync(CancellationToken token)
{
await Task.Delay(1000, token);
}
Use ValueTask Sparingly
ValueTask can optimize frequent synchronous completions but introduces complexity. Stick to Task unless performance profiling justifies ValueTask.
Understand better ValueTask in my previous post.
Use WhenAll for Concurrent Tasks
Use Task.WhenAll for running many asynchronous tasks in parallel is often the better choice when you want to wait for multiple tasks to complete efficiently.
Example:
var result1 = await DoWorkAsync(1);
var result2 = await DoWorkAsync(2);
var result3 = await DoWorkAsync(3);
Better example:
List<Task<int>> tasks = [
DoWorkAsync(1),
DoWorkAsync(2),
DoWorkAsync(3)
];
var results = await Task.WhenAll(tasks);
Use IAsyncEnumerable for Asynchronous Streams
Using IAsyncEnumerable<T> is often the preferred approach for handling asynchronous streams in C#, especially when dealing with large datasets or streams of data that arrive over time. It allows you to process data lazily and asynchronously, making your code more efficient and memory-friendly compared to alternatives like returning a List<T> or Task<IEnumerable<T>>.
Example:
await foreach (var item in GetItemsAsync())
{
Console.WriteLine(item);
}
async IAsyncEnumerable<int> GetItemsAsync()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(100); // Simulate async work
yield return i;
}
}
Async in Sync Context
Calling an async method from a synchronous method is tricky and should generally be avoided because it can lead to deadlocks, unexpected behavior, or degraded performance. However, there are scenarios where it might be necessary.
Use .GetAwaiter().GetResult()
This is the safest option to call an async method synchronously without risking deadlocks, as it bypasses the SynchronizationContext.
Example:
void SyncMethod()
{
var result = ProcessDataAsync().GetAwaiter().GetResult();
}
Summary
By following these best practices, you can write cleaner, more reliable, and performant asynchronous code in C#. Always strive for clarity and handle edge cases to ensure your code behaves as expected.