Don't Block in Asynchronous Code
• CommentsOne 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.Wait
or 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 async
code.
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 Task.Run
):
Do you see the problem? I didn’t.
The deadlock is due to an optimization in the implementation of await
: an async
method’s continuation is scheduled with TaskContinuationOptions.ExecuteSynchronously
.
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
taskReady
is awaited before we callSetResult
. - Meanwhile, the
Test
method continues running and awaitstaskReady
. - After a short time, the thread pool task completes its “work” and invokes
SetResult
. This is where things get interesting!Test
is already awaitingtaskReady
and its continuation is expecting to run in a thread pool context. In this case,SetResult
will not asynchronously schedule the continuation; it will execute it directly. - The
Test
method continues execution, only it’s no longer independent from the thread pool task. Instead,Test
is executing on that same thread pool thread. So when we proceed toWait
on the thread pool task, we are blocking on something that we’re supposed to be completing. - As a result, the last
Sleep
never actually runs. The thread pool task never completes, andTest
never completes.
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!