What happens when marking a method async?

For a very long time (ever since async and await were introduced), I've been living under the assumption that the async modifier simply signalled the compiler that the method body may contain an await. In the absence of an await, the compiler can simply generate the equivalent of Task.FromResult(...) and be done with it.

Visual Studio's compiler emits warnings when you mark a method as asynchronous, but you don't actually await a call inside the method body. Being under the assumption I described above, I've consistently ignored this warning. However, when someone reviewed my code and mentioned what to me was a perfectly normal habit, namely marking tasks as asynchronous even when they didn't actually do anything asynchronously, I decided to take a closer look.

Under the hood

When using the asynchronous syntax introduced in the Task-based asynchronous pattern, the compiler pulls of a couple of tricks to make your method run asynchronously.

First off, it generates a state machine. The code up to the first await runs as the first "state" in this machine, and every subsequent code, up to the next await is ran as a continuation thereof. This is also why Visual Studio tells you that the code may run synchronously if you don't actually await anywhere: it's just a single state in the state machine.

While it's perfectly viable for asynchronous methods to use this state machine, it'd be a complete waste for methods that just run synchronously to begin with, regardless of them being a Task<T> or not. Ergo, the question here becomes: does the compiler generate a state machine for Tasks that do not contain a single await?

It definitely shouldn't have to: any asynchronous method that doesn't contain an await could be compiled into a Task.FromResult(...) as I outlined above.

Two tasks

To find out, we'll use this very simple snippet of code; one with the async modifier, and one without:

You can compile this code and open it in dotPeek to see the code generated by the compiler. Make sure to check this option in the decompiler options if you decide to do so though:

Decompiler Options

The decompiler is pretty smart, and if you don't specify this option it'll just decompile it back to what you wrote in Visual Studio. Which is useful most of the time, but in this case, we're after the raw compiler output.

Either way, here's what dotPeek came up with:

I know I was a bit surprised when I saw that output. That means at the very least that the compiler does generate the state machine, regardless of the presence of an await keyword in the method body.

AsyncTaskMethodBuilder is a struct, so it won't have to allocate for that, but there are at least two allocations going on here: the state machine itself, and the Task that is to be returned. I've looked for any optimisations in AsyncTaskMethodBuilder that would possibly prevent this allocation, but came up empty.

On the other hand, there's only one allocation as far as I can see in the async-less variant of the same method, namely the Task<T> being returned.

Results

As we've seen above, the compiler generates a state machine regardless of any actual awaits in the method. This means it generates a ton more garbage than its non-async counterpart, which should be visible if we micro-benchmark this code.

Because the difference in a small number of calls should be negligible, I've called both methods 10.000.000 times in a for loop. These are the results:

With async: 00:00:00.8955864

Without async: 00:00:00.2827188

That's a rather large difference, especially if we consider that these two methods essentially do exactly the same thing: return true;.

You won't be noticing the difference in your run to the mill web application, but if you're working on high-throughput software, this is definitely a performance gain that's very cheap, at the cost of a little bit extra code, a trade I'd happily make.

I won't be making the mistake of marking methods async unless absolutely required anymore.

Thanks for reading!