58

I wrote a couple of action methods in a controller to test the difference between sync and async controller actions in ASP.NET core:

[Route("api/syncvasync")]
public class SyncVAsyncController : Controller
{
    [HttpGet("sync")]
    public IActionResult SyncGet()
    {
        Task.Delay(200).Wait();

        return Ok(new { });
    }

    [HttpGet("async")]
    public async Task<IActionResult> AsyncGet()
    {
        await Task.Delay(200);

        return Ok(new { });
    }
}

I then load tested the sync end point: enter image description here

... followed by the async end point: enter image description here

Here's the results if I increase the delay to 1000 ms enter image description here enter image description here

As you can see there is not much difference in the requests per second - I expected the async end point to handle more requests per second. Am I missing something?

Carl Rippon
  • 4,553
  • 8
  • 49
  • 64
  • 4
    Async isn’t a magic potion that makes your pc work harder. Actually with the added overhead it’ll make some job even slower. In the end it’s just a tool to use resources more efficiently. – Robbert Draaisma Dec 28 '17 at 22:25
  • I wonder if the results will be different if you set the delay to a larger time, let's say 1,000ms, can you give it a try? – YuvShap Dec 28 '17 at 22:31
  • @YuvalShap I would expect not much difference - one need to use up way more threadpool threads in `.Wait` state to see any... Maybe 10s with higher load should tie up all threads in sync case (also numbers OP has are somewhat suspicious as delay is 200ms but requests take 1000+ms...) – Alexei Levenkov Dec 28 '17 at 22:38
  • I've added results for 1000ms. As suspected there is still no real difference ... – Carl Rippon Dec 28 '17 at 22:44
  • 13
    It is nearly the same as in real life: When it takes 10 minutes to complete a job it will take its time. What differs is how you wait for completion. Starring the whole time at the process (sync) or getting notified on completion (async). In both cases you can deliver the result in 10 minutes, but in async you can drink a coffee while awaiting completion – Sir Rufo Dec 28 '17 at 22:48
  • 24
    @CarlRippon When you're cooking dinner and you need to boil some water, you can either sit there and stare at the water while it's boiling, or you can go off and help your kids with their homework for a few minutes and then come back when the water is ready. If you timed how long it takes the water to get to a boil in both cases, what do you think the difference would be? (In this analogy going and helping your kids is the "asynchronous" version, staring at the water is the "synchronous" version.) – Servy Dec 28 '17 at 22:50
  • The real difference will be after you spin long running task outside the application. For example intensive database query. Sync will be blocked until the query will end. Async will be able to do other work between it. In this case async will have great impact as even the long running task is open, resource not intensive task will be able to be served with waiting thread(s). – dropoutcoder Dec 28 '17 at 22:53
  • I understand that the request time would be the same but I thought the async end point would be handle more requests (because the threadpool thread would be reused). This is the bit I'm not understanding ... I thought Task.Delay(1000) would simulate a longish running database call? Does the delay need to be greater? – Carl Rippon Dec 28 '17 at 22:59
  • Are you issuing the request in the same machine handling them? – Paulo Morgado Dec 29 '17 at 00:11
  • @Paulo, yes I am – Carl Rippon Dec 29 '17 at 01:14
  • 11
    Make a setup like this. Limit the thread pool size to 10, and then sending 100 requests in 100 seconds (one after another). Each requests should delay for 10 seconds in your controller. Then instead of measuring "Request/seconds", calculate the average processing time of all the requests. The async model should give you a smaller value, and reflect its design goal. If you measure something not directly relevant, you won't observe the results you want. – Lex Li Dec 29 '17 at 04:11
  • You might be stressing the client instead of the server. Try Lex's procedure. – Paulo Morgado Dec 29 '17 at 07:11
  • 3
    Thanks @Lex, your suggestion did the trick. My problem was that I hasn't capped the thread pool size and so for the tests I was doing there was no real difference. As soon as I capped the thread pool size, the difference was obvious – Carl Rippon Dec 29 '17 at 23:26
  • Those numbers are too small for an accurate benchmark. Try the ApacheBench tool with something like: `ab -c 400 -n 200000 127.0.0.1/etc` – Crouching Kitten Mar 18 '19 at 15:14

