One of my most famous blog posts is Don’t Block on Asynchronous Code, which took an in-depth look at how a synchronous method could deadlock if it blocked on asynchronous code (e.g., using
Task.Result). This is a fairly common beginner’s mistake.
Recently, I came across another deadlock situation: in some cases, an
async method may deadlock if it blocks on a
Task. I found this behavior surprising and reported it as a bug. I suspect it won’t be fixed because it’s a very uncommon situation and the easiest fix would have a negative impact on performance for all
This deadlock scenario is due to an undocumented implementation detail. This post is accurate as of the initial release of .NET 4.5 in 2012. Microsoft may change this behavior in the future.
This code will deadlock in a free-threaded context (e.g., a Console application, unit test, or
Do you see the problem? I didn’t.
The deadlock is due to an optimization in the implementation of
async method’s continuation is scheduled with
So, stepping through the example code:
- We kick off a task running on the thread pool. So far, so good.
- The thread pool task does a bit of “work”. This is just to make sure
taskReadyis awaited before we call
- Meanwhile, the
Testmethod continues running and awaits
- After a short time, the thread pool task completes its “work” and invokes
SetResult. This is where things get interesting!
Testis already awaiting
taskReadyand its continuation is expecting to run in a thread pool context. In this case,
SetResultwill not asynchronously schedule the continuation; it will execute it directly.
Testmethod continues execution, only it’s no longer independent from the thread pool task. Instead,
Testis executing on that same thread pool thread. So when we proceed to
Waiton the thread pool task, we are blocking on something that we’re supposed to be completing.
- As a result, the last
Sleepnever actually runs. The thread pool task never completes, and
If you place this same method in a GUI project or ASP.NET project, you won’t see a deadlock. The difference is in step 4: the continuation must be run in the captured
SynchronizationContext, but it’s being scheduled by a thread pool thread; so in this case
SetResult will schedule the continuation to run asynchronously instead of executing it directly.
One fun twist to this scenario is that if we use
ConfigureAwait, then the method will consistently deadlock, regardless of its initial context:
Most people would not write code like this. It’s very unnatural to call
Task.Wait in an
async method; the natural code would use
await instead. I only came across this behavior while writing unit tests for my AsyncEx library; these unit tests can get pretty complex and can involve a mixture of synchronous and asynchronous code.
In conclusion, we already knew not to block on asynchronous code; now we know not to block in asynchronous code either!