Use of async/wait in REST API with CPU intensive tasks

I am having problems understanding the advantages of using async/await in REST API. I have this CPU intensive task:

        [HttpGet("GetHeavyStuffAsync")]
        public async Task<string> GetHeavyStuffAsync()
        {
            Guid id = Guid.NewGuid();
            System.Diagnostics.Debug.WriteLine($"{id} has started");
            await _expensiveOperations.DoHeavyStuffOnDifferentThread();
            System.Diagnostics.Debug.WriteLine($"{id} has finished");
            return "request processed";
        }

Where DoHeavyStuffOnDifferentThread() does this:

        public async Task DoHeavyStuffOnDifferentThread()
        {
            var t = Task.Run(() =>
            {
                var limit = 4000;
                var array = Enumerable.Range(0, limit).ToArray();
                stoogesort(array, 0, array.Count() - 1);
            });

            await t;
        }

I am using Task.Run(() => ... so that the CPU heavy stuff executes in a different threat without blocking the main one, and using async/await in the hope that the controller thread is not blocked by the heavy task and can continue attending requests.

To test that I write a program that launched 250 request against the GetHeavyStuffAsync(), and after that from swagger I made a request to a different endpoint in the same API controller:

        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            System.Diagnostics.Debug.WriteLine("GETTING FORECAST ...");
            var rng = new Random();
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = rng.Next(-20, 55),
                Summary = Summaries[rng.Next(Summaries.Length)]
            })
            .ToArray();
        }

As you can see this last endpoint is the one created by default as an example by Visual Studio when you create an API project, and it is a very simple function that returns immediately.

What I expected to happen: the calls to GetHeavyStuffAsync would be processed, at await _expensiveOperations.DoHeavyStuffOnDifferentThread(); the control would pass to the thread doing the heavy stuff, and the API controller would be free to process the other request, at some point DoHeavyStuffOnDifferentThread would finish and the controller would continue with this instruction System.Diagnostics.Debug.WriteLine($"{id} has finished"); and finish the method, be free to continue processing other request.

What actually happened was that public IEnumerable<WeatherForecast> Get() took minutes to return.

So why there was no difference in behavior from what I would have had if I hadn´t used a different thread nor async/await?

(Note: during the test CPU and memory in my laptop remaining at about 50%)


Solution 1:

I am using Task.Run(() => ... so that the cpu heavy stuff executes in a different threat without blocking the main one

There is no benefit to doing this in ASP.NET.

In a desktop application, the UI can only be updated by the main thread. So offloading CPU-intensive tasks to another thread makes sense because it frees up the UI thread to continue responding to user input.

However, in ASP.NET, there is no one "main thread." Every new request is assigned a new thread, up until the max ThreadPool count is hit, then any further requests have to wait.

So when you use Task.Run, you are freeing the main request's thread, but you're using another thread. So the net effect on the ThreadPool count is still the same.

In the article ASP.NET Core Performance Best Practices, Microsoft recommends:

Do not:

  • Call Task.Run and immediately await it. ASP.NET Core already runs app code on normal Thread Pool threads, so calling Task.Run only results in extra unnecessary Thread Pool scheduling. Even if the scheduled code would block a thread, Task.Run does not prevent that.

Asynchronous code only helps you when making I/O requests (network, file system, etc.), since there is truly nothing to do while waiting. But with CPU-intensive tasks, there is no benefit.