Asynchronous Programming in C#: Best Practices

Rupen Anjaria
6 min readJun 30, 2024

--

A deep dive into async and await, covering best practices and common pitfalls

What is Asynchronous programming?

Often in programming, we come across scenarios where a program is dealing with multiple resources. This could be reading a file, sending email, writing to a file etc. If we write program traditionally then we will end up taking more time in execution. This is because, our program are being executed in sequential manner.

Let’s say we have a requirement in a travel website where a user can enter the name of the city and date she is planning to visit. Our program will get those details from APIs and present location details to the user. The details should include historical information, weather on the date mentioned and best places to visit on those days for the city. The synchronous way to do this will look like below:

Non-Asynchronous Way

Let’s say each API takes about 2 seconds to respond, the total time would be around 6 seconds.

If we use asynchronous way then the flow of the program will be like:

Asynchronous Way

As you can see from above example, each fetch has it’s own thread so we have an opportunity to gather all the details in 2 seconds and hence the program’s response will be faster compare to previous approach.

Asynchronous programming is a way to write your program such that the main thread doesn’t have to wait for task that are taking longer to execute. This will make your program more responsive while doing long running processes in a separate thread.

Above example can be written in C# as below:

using System;
using System.Net.Http;
using System.Threading.Tasks;

public class CityInfoService
{
private static readonly HttpClient httpClient = new HttpClient();

public async Task<string> GetCityDetailsAsync(string cityName, DateTime date)
{
string dateString = date.ToString("yyyy-MM-dd");

//Call History API
Task<string> historyTask = GetCityHistoryAsync(cityName);
string historyDetails = await historyTask;

//Call Weather API
Task<weatherResult> weatherTask = GetWeatherAsync(cityName, dateString);
weatherResult weatherDetails = await weatherTask;

//Call Best Places API
Task<string> placesTask = GetBestPlacesToVisitAsync(cityName, dateString);
string placesDetails = await placesTask;

// Format the result
string result = $"City: {cityName}\nDate: {dateString}\n\nHistory:\n{historyDetails}\n\nWeather:\n{weatherDetails.ToString()}\n\nBest Places to Visit:\n{placesDetails}";

return result;
}

private async Task<string> GetCityHistoryAsync(string cityName)
{
// Fictional API
string apiUrl = $"https://api.example.com/history?city={cityName}";
HttpResponseMessage response = await httpClient.GetAsync(apiUrl);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}

private async Task<weatherResult> GetWeatherAsync(string cityName, string date)
{
// Fictional API URL
string apiUrl = $"https://api.example.com/weather?city={cityName}&date={date}";

HttpResponseMessage response = await httpClient.GetAsync(apiUrl);
response.EnsureSuccessStatusCode();
string jsonResponse = await response.Content.ReadAsStringAsync();
weatherResult result = JsonSerializer.Deserialize<weatherResult>(jsonResponse);

return result;
}

private async Task<string> GetBestPlacesToVisitAsync(string cityName, string date)
{
// Fictional API
string apiUrl = $"https://api.example.com/places?city={cityName}&date={date}";
HttpResponseMessage response = await httpClient.GetAsync(apiUrl);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}

public class weatherResult
{
public string Temperature { get; set; }
public string Rainy { get; set; }
public string Wind { get; set; }

public override string ToString()
{
return $"Temperature: {Temperature}\Rainy: {RainyPercentage}\nWind: {Wind}";
}
}

Let’s dissect the key terms. The main function GetCityDetailsAsync has signature with async keyword. This indicates that the function body has await at least once — meaning it has at least one function which will be executed in a separate thread.

When we await an asynchronous operation, the method yields control back to the caller until the operation completes, without blocking the calling thread. So in our case, each await will free-up the main thread to continue the execution.

Also note that the return type is wrap around Task, this indicates that the function will eventually return string, but not immediately.

Though above code works and leverages asynchronous mechanism, we still need to refine it using best practices.

Best practices

Start tasks concurrently

Notice the lines from our code:

Task<string> historyTask = GetCityHistoryAsync(cityName);
string historyDetails = await historyTask;

Here we are calling Async function GetCityHistoryAsync and then waiting immediately. This will not provide the real benefit as we are still blocked.

We should change the block as below:

// Start the three API calls in parallel  
Task<string> historyTask = GetCityHistoryAsync(cityName);
Task<weatherResult> weatherTask = GetWeatherAsync(cityName, dateString);
Task<string> placesTask = GetBestPlacesToVisitAsync(cityName, dateString);

// Combine the results
string historyDetails = await historyTask;
weatherResult weatherDetails = await weatherTask;
string placesDetails = await placesTask;

// Format the result
string result = $"City: {cityName}\nDate: {dateString}\n\nHistory:\n{historyDetails}\n\nWeather:\n{weatherDetails.ToString()}\n\nBest Places to Visit:\n{placesDetails}";

return result;

We are creating three separate thread and then waiting for each of them to complete. As computer encounters each async function, it doesn’t have to wait it to resolve. So, computer will execute GetCityHistoryAsync and them immediately execute GetWeatherAsyc and then GetBestPlacesToVisitAsync.

Use await when needed

Let’s tweak the travel app requirements. User has preference to not to visit if weather is above 110 F or 70% rainy. Our app should highlight it after getting weather details.

Now, this is not an async operation as it just requires checking the weather details. However, if we add the logic right after awaiting weatherTask then it may delay execution of placeTask. We don’t need result of placesTask as we are yet to check the result of weather API.

string alert = String.empty;

// Start the three API calls in parallel
Task<string> historyTask = GetCityHistoryAsync(cityName);
Task<weatherResult> weatherTask = GetWeatherAsync(cityName, dateString);
Task<string> placesTask = GetBestPlacesToVisitAsync(cityName, dateString);

// Combine the results
string historyDetails = await historyTask;
string weatherDetails = await weatherTask;

if (weatherDetails.Temperature > user.preferredTemperature ||
weatherDetails.Rainy > user.preferredRainyPercentage){

alert = addWeatherConditionAlert();

}

//use await for placesTask here instead of waiting after weatherTask
string placesDetails = await placesTask;


// Format the result
string result = $"City: {cityName}\nDate: {dateString}\n\nHistory:\n{historyDetails}\n\nWeather:\n{weatherDetails}\n\nBest Places to Visit:\n{placesDetails}\n\nAlert:\n{alert}";

return result;

Composition of tasks

If any portion of a task is asynchronous then consider the entire task as asynchronous. It’s always good to combine them in a separate function and perform under it.

This is applicable to our weather result and the code should be as below:

/*Preferred way*/
static async Task<string>
GetWeatherAndCheckPreference(int preferredTemperature,
int preferredRainyPercentage)
{

string alert = string.empty;

Task<weatherResult> weatherTask = GetWeatherAsync(cityName, dateString);
weatherResult weatherDetails = await weatherTask;

if (weatherResult.Temperature > preferredTemperature ||
weatherResult.Rainy > preferredRainyPercentage){

alert= addWeatherConditionAlert();

}

return $"Weather:\n {weatherDetails.ToString()}\n\nAlert:\n{alert}";
}

Await tasks efficiently

There are two versions on how actually we should await. Let’s discuss them here.

The first version is to use WhenAll. This will return a Task that completes when all the tasks in its argument list have completed. Here is how we can use it:

        Task<string> historyTask = GetCityHistoryAsync(cityName);
Task<weatherResult> weatherTask = GetWeatherAsync(cityName, dateString);
Task<string> placesTask = GetBestPlacesToVisitAsync(cityName, dateString);

// Wait for all the tasks to complete
await Task.WhenAll(historyTask, weatherTask, placesTask);

// Combine the results
string historyDetails = historyTask.Result;
weatherResult weatherDetails = weatherTask.Result;
string placesDetails = placesTask.Result;

// Format the result
string result = $"City: {cityName}\nDate: {dateString}\n\nHistory:\n{historyDetails}\n\nWeather:\n{weatherDetails.ToString()}\n\nBest Places to Visit:\n{placesDetails}";

return result;

The second version is to use WhenAny. This is useful when you need to proceed as soon as any one of the tasks completes. Here is how we will write it in our code:

// Start the three API calls in parallel
Task<string> historyTask = GetCityHistoryAsync(cityName);
Task<weatherResult> weatherTask = GetWeatherAsync(cityName, dateString);
Task<string> placesTask = GetBestPlacesToVisitAsync(cityName, dateString);

// Wait for any one of the tasks to complete
Task<string> completedTask = await Task.WhenAny(historyTask, weatherTask, placesTask);

// Determine which task completed and get the result
if (completedTask == historyTask)
{
string historyDetails = await historyTask;
// Process the history details
}
else if (completedTask == weatherTask)
{
weatherResult weatherDetails = await weatherTask;
// Process the weather details
}
else if (completedTask == placesTask)
{
string placesDetails = await placesTask;
// Process the places details
}

// Continue with the rest of the logic

Note that we are awaiting two times here. First is when we call WhenAny and second time when we have identified the completed task. This is necessary to endure that we have retrieve its result, or ensure that the exception causing it to fault gets thrown.

When to use Task.WhenAll or Task.WhenAny

The choice between Task.WhenAll and Task.WhenAny depends on the specific use case and what you need to achieve in your application:

Task.WhenAll

  • Use Case: When you need all the tasks to complete before proceeding.
  • Behavior: Waits until all tasks have completed. If any task fails, it will throw an exception.
  • Example: Ideal for scenarios where you need combined results from multiple tasks, as in your initial code. Additionally, you need results from all the tasks to proceed.

Task.WhenAny

  • Use Case: When you need to proceed as soon as any one of the tasks completes.
  • Behavior: Waits until any one task completes. You can then check which task completed and take appropriate action.
  • Example: Useful for scenarios where you want to act on the first available result, such as fetching data from multiple redundant sources and using the fastest response.

Conclusion

Concurrency is one of the complex subject in programming. C# supports all the versions of it. Be it parallel programming, multi-threading or async/await. Among these, Async/await is the widely used. I hope you enjoy reading this article as much as I enjoyed it writing.

Click here to see the final version on GitHub.

--

--

No responses yet