Imagine that you build an application with long-running and heavy tasks, and your main thread is blocked. With synchronous programming, each line of code is executed sequentially, blocking the calling thread until it completes. You have to deal with performance degradation and application unresponsiveness, especially with high traffic.
A powerful solution to this problem is asynchronous programming.
In C# 5.0, Microsoft introduced two keywords to facilitate the process of performing non-blocking operations and help us improve the reliability and responsiveness: async and await. Asynchronous Programming is ideal for I/O tasks, HTTP calls, file handling or external API requests. In a .NET Core API, this approach will lead us to process even more concurrent requests, boosting the overall performance and scalability.
Threads and Asynchronous Programming
Every request to an API is handled by a Thread from the Thread Pool, managed by the CLR (Common Language Runtime). With synchronous execution, the request remains blocked until it is completed, while with asynchronous execution, .NET releases the thread back to the Thread pool, allowing it to handle other incoming requests.
The asynchronous lifecycle runs through these steps:
- Thread pool assigns a worker thread to every request.
- The thread is released back to the thread pool if an asynchronous operation.
- After the task completion, another thread will resume the execution and return the response.
Let’s walk through a simple .NET Core API project to see how asynchronous programming works.
Asynchronous Joke Fetcher API
We will build a Joke Fetcher .NET Core API that calls an external joke API. This process should not block the overall system, allowing multiple concurrent requests. Multiple users could fetch a joke at the same time, and we should handle it properly.
First, create the JokeService with one method that returns a joke to the client:
1234567891011121314151617181920212223242526272829303132333435 | using System.Text.Json;
namespace AsyncJoker
{
public class JokeService
{
private readonly HttpClient _httpClient;
private const string JokeApiUrl = "https://official-joke-api.appspot.com/jokes/random";
public JokeService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<JokeResponse> GetJokeAsync()
{
var response = await _httpClient.GetAsync(JokeApiUrl);
response.EnsureSuccessStatusCode();
var jokeJson = await response.Content.ReadAsStringAsync();
var joke = JsonSerializer.Deserialize<JokeResponse>(jokeJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return joke ?? new JokeResponse();
}
}
public class JokeResponse
{
public string Setup { get; set; } = string.Empty;
public string Punchline { get; set; } = string.Empty;
}
}
|
Every time we call the GetJokeAsync method, the service makes an asynchronous HTTP GET request to the external API for a random joke(https://official-joke-api.appspot.com/jokes/random). As mentioned earlier, this asynchronous operation will not block our API from handling other requests and, as a result, remains responsive. Notice the naming convention with the Async at the end. It’s common practice in these methods for clear separation of their async nature.
Then, in the Program.cs file, register the JokeService and configure the needed HttpClient in the dependency injection container.
12345 | var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<JokeService>();
builder.Services.AddControllers();
|
Finally, create the controller to orchestrate this process, receive the request, fetch a joke async, and return the JokeResponse to the user.
1234567891011121314151617181920212223 |
using Microsoft.AspNetCore.Mvc;
namespace AsyncJoker.Controllers
{
[ApiController]
[Route("joke")]
public class JokeController : ControllerBase
{
private readonly JokeService _jokeService;
public JokeController(JokeService jokeService)
{
_jokeService = jokeService;
}
[HttpGet("random")]
public async Task<JokeResponse> GetRandomJoke()
{
return await _jokeService.GetJokeAsync();
}
}
}
|
The return type of an async method has 2 possible choices: Task or Task<T>. Task<T> represents a future result that we will get after an asynchronous operation, the joke in our case, while Task without a return type represents an operation that does not return any value.
Although it’s different from our method, it’s important to mention that using async void in methods should generally be avoided because:
- The method cannot be awaited.
- You have no way of knowing when the method’s task has completed.
- If an exception occurs, it crashes the application instead of propagating properly.
To ensure robust and manageable asynchronous code, always return a Task from asynchronous methods.
Cancellation Tokens
As requests come in, we are not sure that every request will be completed. There are many cases that could stop the operation, such as the user navigating away or canceling a request manually. To handle this situation properly, we use the CancellationToken. It is a part of the System.Threading namespace and works with CancellationTokenSource, which generates the token.
The advantages of this implementation are:
- Graceful Termination: Allow ongoing async operations to be cancelled.
- Resource Efficiency: Free up resources.
- Better User Experience: The server stops processing the request, improving the overall experience.
- Error Handling: Manage the cancellation differently from other exceptions.
To integrate cancellation tokens, first update the JokeService:
12345678910111213 | public async Task<JokeResponse> GetJokeAsync(CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync(JokeApiUrl, cancellationToken);
response.EnsureSuccessStatusCode();
var jokeJson = await response.Content.ReadAsStringAsync(cancellationToken);
var joke = JsonSerializer.Deserialize<JokeResponse>(jokeJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return joke ?? new JokeResponse();
}
|
The GetJokeAsync method’s signature changes to accept an optional CancellationToken and then we pass it to the HTTP client’s get async method. CancellationToken is a struct that lets us know if an operation should be cancelled. The default here means that if not provided, we use the CancellationToken.None.
Next, update the controller method to accept a CancellationToken. ASP.NET Core automatically binds the token from the HTTP request:
1234567891011121314151617 | [ApiController]
[Route("joke")]
public class JokeController : ControllerBase
{
private readonly JokeService _jokeService;
public JokeController(JokeService jokeService)
{
_jokeService = jokeService;
}
[HttpGet("random")]
public async Task<JokeResponse> GetRandomJoke(CancellationToken cancellationToken)
{
return await _jokeService.GetJokeAsync(cancellationToken);
}
}
|
In this way, we covered how to handle scenarios where a request is canceled for any reason, ensuring it is managed properly without causing issues or affecting the overall application stability.
Parallelism with Async
There is a new requirement from our users to fetch multiple jokes at once. We need to create an endpoint to cover this request with the best possible performance. We saw how to run a single async operation efficiently, but how do multiple asynchronous tasks run concurrently without blocking threads?
Let’s create the GetMultipleJokesAsync method in JokeService:
1234567 | public async Task<IEnumerable<JokeResponse>> GetMultipleJokesAsync(int count, CancellationToken cancellationToken = default)
{
var jokeTasks = Enumerable.Range(0, count)
.Select(_ => GetJokeAsync(cancellationToken));
return await Task.WhenAll(jokeTasks);
}
|
And expose a parallel endpoint:
12345 | [HttpGet("multiple/{count}")]
public async Task<IEnumerable<JokeResponse>> GetMultipleJokes(int count, CancellationToken cancellationToken)
{
return await _jokeService.GetMultipleJokesAsync(count, cancellationToken);
}
|
In this case, we use the Task.WhenAll() to leverage the below benefits:
- Task.WhenAll() runs all GetJokeAsync calls concurrently.
- No waiting for one request to finish before starting the next.
- If each joke takes 2 seconds and we fetch 5 jokes, the total time stays ~2 seconds instead of ~10 seconds.
We pass the cancellation token to every HTTP request, meaning that if one is cancelled, all dependent results will be cancelled. To handle them individually, we could catch OperationCanceledException per task, but let’s keep it simple with the implementation as it is.
Using ConfigureAwait(false) for Performance Optimization
To further enhance the performance of our requests, we can use a simple configuration for the await keyword and its functionality. By using ConfigureAwait(false), you tell the runtime: don’t resume on the original context; just continue on any available thread. The method GetJokeAsync, which doesn’t depend on the HttpContext or UI elements, is a great use case.
By default, await tries to resume on the captured context, which can cause unnecessary overhead.
Let’s change it:
1234567891011 | public async Task<JokeResponse> GetJokeAsync(CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync(JokeApiUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jokeJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<JokeResponse>(jokeJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new JokeResponse();
} |
We added ConfigureAwait(false) to both async calls, GetAsync and ReadAsStringAsync. This way, the continuation runs on any available thread, avoiding context capture and improving performance.
Conclusion
In this article, we explored how asynchronous programming transforms a .NET Core API by leveraging its benefits and features, improving performance and the overall user experience.
Additionally, the advanced features such as parallel async and ConfigureAwait further elevate both the developer and user satisfaction. As a result, by embracing asynchronous programming, we can build high-performance .NET Core APIs.