Not much difference between ASP.NET Core sync and async controller actions

Yes, you are missing the fact that async is not about speed, and is only slightly related to the concept of requests per second.

Async does one thing and only one thing. If a task is being awaited, and that task does not involve CPU-bound work, and as a result, the thread becomes idle, then, that thread potentially could be released to return to the pool to do other work.

That's it. Async in a nutshell. The point of async is to utilize resources more efficiently. In situations where you might have had threads tied up, just sitting there tapping their toes, waiting for some I/O operation to complete, they can instead be tasked with other work. This results in two very important ideas you should internalize:

  1. Async != faster. In fact, async is slower. There's overhead involved in an asynchronous operation: context switching, data being shuffled on and off the heap, etc. That adds up to additional processing time. Even if we're only talking microseconds in some cases, async will always be slower than an equivalent sync process. Period. Full stop.

  2. Async only buys you anything if your server is at load. It's only at times when your server is stressed that async will give it some much needed breathing room, whereas sync might bring it to its knees. It's all about scale. If your server is only fielding a minuscule amount of requests, you very likely will never see a difference over sync, and like I said, you may end up using more resources, ironically, because of the overhead involved.

That doesn't mean you shouldn't use async. Even if your app isn't popular today, it doesn't mean it won't be later, and rejiggering all your code at that point to support async will be a nightmare. The performance cost of async is usually negligible, and if you do end up needing it, it'll be a life-saver.

UPDATE

In the regard of keeping the performance cost of async negligible, there's a few helpful tips, that aren't obvious or really spelled out that well in most discussions of async in C#.

  • Use ConfigureAwait(false) as much as you possibly can.

    await DoSomethingAsync().ConfigureAwait(false);
    

    Pretty much every asynchronous method call should be followed by this except for a few specific exceptions. ConfigureAwait(false) tells the runtime that you don't need the synchronization context preserved during the async operation. By default when you await an async operation an object is created to preserve thread locals between thread switches. This takes up a large part of the processing time involved in handling an async operation, and in many cases is completely unnecessary. The only places it really matters is in things like action methods, UI threads, etc - places where there's information tied to the thread that needs to be preserved. You only need to preserve this context once, so as long as your action method, for example, awaits an async operation with the synchronization context intact, that operation itself can perform other async operations where the synchronization context is not preserved. Because of this, you should confine uses of await to a minimum in things like action methods, and instead try to group multiple async operations into a single async method that that action method can call. This will reduce the overhead involved in using async. It's worth noting that this is only a concern for actions in ASP.NET MVC. ASP.NET Core utilizes a dependency injection model instead of statics, so there are no thread locals to be concerned about. In others you can use ConfigureAwait(false) in an ASP.NET Core action, but not in ASP.NET MVC. In fact, if you try, you'll get a runtime error.

  • As much as possible, you should reduce the amount of locals that need to be preserved. Variables that you initialize before calling await are added to the heap and the popped back off once the task has completed. The more you've declared, the more that goes onto the heap. In particular large object graphs can wreck havoc here, because that's a ton of information to move on and off the heap. Sometimes this is unavoidable, but it's something to be mindful of.

  • When possible, elide the async/await keywords. Consider the following for example:

    public async Task DoSomethingAsync()
    {
        await DoSomethingElseAsync();
    }
    

    Here, DoSomethingElseAsync returns a Task that is awaited and unwrapped. Then, a new Task is created to return from DoSometingAsync. However, if instead, you wrote the method as:

    public Task DoSomethingAsync()
    {
        return DoSomethingElseAsync();
    }
    

    The Task returned by DoSomethingElseAsync is returned directly by DoSomethingAsync. This reduces a significant amount of overhead.


Remember that async is more about scaling than it is performance. You aren't going to see improvements in your application's ability to scale based on your performance test you have above. To properly test scaling you need to do Load Testing across in an appropriate environment that ideally matches your prod environment.

You are trying to microbenchmark performance improvements based on async alone. It is certainly possible (depending on the code/application) that you see an apparent decrease in performance. This is because there is some overhead in async code (context switching, state machines, etc.). That being said, 99% of the time, you need to write your code to scale (again, depending on your application) - rather than worry about any extra milliseconds spent here or there. In this case you are not seeing the forest for the trees so to speak. You should really be concerned with Load Testing rather than microbenchmarking when testing what async can do for you.