2 Answers2

144

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.

Chris Pratt
  • 232,153
  • 36
  • 385
  • 444
  • 3
    While you can use `ConfigureAwait(false)` in ASP.NET Core, this post mentions it's not needed: https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html – Bart Verkoeijen Aug 22 '18 at 09:17
  • That's true. However, it's safer to just continue using ConfigureAwait. It won't hurt anything to use it when it's not actually needed, but if you then refactor your code and move it into a context where it is needed, you may not remember or realize you should add it back. Following different practices in different situations is a recipe for introducing bugs. If you can use ConfigureAwait, use it. If it's not actually needed, oh well, but if it ever is, you're covered then too. – Chris Pratt Aug 22 '18 at 10:07
  • 4
    "As much as possible, you should reduce the amount of locals that need to be preserved.": I consider this a case of premature optimization. It's like, in functional programming, minimizing the number of locals reused in lambdas just because they need to be copied to the closure instance. I.m.o., the first concern should be code clarity/readability. In most cases, the few nano-/microseconds it takes to allocate heap variables are irrelevant, especially compared to other "bottlenecks" occurring in the overall context of a controller action. – Marc Sigrist Jul 06 '19 at 14:30
  • 2
    "In particular large object graphs can wreck havoc here, because that's a ton of information to move on and off the heap.": I think that's not the case. It does not matter how large the object graph under a root variable is. The only thing that's copied to the async state machine is the pointer to the root object, which is just 64bit. The graph's memory itself stays where it is, it's not copied anywhere. Or did you mean something else? – Marc Sigrist Jul 06 '19 at 14:30
  • @ChrisPratt the explanations and reasons you give for stacking multiple async operations in a single async method seem not entirely correct. If inside that async method your first async operation decides not to await on the context, every other async operation in that async method, regardless of their`ConfigureAwait` property will run without a context. The mental model you're trying to convey seems incorrect in that sense? – SpiritBob Jan 21 '20 at 16:40
  • I am just curious about one thing nobody mentions when explaining SYNC version of implementation. Let's say that down the stream of calls there is indeed some waiting for I/O operation to finish. But does it mean that it is usually an active waiting in a loop? I don't thing so. I guess it's implemented in a way that a background thread is spawned and the current thread waits for it to finish, meaning that even in SYNC version we are releasing current thread to the list of available threads. No processor unit should be using cycles then. So if not for the UI, what is ASYNC buying for us then? – Radek Strugalski Aug 24 '20 at 13:23
  • 2
    No. What you're describing is how async works. In sync, the thread is held, but it would go inactive. Sync nor async has anything to do with what the CPU is doing. It's all about threads. There's always a pool of threads, and each thread consumes some amount of memory, whether or not it's being actively used. As such, there's a hard limit on the total number of threads that are available. In something like a web server, that limit is generally 1000. So if all 1000 threads are synchronously waiting, then all further requests are queued until one of those 1000 frees up. – Chris Pratt Aug 24 '20 at 14:39
  • 1
    All async does is allow threads to be returned to the pool when they're not actively needed, allowing additional headroom. – Chris Pratt Aug 24 '20 at 14:40
  • @Chris Pratt Thanks for your explanations. So in short Task.Wait(someThread) won't release the thread, but await will do. I wonder if there is a way to release the thread in the way await does. I am just exploring all the possible solutions, thus asking. – Radek Strugalski Aug 24 '20 at 15:07
  • This is an excellent explanation. I have tried numerous times to explain to people exactly what you are highlighting here and they just don't get it. I always ask people, "Do you know why you are using async?" if the answer is no, maybe go figure that out first. I think most people use async because they think it provides speed. The dumbest thing I have heard repeatedly is being told to await a Task at every return. I wholly disagree with this. The other misconception is never use Task always use Task. Love the bizarre-o rules people make from misundertandings. – dyslexicanaboko Jan 06 '23 at 03:31
9

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.

Dave Black
  • 7,305
  • 2
  • 52
  • 41