Mastering Async/Await in C# with .NET Core
Rahul Sahay
Architect @IBM Software Labs (ISL) | Microservices | Arch/Design | Service Mesh | Cloud | Full Stack | DevOps( Docker,K8s) | ELK Stack| Azure | Dotnet | Angular | React | SpringBoot | Open AI | Gen AI
In the world of modern software development, writing asynchronous code is essential to ensure your applications are responsive and scalable. C# and .NET Core provide powerful tools for managing asynchronous operations using the async and await keywords. In this blog post, we will explore best practices for using async/await in C# with .NET Core, starting with simple examples and gradually moving towards more complex scenarios.
Why Use Async/Await?
Before diving into best practices, let’s briefly understand why we use async/await in C#:
Best Practices for Simple Async/Await Usage
1. Use Async Whenever Possible
Whenever you encounter I/O-bound operations, such as database queries, network requests, or file I/O, consider making the method async. For example:
using System;
using System.Net.Http;
using System.Threading.Tasks;
public class AsyncExample
{
public async Task<string> FetchDataAsync()
{
// Simulate an HTTP request
using (var client = new HttpClient())
{
var response = await client.GetAsync("https://jsonplaceholder.typicode.com/posts/1");
return await response.Content.ReadAsStringAsync();
}
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
var result = await example.FetchDataAsync();
Console.WriteLine(result);
}
}
Explanation:
Example 2: Using the “Async” Suffix
using System;
using System.Threading.Tasks;
public class AsyncExample
{
public async Task ProcessDataAsync()
{
// Simulate an async operation
await Task.Delay(1000); // Delay for 1 second
Console.WriteLine("Data processing completed.");
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
await example.ProcessDataAsync();
}
}
Explanation:
Example 3: Using ConfigureAwait(false) for Non-UI Threads
using System;
using System.Threading.Tasks;
public class AsyncExample
{
public async Task DoSomethingAsync()
{
await Task.Delay(1000).ConfigureAwait(false); // ConfigureAwait(false) for non-UI thread
Console.WriteLine("Operation completed.");
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
await example.DoSomethingAsync();
}
}
Explanation:
Example 4: Avoid Using async void
using System;
using System.Threading.Tasks;
public class AsyncExample
{
// Define an event
public event EventHandler<string> OperationCompleted;
public async Task DoSomethingAsync()
{
await Task.Delay(1000);
Console.WriteLine("Operation completed.");
// Raise the event when the operation is completed
OperationCompleted?.Invoke(this, "Operation completed.");
}
public async void HandleAsyncEvent()
{
await DoSomethingAsync();
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
// Subscribe to the event
example.OperationCompleted += (sender, message) =>
{
Console.WriteLine("Event handler: " + message);
};
await example.DoSomethingAsync(); // Good practice
example.HandleAsyncEvent(); // Only for asynchronous event handlers
}
}
Explanation:
With this complete event handler example, you can see how to use async/await in conjunction with events. When the asynchronous operation in DoSomethingAsync is finished, it raises the event, and the event handler in the Main method reacts to it by printing the message. This pattern is useful for asynchronous notification and event-driven programming.
Best Practices for More Complex Async Scenarios
5. Compose Async Methods
Compose multiple asynchronous operations using Task.WhenAll or Task.WhenAny to execute them concurrently or sequentially.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
public class AsyncExample
{
public async Task<string> FetchDataAsync()
{
await Task.Delay(2000); // Simulate data fetching
return "Data fetched successfully.";
}
public async Task<string> ProcessDataAsync()
{
await Task.Delay(1500); // Simulate data processing
return "Data processed successfully.";
}
public async Task<string> SaveDataAsync()
{
await Task.Delay(1000); // Simulate data saving
return "Data saved successfully.";
}
public async Task ProcessDataConcurrentlyAsync()
{
var tasks = new List<Task<string>> // Use Task<string> to store results
{
FetchDataAsync(),
ProcessDataAsync(),
SaveDataAsync()
};
// Start all tasks concurrently and wait for all to complete
string[] results = await Task.WhenAll(tasks);
// Process results or perform other operations
foreach (string result in results)
{
Console.WriteLine(result);
}
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
await example.ProcessDataConcurrentlyAsync();
}
}
Explanation:
This code demonstrates the power of composing asynchronous operations using Task.WhenAll. It allows you to execute multiple asynchronous tasks concurrently and efficiently wait for all of them to complete. This is especially useful in scenarios where you need to perform multiple asynchronous operations in parallel, such as fetching data from different sources, processing it, and saving it simultaneously.
6. Exception Handling
Handle exceptions properly in async code by using try-catch blocks. Avoid swallowing exceptions; instead, log or report them.
using System;
using System.Threading.Tasks;
public class AsyncExample
{
public async Task<int> DivideAsync(int dividend, int divisor)
{
try
{
// Simulate a potentially problematic async operation
await Task.Delay(1000); // Delay for 1 second
if (divisor == 0)
{
throw new DivideByZeroException("Divisor cannot be zero.");
}
return dividend / divisor;
}
catch (DivideByZeroException ex)
{
// Handle specific exception
Console.WriteLine("DivideByZeroException: " + ex.Message);
throw; // Rethrow the exception
}
catch (Exception ex)
{
// Handle general exception
Console.WriteLine("An error occurred: " + ex.Message);
throw; // Rethrow the exception
}
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
try
{
int result = await example.DivideAsync(10, 2);
Console.WriteLine("Result: " + result);
}
catch (Exception ex)
{
// Handle exceptions here
Console.WriteLine("Exception caught in Main: " + ex.Message);
}
}
}
Explanation:
领英推荐
In the Main method, we call DivideAsync with valid arguments (10 and 2) inside a try block. We handle exceptions in the catch block, where we print the exception message.
The code demonstrates how to handle both specific and general exceptions in async methods. It is important to catch and handle exceptions appropriately, whether they are specific to your operation or more general. Additionally, rethrowing exceptions using throw ensures that they are not swallowed, allowing higher-level code to handle them if necessary.
When you run this code, it will successfully perform the division operation, and you will see the “Result” printed to the console. However, if you change the divisor to 0, it will throw a DivideByZeroException, and you will see the appropriate exception handling messages printed to the console.
7. CancellationToken
Pass a CancellationToken to async methods when you need to cancel them gracefully. This is useful for scenarios like user-initiated cancellations.
using System;
using System.Threading;
using System.Threading.Tasks;
public class AsyncExample
{
public async Task DownloadFileAsync(string url, CancellationToken cancellationToken)
{
try
{
Console.WriteLine("Downloading file from " + url);
using (var client = new HttpClient())
{
// Simulate a long-running download
await Task.Delay(1000, cancellationToken); // Delay for 1 second, can be canceled
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("Download canceled.");
cancellationToken.ThrowIfCancellationRequested();
}
Console.WriteLine("Download completed.");
}
}
catch (OperationCanceledException ex)
{
Console.WriteLine("OperationCanceledException: " + ex.Message);
}
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
// Create a CancellationTokenSource to manage the cancellation
using (var cancellationTokenSource = new CancellationTokenSource())
{
// Simulate user-initiated cancellation after 500ms
cancellationTokenSource.CancelAfter(500);
try
{
await example.DownloadFileAsync("https://example.com/bigfile.zip", cancellationTokenSource.Token);
}
catch (Exception ex)
{
Console.WriteLine("Exception caught: " + ex.Message);
}
}
}
}
Explanation:
This example demonstrates how to use CancellationToken to gracefully cancel an asynchronous operation. It allows you to implement user-initiated cancellations or handle situations where you need to stop an ongoing operation without abruptly terminating the application. When you run this code, you will see the appropriate messages indicating download progress and cancellation.
8. ConfigureAwait(false) in Library Code
When writing reusable library code, use ConfigureAwait(false) liberally to prevent potential deadlocks in consumer applications. Leave the choice of synchronization context to the caller.
In .NET, asynchronous code execution can be associated with a synchronization context. The synchronization context determines how asynchronous operations are scheduled and executed. In most UI applications, there is a synchronization context that ensures that asynchronous code runs on the UI thread to update the user interface components.
However, in certain scenarios, especially when writing library code, using the synchronization context can lead to deadlocks or inefficient performance. This is because blocking the UI thread or other special threads can lead to unresponsiveness or degradation in application performance. To avoid this, you can use ConfigureAwait(false) when awaiting asynchronous operations in your library code.
using System;
using System.Threading.Tasks;
public class LibraryCode
{
public async Task<string> SomeLibraryMethod()
{
// Simulate an asynchronous operation
await Task.Delay(1000).ConfigureAwait(false);
// Return a result
return "Library operation completed.";
}
}
In this example, we have a library class called LibraryCode, and it contains a method called SomeLibraryMethod. Inside this method:
We perform an asynchronous operation using await Task.Delay(1000).ConfigureAwait(false);. By using ConfigureAwait(false), we explicitly specify that we don't want to capture the current synchronization context.
Now, let’s use this library in a consumer application:
using System;
using System.Threading.Tasks;
public class ConsumerApp
{
public async Task UseLibraryCode()
{
var library = new LibraryCode();
string result = await library.SomeLibraryMethod();
Console.WriteLine("Result: " + result);
}
}
In the consumer application, we create an instance of LibraryCode and call the SomeLibraryMethod asynchronously.
Explanation of ConfigureAwait(false) in Library Code
In summary, using ConfigureAwait(false) in library code is a good practice to prevent potential deadlocks and make your library more robust when used in various application contexts. It leaves the choice of synchronization context to the caller, allowing consumers to control how they want to handle asynchronous operations and avoiding unintended UI thread blocking or performance issues.
9. Profile and Optimize
Profile your async code to identify performance bottlenecks. Optimize CPU-bound operations by offloading them to a separate thread pool. In this best practice, we’ll explore how to offload CPU-bound operations to a separate thread pool to prevent blocking the main thread. Here’s a complete code snippet to illustrate this:
using System;
using System.Diagnostics;
using System.Threading.Tasks;
public class AsyncExample
{
public async Task HeavyCpuBoundOperationAsync()
{
var stopwatch = Stopwatch.StartNew();
// Offload a CPU-bound operation to a separate thread pool
await Task.Run(() =>
{
// Simulate a CPU-bound operation
for (int i = 0; i < 1000000; i++)
{
// Perform some heavy computation
Math.Sqrt(i);
}
});
stopwatch.Stop();
Console.WriteLine("CPU-bound operation completed in " + stopwatch.ElapsedMilliseconds + "ms");
}
public static async Task Main(string[] args)
{
var example = new AsyncExample();
Console.WriteLine("Starting CPU-bound operation...");
await example.HeavyCpuBoundOperationAsync();
Console.WriteLine("CPU-bound operation finished.");
// Continue with other asynchronous work or application logic
}
}
Explanation:
By offloading the CPU-bound operation to a separate thread pool using Task.Run, you can prevent it from blocking the main thread and keep your application responsive. Profiling the operation's execution time using a stopwatch helps you identify performance bottlenecks. It's important to note that not all operations should be offloaded; you should do so only for CPU-bound tasks where it makes sense. This best practice ensures that your application remains responsive while handling resource-intensive tasks efficiently.
Conclusion
Using async/await in C# with .NET Core can greatly improve the responsiveness and scalability of your applications. By following these best practices, you can write clean, efficient, and maintainable asynchronous code. Start with the simple examples and gradually incorporate these practices into your more complex async scenarios to ensure a smooth transition. Happy coding!
Having said that, now if you like to apply the best practices in a real world application while building live with me. Feel free to check my 32+ hours of Microservices course “Creating .Net Core Microservices using Clean Architecture”
You can read about the same in my blog here.
Thanks,
Happy Learning