Eliding Async and Await
• CommentsOnce one has learned the basics of async
and await
and has gotten fairly comfortable with it, a common design question often comes up: If I can remove async
and await
, should I? There are a number of situations where you can elide the async
/await
keywords and just return the task directly.
This is a surprisingly nuanced question, and in fact I now hold to a different answer than the position I originally took on this issue.
First, let’s check out the argument in favor of eliding the keywords.
Efficiency
It’s more efficient to elide async
and await
. By not including these keywords, the compiler can skip generating the async
state machine. This means that there are fewer compiler-generated types in your assembly, less pressure on the garbage collector, and fewer CPU instructions to execute.
However, it’s important to point out that each of these gains are absolutely minimal. There’s one fewer type, a handful of small objects saved from GC, and only a few CPU instructions skipped. The vast majority of the time, async
is dealing with I/O, which completely dwarfs any performance gains. In almost every scenario, eliding async
and await
doesn’t make any difference to the running time of your application.
For a thorough overview of the efficiency benefits of eliding async
and await
, see Stephen Toub’s classic video The Zen of Async or his MSDN article on the subject.
When I started writing about async
, I would always recommend eliding async
and await
, but I’ve modified that stand in recent years. There are just too many pitfalls to recommend eliding as a default decision. These days I recommend keeping the async
and await
keywords around except for a few scenarios, because of the drawbacks described in the rest of this blog post.
Pitfalls
By eliding async
and await
, you can avoid the compiler modifications to your method. Unfortunately, this also means that all the compiler modifications to your method must now be done by hand if you desire the same semantics.
Using
One of the most common mistakes in eliding async
and await
is that developers forget that there is code at the end of their method that needs to run at the appropriate time. In particular, when using a using
statement:
In this example, eliding the keywords will abort the download.
It’s easier to understand if you walk through how the code progresses (if you need a review, my intro to async
and await
post is still perfectly relevant today). For simplicity, I’ll assume that HttpClient.GetStringAsync
never completes synchronously.
With GetWithKeywordsAsync
, the code does this:
- Create the
HttpClient
object. - Invoke
GetStringAsync
, which returns an incomplete task. - Pauses the method until the task returned from
GetStringAsync
completes, returning an incomplete task. - When the task returned from
GetStringAsync
completes, it resumes executing the method. - Disposes the
HttpClient
object. - Completes the task previously returned from
GetWithKeywordsAsync
.
With GetElidingKeywordsAsync
, the code does this:
- Create the
HttpClient
object. - Invoke
GetStringAsync
, which returns an incomplete task. - Disposes the
HttpClient
object. - Returns the task that was returned from
GetStringAsync
.
Clearly, the HttpClient
is disposed before the GET
task completes, and this causes that request to be cancelled. The appropriate fix is to (asynchronously) wait until the GET
operation is complete, and only then dispose the HttpClient
, which is exactly what happens if you use async
and await
.
Exceptions
Another easily-overlooked pitfall is that of exceptions. The state machine for async
methods will capture exceptions from your code and place them on the returned task. Without the async
keyword, the exception is raised directly rather than going on the task:
These methods work exactly the same as long as the calling method does something like this:
However, if the method call is separated from the await
, then the semantics are different:
The invocation of the method can be separated from the await
in a variety of cases. For example, the calling method may have other work to do concurrently with the asynchronous work done by our method. This is most common in code that uses Task.WhenAll
.
The expected asynchronous semantics are that exceptions are placed on the returned task. Since the returned task represents the execution of the method, if that execution of that method is terminated by an exception, then the natural representation of that scenario is a faulted task.
So, eliding the keywords in this case causes different (and unexpected) exception behavior.
AsyncLocal
This pitfall is a bit harder to reason about.
AsyncLocal<T>
(and the lower-level LogicalCallContext
) allow asynchronous code to use a kind of async
-compatible almost-equivalent of thread local storage. The way that this actually works is that as part of the async
compiler transformation, the compiler-generated code will notify the logical call context that it needs to establish a copy-on-write scope.
This provides a way for contextual information to flow “down” asynchronous calls. Note that the value does not flow “up”.
In the example above, the context value is set in the “child” Async
method, but when Async
completes and the control flow moves back to MainAsync
, the code executes in the “parent” context. So the “parent” value flows to the “child”, but the “child” value does not flow to the “parent”.
The pitfall is that synchronous methods do not notify the logical call context that anything is different. For normal (non-task-returning) synchronous methods, this works out fine; from the logical call context’s perspective, all synchronous invocations are “collapsed” - they’re actually part of the context of the closest async
method further up the call stack. For example:
In this example, Async
does see the modification from its child Sync
method. As I mentioned above, I prefer to think of this as the synchronous methods really being a part of the closest async
context further up the stack. From the context’s perspective, Sync
is just a part of Async
.
When eliding async
and await
, you do need to be aware that the task-returning non-async
method is seen by the context as though it were a regular synchronous method. So, if it does any modification of the logical call context, it will actually affect its parent context:
This is a rare scenario, but it is one of the pitfalls of eliding async
/await
.
Recommended Guidelines
I suggest following these guidelines:
- Do not elide by default. Use the
async
andawait
for natural, easy-to-read code. - Do consider eliding when the method is just a passthrough or overload.
Examples: