The Power of Async/Await in C#

Artem A. Semenov
7 min readMay 4, 2023

--

Image from Unsplash

As the digital landscape continues to evolve, developers are increasingly faced with the challenge of creating applications that provide seamless, responsive user experiences. Enter async/await, a powerful feature of C# that revolutionizes asynchronous programming and paves the way for more efficient and performant applications. In this article, we’ll delve into the mechanics of async/await in C#, explore its advantages, and examine real-world examples of its implementation. By the end, you’ll have a solid understanding of async/await and be well-equipped to harness its power in your own C# projects.

Background Information: Understanding Asynchronous Programming

To fully appreciate the benefits of async/await, it’s important to first understand the concept of asynchronous programming. Traditionally, software has been written using synchronous, or blocking, code. In this approach, each operation must complete before the next one begins, which can lead to performance issues when certain tasks take a long time to finish (e.g., file I/O or network requests).

Asynchronous programming, on the other hand, allows multiple tasks to run concurrently without blocking the main thread of execution. This can significantly improve the performance and responsiveness of applications, particularly in scenarios where tasks can be performed independently or are time-consuming.

In C#, asynchronous programming has evolved over time, with various techniques being introduced to facilitate concurrent task execution. The introduction of async/await in C# 5.0 represented a major breakthrough, making it much easier for developers to write and manage asynchronous code.

The Mechanics of Async/Await in C#

Async/await is a pair of keywords in C# that simplify the process of writing and managing asynchronous code. By leveraging the Task Parallel Library (TPL) and language features, async/await enables developers to write cleaner, more readable, and maintainable asynchronous code. Let’s explore the mechanics of async/await and illustrate its usage with examples.

The async Keyword

The async keyword is used to mark a method as asynchronous. This indicates to the compiler that the method contains asynchronous operations and will return a Task or Task<T> object. The Task object represents an ongoing operation that can be awaited using the await keyword.

Example:

public async Task<string> FetchDataAsync()
{
// Asynchronous implementation here
}

The await Keyword

The await keyword is used to suspend the execution of an async method until a particular asynchronous operation is completed. When the await keyword is encountered, the control is returned to the caller, allowing other tasks to execute concurrently. Once the awaited operation is finished, the async method resumes execution from the point where it was suspended.

Example:

public async Task<string> FetchDataAsync()
{
using var httpClient = new HttpClient();
string data = await httpClient.GetStringAsync(“https://api.example.com/data");
return data;
}

In this example, the await keyword is used to suspend the FetchDataAsync method until the GetStringAsync method completes its execution. During this time, the main thread is not blocked, allowing other tasks to run concurrently.

Combining Async/Await with Task.Run

In some cases, you may need to offload a CPU-bound operation to a separate thread to prevent it from blocking the main thread. This can be achieved using Task.Run in conjunction with async/await.

Example:

public async Task<int> PerformIntensiveCalculationAsync()
{
int result = await Task.Run(() =>
{
// CPU-bound operation here
int calculatedValue = SomeIntensiveCalculation();
return calculatedValue;
});

return result;
}

In this example, the Task.Run method is used to offload the CPU-bound operation to a separate thread, allowing the main thread to continue executing other tasks. The await keyword ensures that the method resumes execution once the Task.Run operation is completed.

By understanding the mechanics of async/await in C#, developers can write more efficient, responsive, and scalable applications. In the next sections, we will discuss the advantages of async/await and explore real-world examples of its implementation.

Advantages of Async/Await in C#

Async/await brings numerous benefits to the table when it comes to writing asynchronous code in C#. In this section, we will discuss the advantages of async/await and provide examples that demonstrate its value in real-world scenarios.

Improved Application Responsiveness

Async/await allows developers to create applications with better responsiveness by preventing the main thread from being blocked during time-consuming operations. This is particularly important for user interface (UI) applications, where maintaining a responsive UI is essential for a positive user experience.

Example:

Consider a desktop application that fetches data from an external API and displays it on the screen. Without async/await, the UI would become unresponsive during the data fetch operation, leading to a poor user experience. With async/await, the data fetch operation can be performed asynchronously, allowing the UI to remain responsive during the operation.

public async void FetchDataButton_Click(object sender, RoutedEventArgs e)
{
string data = await FetchDataAsync();
DisplayData(data);
}

Simplified Asynchronous Code

Before async/await, writing asynchronous code in C# was more complex and error-prone, involving the use of callbacks or continuations. Async/await simplifies the process, allowing developers to write asynchronous code that closely resembles synchronous code, making it easier to read, write, and maintain.

Example:

Imagine you need to download multiple files concurrently and process them once all downloads are complete. With async/await, this can be achieved using Task.WhenAll.

public async Task DownloadAndProcessFilesAsync(IEnumerable<string> urls)
{
List<Task<byte[]>> downloadTasks = urls.Select(url => DownloadFileAsync(url)).ToList();
byte[][] filesData = await Task.WhenAll(downloadTasks);

foreach (byte[] fileData in filesData)
{
ProcessFile(fileData);
}
}

Better Error Handling

Async/await improves error handling in asynchronous code by allowing developers to use familiar constructs like try/catch/finally blocks. This results in cleaner, more robust code.

Example:

In this example, we demonstrate how to handle exceptions using async/await in a similar manner to synchronous code.

public async Task<string> SafeFetchDataAsync()
{
try
{
string data = await FetchDataAsync();
return data;
}
catch (HttpRequestException ex)
{
// Handle specific network-related exceptions here
LogError(ex);
return null;
}
catch (Exception ex)
{
// Handle general exceptions here
LogError(ex);
return null;
}
finally
{
// Perform any required cleanup here
}
}

These are just a few of the advantages that async/await brings to C# developers. As we move on to real-world examples, we’ll see how these benefits can be leveraged in practice to create more efficient and performant applications.

Implementing Async/Await in Real-World Scenarios

In this section, we’ll explore two real-world scenarios where async/await can be applied effectively to improve application performance and responsiveness.

Example 1: Improving Web Application Performance

In web applications, async/await can be used to enhance performance by allowing multiple tasks, such as database queries or external API calls, to run concurrently without blocking the main thread. This can lead to faster response times and improved scalability.

Consider an e-commerce web application that needs to fetch product details and customer reviews from different data sources.

public async Task<ActionResult> ProductDetails(int productId)
{
var productTask = GetProductAsync(productId);
var reviewsTask = GetReviewsAsync(productId);

Product product = await productTask;
IEnumerable<Review> reviews = await reviewsTask;

var viewModel = new ProductDetailsViewModel
{
Product = product,
Reviews = reviews
};

return View(viewModel);
}

By using async/await, the application can fetch product details and reviews concurrently, reducing the total time taken for these operations.

Example 2: Optimizing Data Processing

In data processing applications, async/await can be used to optimize resource utilization by running CPU-bound and I/O-bound tasks concurrently. This can lead to more efficient processing and faster completion times.

Suppose we have an application that processes a large number of files. Each file must be downloaded from an external source, decoded, and then processed. With async/await, we can optimize this pipeline by performing the decoding and processing tasks concurrently with the file downloads.

public async Task ProcessFilesAsync(IEnumerable<string> fileUrls)
{
var downloadTasks = fileUrls.Select(url => DownloadFileAsync(url)).ToList();
var processDataTasks = new List<Task>();

while (downloadTasks.Count > 0)
{
Task<byte[]> completedDownloadTask = await Task.WhenAny(downloadTasks);
downloadTasks.Remove(completedDownloadTask);

byte[] fileData = await completedDownloadTask;
byte[] decodedData = DecodeFileData(fileData);

processDataTasks.Add(ProcessFileDataAsync(decodedData));
}

await Task.WhenAll(processDataTasks);
}

In this example, the application begins processing each file as soon as its download is complete, allowing CPU-bound and I/O-bound tasks to run concurrently and optimizing overall processing time.

These examples demonstrate how async/await can be effectively implemented in real-world scenarios to create more responsive and efficient applications. By understanding the principles of async/await and leveraging its benefits, developers can build applications that scale better and provide a better user experience.

Best Practices and Common Pitfalls

While async/await brings numerous benefits to C# developers, it’s essential to follow best practices and be aware of common pitfalls to maximize its advantages and avoid potential issues. In this section, we will discuss several best practices and common pitfalls associated with async/await in C#.

Best Practices:

Use ConfigureAwait(false) when possible

In library code, it’s generally a good practice to use ConfigureAwait(false) when awaiting a Task. This prevents the continuation from capturing the original synchronization context, which can help avoid potential deadlocks and improve performance.

Example:

public async Task<string> FetchDataAsync()
{
using var httpClient = new HttpClient();
string data = await httpClient.GetStringAsync("https://api.example.com/data").ConfigureAwait(false);
return data;
}

Do not mix blocking and asynchronous code

Avoid using blocking calls like Task.Result or Task.Wait in async methods, as they can lead to deadlocks and reduce the benefits of async/await. Instead, use the await keyword to retrieve the result of an asynchronous operation.

Example:

// Bad: Mixing blocking and asynchronous code
public async Task<string> BadMethodAsync()
{
Task<string> dataTask = FetchDataAsync();
string data = dataTask.Result; // Blocking call
return data;
}

// Good: Using async/await correctly
public async Task<string> GoodMethodAsync()
{
string data = await FetchDataAsync();
return data;
}

Common Pitfalls:

Forgetting the await keyword

One common mistake is forgetting to use the await keyword when calling async methods. This can lead to unexpected behavior, as the method will return a Task object instead of the expected result. Always ensure you use await when calling async methods.

Example:

// Bad: Forgetting the 'await' keyword
public async Task ProcessDataAsync()
{
string data = FetchDataAsync(); // Forgot 'await'
ProcessData(data); // Compiler error: data is Task<string>, not string
}

// Good: Using 'await' correctly
public async Task ProcessDataAsync()
{
string data = await FetchDataAsync();
ProcessData(data);
}

Using async void methods

Avoid using async void methods, as they can cause unexpected behavior and make error handling more difficult. Instead, use async Task methods, which allow for better error propagation and handling.

Example:

// Bad: Using 'async void' method
public async void BadEventHandler(object sender, EventArgs e)
{
await SomeAsyncOperation();
}

// Good: Using 'async Task' method
public async Task GoodEventHandler(object sender, EventArgs e)
{
await SomeAsyncOperation();
}

Note: The only exception to this rule is event handlers in UI applications, where async void is required by the framework.

--

--