Async/await - Pitfall 1 - Blocking async calls
This is a topic which has indeed been well covered and done to death at this point. But I still find many people who frequently fall into misunderstanding and pitfalls when working with asynchronous behaviour; using async/await in .NET. I'm primarily writing this series of post in the context of asp.net web applications as opposed to desktop applications. I find myself spending a lot of keystrokes repeating the content in these posts. So let's get into it.
Calling asynchronous code in synchronous contexts.
I refer to the use of .Result
and .Wait()
specifically.
Why not use it?
The primary reason not to use these is they block the caller. Any advantages you might gain from running asynchronously has all but gone when you call .Result
or .Wait()
. You also run into deadlocks in .net framework if somewhere in your async calling code you're missing a ConfigureAwait(false)
to tell the Synchronisation context that you don't care about resuming the current context. What you end up with is a continuation awaiting on a thread that can resume your context. That however, will never be available, because it's sat idle on the .Result/.Wait().
As an aside, it's not possible for these blocking calls to deadlock in .net core (or .net 5+) as there is no SynchronisationContext
.
It get's worse too. These blocking calls can cause thread starvation pretty easily. Especially during spiky load and bursts of activity. Take the follow snippet as an example:
CallDatabaseAsync().Result;
This will be processed in the following steps:
- Execute
CallDatabaseAsync()
, which inside is awaiting a database query. The runtime is going to build a state machine to manage the execution of this method, queuing it up to be executed on the thread pool. - The original calling thread now hits the
Result
call, thereby waiting idle for the database task to complete.
The thing to note here, is that in this one call alone there are 2 threads in flight, and one of them is blocked, unable to contribute to anything else the application might have queued for execution; like another request perhaps. This is a very simple example too! These types of calls can easily become more complex and propagate through your system, eventually starving the thread pool, requiring it to grow; causing delays and interrupts, or grinding to a halt. These types of problems are really difficult to diagnose too. It's likely that your application throughput capacity is degraded permanently before you can link cause to bad use of async/await.
You can get into some really interesting problems too - https://labs.criteo.com/2018/10/net-threadpool-starvation-and-how-queuing-makes-it-worse/ talks about the how a .NET thread has 2 queues; local and global. Decisions are made by the thread pool algorithm as to which queue a task is placed, subsequently surfacing different behaviours... It's interesting, give it a read through.
Do this instead
Use await all the way down. Refactor your whole flow, if it's worth being asynchronous at all. It's much easier these days than it was several years ago when async/await was a new feature, most (all?) worthy libraries and frameworks fully support async.
It's worth raising a recommendation however - Don't just blindly make everything asynchronous, especially if the work is CPU bound. Your application needs to execute all that work anyway, just do it synchronously; save the context switching, it will be faster. The balance is only really tipped when you are awaiting IO bound tasks somewhere in the execution path. Remember, the goal of asynchrony is to increase throughput and efficiency, not to directly speed a single process.
All of the options for blocking async code are bad, but if using await really isn't an option, or the refactor is deemed too much work for the async payoff - Then prefer using .GetAwaiter().GetResult()
. Reason being is that GetResult() propagates exceptions, instead of wrapping them up in AggregateException
like .Result does.
Like all things, use common sense as to when to refactor and when to use blocking calls. If you're looking at a hot path, definitely lean towards a refactor; if you're building an endpoint which is rarely hit, the blocking call isn't going to have the same negative impact and maybe is a reasonable choice verses a big refactor.
Next up
We take a look at pitfalls of working with synchronisation