The Cost of Synchronous Async Methods
C# 5 introduced the async/await keywords and dramatically increased how easy it was to program asynchronously. The first time I encountered these keywords I was so confused. As I learned more I began to realize how powerful and helpful they were.
At the end of the day, C#’s async/await mechanism is an abstraction. A very useful abstraction, but abstraction none the less.
With any abstraction, it’s valuable to know what is going on under the hood to make sure you’re making smart decisions when using it.
Today I’m going to dive into what the performance tradeoffs are of that abstraction when no async functionality is needed.
Under the Cover of Async
Before async/await, writing correct non blocking code was a complex undertaking. You really had to understand the nuances to do it correctly.
With async/await, the C# compiler basically said “I’ll start doing everything that you previously had to do manually.” Which is great. The C# compiler does a better job than you or I at doing that accurately every time.
Under the hood the C# compiler generates* a state machine to track the progress of an async task. Lets take a look at the difference in code generated from the C# compiler for sync v async methods.
The first example is super easy, the compiled version of a synchronous method that returns a number is identical to its pre-compilation version.
That was easy, now lets take a look at what happens when that method is changed to an async method.
That is a lot different than our synchronous version. This is the state machine in action. The C# compiler creates a new class, which is of type IAsyncStateMachine, to manage the asynchronous state of our method.
It’s our method definition that drives whether a state machine gets generated or not. You’ll notice that my async method doesn’t actually do anything async. All it does is returns a number. So a state machine is useless and wasteful in this method because there’s no async state to track, but one is generated because we defined the method as async.
In Visual Studio a warning is generated for this method saying, “This async method lacks ‘await’ operators and will run synchronously.” This is the C# compile letting us know that you’re not doing anything in this method requiring it to be an async method.
There have been plenty of times that I’ve seen this warning and totally ignored it. I didn’t really care, or have the time to fix it. Ultimately I was just being a sloppy programmer.
What is the performance cost of this? Does it matter that I have a bunch of synchronous async methods scattered throughout my code base?
Lets create a benchmark to actually put some numbers to the difference between the two methods.
Here’s a simple BenchmarkDotNet benchmark that I created in order to see what kind of performance hit this was.
I have two classes, one with a synchronous method that returns a number, the other with an async method that returns a number.
Both of this methods run synchronously, but a state machine is generated for the async method much like the example above.
My two benchmarks consists of a for loop that create these classes and call their respective methods. It’s about as simple as you can get.
Here are the raw results for the following benchmark:
That’s a little hard to read, so I also graphed the time and memory usage.
There is a fairly substantial performance cost for using async methods with no async. Around a 10x difference on time and a 4x difference on memory.
Since this benchmark was very simple, the difference we’re talking about is in kilobytes of memory and microseconds. But in a real world app those differences wouldn’t be so trivial. If this kind of programming is common across an entire code base, it would definitely add up.
What this benchmark isn’t saying
I would like to stress that this benchmark is comparing synchronous operations in the context of a sync and async method.
I think async/await is a great language feature. If you’re actually needing to do async programming, async/await is one of your best friends.
The overhead generated by C# to automatically handle async is overwhelming worth it in order to write non blocking methods.
I just wanted to call that out, because async methods are awesome when they need to be.
You might ask, what happens if our method doesn’t always perform an async operation, or only rarely does?
In this example, only if the names is empty does an async operation occur. What if >90% of the time this method is called there’s no async happening.
In these types of scenarios it might be worth trying out ValueTask. With this guidance from the API docs:
Methods may return an instance of this value type when it’s likely that the result of their operations will be available synchronously and when the method is expected to be invoked so frequently that the cost of allocating a new _ Task for each call will be prohibitive._
ValueTask can offer some performance improvements for the case when your method may complete synchronously depending on circumstances. I reran the performance test using a ValueTask and got these numbers.
The time of ValueTask was pretty comparable to the regular Task method. The memory footprint was exactly that of a normal sync method, which is why you can’t see blue on this graph because it’s completely covered by yellow.
So if you’re needing to boost performance of your app, potentially look into use ValueTask in your app.
It’s worth understanding the abstractions that you are using when you’re programming. Knowing that async/await generates a state machine for you gives you some incentive not to use async methods when they’re not required. If you’re not sure what C# is doing, throw it in SharpLab and see what happens.
Benchmarking is also a much better way to compare features or implementations versus speculating about it. You might be surprised to see facts about things you’ve always “known” about your favorite language feature.
*I’m using SharpLab to view the compiled results of my code. Yes, the compiler emits IL code, this is the C# version of the IL code that’s emitted from the compiler.