.NET Core MVC Thread Pool: Sequential vs Async Performance

I’ve been working with .NET Core lately and am really enjoying C#. I was interested in finding out more about how to increase RPS in a backend web environment. Some online research shows that .NET threads are heavy, and that .NET uses M:M threading, so the OS will do the context switching between threads. This is especially concerning in a web application because I/O intensive operations such as Database and API calls will cause threads to block and use up system resources.

Luckily, .NET provides Async / Await calls that convert supported Async calls to callback-based calls and free-up the thread to perform other work while waiting on I/O. I couldn’t find much documentation on how exactly .NET Core MVC’s request handler worked. My testing revealed that it uses a dynamically-sized thread pool to respond to incoming requests, and reuses threads when async controllers are used.

Sequential Behavior

  // GET api/values/sleep
  [HttpGet("sleep")]
  public string GetSleep()
  {
      Thread.Sleep(1000);
      return Process.GetCurrentProcess().Threads.Count.ToString();
  }

This MVC API controller uses Thread.Sleep to sleep the currently executing thread. Performance is quite poor; even though the request only sleeps for 1 second, the backlog of requests causes the response time to backup to around 57 seconds at load.

This chart does verify one important thing though - .NET Core MVC is using a dynamic thread pool to serve requests. As the request volume increases, .NET adds more system threads to process requests.

Async to the Rescue

  // GET api/values/delay
  [HttpGet("delay")]
  async public Task GetDelay()
  {
      await Task.Delay(1000);
      return Process.GetCurrentProcess().Threads.Count.ToString();
  }

What a relief! Task.Delay use async/await, and mean response time is right where it should - 1s. .NET’s thread pool stays steady, between 21-22 threads the entire time. It looks like in both case, .NET starts out with around 21 threads on the first request. Most of these are probably framework threads such as Kestrel, the HTTP Server the Garbage Collector, etc. We’re really just interested in anything over the initial 21 threads.

If there’s one takeaway from this, it is to find a database driver that implements async methods properly, and make all of the API Controllers that you can use these async methods. .NET will efficiently reuse the threads in it’s dynamic thread pool and you’ll be able to squeeze way more performance out of each web server.

Testing Methodology

I used Vegeta to HTTP Load test:

  • Ran 50 requests per second for 60 seconds
  • Recorded the number of threads in the .NET Core Web API process every second