Scalable and Performant ASP.NET Core Web APIs: Asynchronous Code
This is another post in a series of posts on creating performant and scalable web APIs using ASP.NET Core. In this post, we’ll focus on making our code asynchronous, and hopefully making our API work more efficiently …
Up to now, in this blog series, all our API code has been synchronous - i.e. we haven’t used async / await yet. For synchronous API code, when a request is made to the API, a thread from the thread pool will handle the request. If the code makes an I/O call (like a database call) synchronously, the thread will block until the I/O call has finished. The blocked thread can’t be used for any other work, it simply does nothing and waits for the I/O task to finish. If other requests are made to our API whilst the other thread is blocked, different threads in the thread pool will be used for the other requests.
There is some overhead in using a thread - a thread consumes memory and it takes time to spin a new thread up. So, really we want our API to use as few threads as possible.
If the API was to work in an asynchronous manner, when a request is made to our API, a thread from the thread pool handles the request (as in the synchronous case). If the code makes an asynchronous I/O call, the thread will be returned to the thread pool at the start of the I/O call and then be used for other requests.
So, making operations asynchronous will allow our API to work more efficiently with the ASP.NET Core thread pool. So, in theory, this should allow our API to scale better - let’s see if we can prove that …
Sync v Async Test
Let’s test the above theory with the following controller action methods. You can see that we prefix the method with “async” to make it asynchronous and prefix asynchronous I/O calls with “await”. In our example, we are using WAITFOR DELAY
to simulate a database call that takes 2 seconds.`
[Route("api/syncvasync")]
public class SyncVAsyncController : Controller
{
private readonly string _connectionString;
public SyncVAsyncController(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection");
}
[HttpGet("sync")]
public IActionResult SyncGet()
{
using (SqlConnection connection = new SqlConnection(_connectionString))
{
connection.Open();
connection.Execute("WAITFOR DELAY '00:00:02';");
}
return Ok();
}
[HttpGet("async")]
public async Task<IActionResult> AsyncGet()
{
using (SqlConnection connection = new SqlConnection(_connectionString))
{
await connection.OpenAsync();
await connection.ExecuteAsync("WAITFOR DELAY '00:00:02';");
}
return Ok();
}
}
Let’s load test the sync
end point first:
Now let’s load test the async
end point:
So, we have better results for the async
end point, but only just!
Let’s throttle the thread pool:
public class Program
{
public static void Main(string[] args)
{
...
int processorCounter = Environment.ProcessorCount; // 8 on my PC bool success = ThreadPool.SetMaxThreads(processorCounter, processorCounter); ...
}
}
Let’s also return the available threads in the thread pool in the responses:
[HttpGet("sync")]
public IActionResult SyncGet()
{
using (SqlConnection connection = new SqlConnection(_connectionString))
{
connection.Open();
connection.Execute("WAITFOR DELAY '00:00:02';");
}
return Ok(GetThreadInfo());}
[HttpGet("async")]
public async Task<IActionResult> AsyncGet()
{
using (SqlConnection connection = new SqlConnection(_connectionString))
{
await connection.OpenAsync();
await connection.ExecuteAsync("WAITFOR DELAY '00:00:02';");
}
return Ok(GetThreadInfo());}
private dynamic GetThreadInfo(){ int availableWorkerThreads; int availableAsyncIOThreads; ThreadPool.GetAvailableThreads(out availableWorkerThreads, out availableAsyncIOThreads); return new { AvailableAsyncIOThreads = availableAsyncIOThreads, AvailableWorkerThreads = availableWorkerThreads };}
Let’s load test the sync end point again now that the thread pool has been throttled:
We can see from the responses that all the threads in thread pool are being used:
Load testing the async end point again shows it is more efficient than the sync end point:
We can see from the responses that not all the threads in thread pool are being used - the thread pool is being used more efficiently:
Conclusion
When the API is stressed, async action methods will give the API some much needed breathing room whereas sync action methods will deteriorate quicker.
Async code doesn’t come for free - there is additional overhead in context switching, data being shuffled on and off the heap, etc which is why async code can be a bit slower than the equivalent sync code if there is plenty of available threads in thread pool. This difference is usually very minor though.
It’s a good idea to write async action methods that are I/O bound even if the API is only currently dealing with a low amount of usage. It only takes typing an extra keyword per I/O call and the usage can grow.
If you to learn about using React with ASP.NET Core you might find my book useful: