Async/await makes asynchronous code much easier to write because it hides away a lot of the details. Many of these details are captured in the SynchronizationContext which may change the behavior of your async code entirely depending on the environment where you’re executing your code (e.g. WPF, Winforms, Console, or ASP.NET). By ignoring the influence of the SynchronizationContext you may run into deadlocks and race conditions.
The SynchronizationContext controls how and where task continuations are scheduled and there are many different contexts available. If you’re writing a WPF application, building a website, or an API using ASP.NET you’re already using a special SynchronizationContext which you should be aware of.
SynchronizationContext in a console application
To make this less abstract, let’s have a look at some code from a console application:
public class ConsoleApplication { public static void Main() { Console.WriteLine($"{DateTime.Now.ToString("T")} - Starting"); var t1 = ExecuteAsync(() => Library.BlockingOperation()); var t2 = ExecuteAsync(() => Library.BlockingOperation())); var t3 = ExecuteAsync(() => Library.BlockingOperation())); Task.WaitAll(t1, t2, t3); Console.WriteLine($"{DateTime.Now.ToString("T")} - Finished"); Console.ReadKey(); } private static async Task ExecuteAsync(Action action) { // Execute the continuation asynchronously await Task.Yield(); // The current thread returns immediately to the caller // of this method and the rest of the code in this method // will be executed asynchronously action(); Console.WriteLine($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}"); } }
Where Library.BlockingOperation()
may be a third party library we’re using that blocks the thread. It can be any blocking operation but for testing purposes, you can use Thread.Sleep(2)
as an implementation.
When we run the application, the output looks like this:
16:39:15 - Starting 16:39:17 - Completed task on thread 11 16:39:17 - Completed task on thread 10 16:39:17 - Completed task on thread 9 16:39:17 - Finished
In the sample, we create three tasks that block the thread for some period of time. Task.Yield
forces a method to be asynchronous by scheduling everything after this statement (called the _continuation_) for execution but immediately returning control to the caller. As you can see from the output, due to Task.Yield
all the operations ended up being executed in parallel resulting in a total execution time of just two seconds.
Now let’s port this code over to ASP.NET.
SynchronizationContext in an ASP.NET application
Let’s say we want to reuse this code in an ASP.NET application. So we copy the code over converting the `Console.WriteLine` calls to `HttpConext.Response.Write` so we can see the output on the page:
public class HomeController : Controller { public ActionResult Index() { HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Starting"); var t1 = ExecuteAsync(() => Library.BlockingOperation())); var t2 = ExecuteAsync(() => Library.BlockingOperation())); var t3 = ExecuteAsync(() => Library.BlockingOperation())); Task.WaitAll(t1, t2, t3); HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Finished"); return View(); } private async Task ExecuteAsync(Action action) { await Task.Yield(); action(); HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}"); } }
We launch this page in a browser and it doesn’t load. It seems we’ve introduced a deadlock.
What happened?
The reason for the deadlock is that console applications schedule asynchronous operations differently than ASP.NET. While console applications just schedule the tasks on the threadpool, ASP.NET ensures that all asynchronous tasks of the same HTTP Request are executed sequentially. Since Task.Yield
enqueues the remaining work and immediately returns control to the caller, we have three waiting operations at the point we run into Task.WaitAll
. Task.WaitAll
is a blocking operation like Task.Wait
or Task.Result
and therefore blocks the current Thread.
Since ASP.NET schedules its tasks on the threadpool, blocking the thread is not the cause of the deadlock here. But due to the sequential execution, the waiting operations aren’t allowed to start. If they can’t start, they can never finish and the blocked thread isn’t able to continue.
This scheduling mechanism is controlled by the SynchronizationContext
class. Whenever we await
a task, everything that runs after the await statement (i.e. the continuation) will be scheduled on the current SynchronizationContext once the awaited operation finishes. The context decides how, when and where to execute the task. You can access the current context using the static SynchronizationContext.Current
property and the value of that property will always be the same before and after an await
statement.
In a console application, SynchronizationContext.Current
is always null
which means that continuations can be picked up by any free thread in the threadpool. That’s the reason why the operations are executed in parallel in the first example. But in our ASP.NET Controller we have an AspNetSynchronizationContext
instead which ensures the sequential processing mentioned earlier.
Takeaway #1
- Never use the blocking task synchronization methods like
Task.Result
,Task.Wait
,Task.WaitAll
orTask.WaitAny
. A console application’sMain
method is currently the only exception from that rule (thought that will change when they get full async support too).
Let’s fix it
Now that we know not to use Task.WaitAll
, let’s fix our controller’s Index
action:
public async Task<ActionResult> Index() { HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Starting "); var t1 = ExecuteAsync(() => Library.BlockingOperation())); var t2 = ExecuteAsync(() => Library.BlockingOperation())); var t3 = ExecuteAsync(() => Library.BlockingOperation())); await Task.WhenAll(t1, t2, t3); HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Finished "); return View(); }
We changed Task.WaitAll(t1, t2, t3)
to the non-blocking await Task.WhenAll(t1, t2, t3)
. This also requires us to change the method’s return type from ActionResult
to async Task
.
With these changes the page loads again and we see the following output:
16:41:03 - Starting 16:41:05 - Completed task on thread 60 16:41:07 - Completed task on thread 50 16:41:09 - Completed task on thread 74 16:41:09 - Finished
This looks better but we have another problem. The page now needs six seconds to load instead of the two seconds we have in the console application. The output nicely shows that AspNetSynchronizationContext
indeed schedules its work on the threadpool since we can see different threads executing the tasks. But because of the sequential nature of this context, they won’t run in parallel. While we fixed the deadlock, our copy-pasted code is still less efficient than when used in a console app.
Takeaway #2
- Never assume asynchronous code is executed in parallel unless you explicitly schedule it for parallel execution. Schedule asynchronous code to run in parallel with
Task.Run
orTask.Factory.StartNew
.
The second attempt
Let’s apply this new rule too and change these lines:
private async Task ExecuteAsync(Action action) { await Task.Yield(); action(); HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} "); }
to:
private async Task ExecuteAsync(Action action) { await Task.Run(action); HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} "); }
Task.Run
schedules the given action on the threadpool without a SynchronizationContext. This means everything run within the task will have SynchronizationContext.Current
set to null
. The result is that all enqueued operations are free to be picked up by any thread and they do not have to follow the sequential execution order specified by the ASP.NET context. This means the tasks are able to be executed in parallel.
Note that HttpContext
isn’t threadsafe and therefore we shouldn’t access it within Task.Run since that may yield bizarre results on the html output. But thanks to the context capturing, the Response.Write
is ensured to take place on the AspNetSynchronizationContext
(that’s the current context before the await
) which ensures serialized access to the HttpContext
.
This time, we’ll get the desired output:
16:42:27 - Starting 16:42:29 - Completed task on thread 9 16:42:29 - Completed task on thread 12 16:42:29 - Completed task on thread 14 16:42:29 - Finished
But wait, there is more!
SynchronizationContext
can do more than just scheduling tasks. The AspNetSynchronizationContext
also ensures the correct user is set on the currently executing thread (remember, it schedules work on the whole threadpool) and it makes HttpContext.Current
available to you.
In our code, this wasn’t necessary, because we were able to use the HttpContext
property of the Controller. If we want to extract our super useful ExecuteAsync
to a helper class instead, this becomes apparent:
class AsyncHelper { public static async Task ExecuteAsync(Action action) { await Task.Run(action); HttpContext.Current.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} "); } }
We just changed HttpContext.Response
to the static available HttpContext.Current.Response
. This will still work, thanks to AspNetSynchronizationContext
but if you try to access HttpContext.Current
within Task.Run
, you’ll get a NullReferenceException
because HttpContext.Current
was not set.
Forget the context
As we saw in previous examples, context capturing can come in very handy. But there are many scenarios where we do not need the context to be restored for the continuation. Context capturing comes at a cost and if we don’t need it, it is best to avoid this additional logic. Let’s say that we want to switch to a logging framework instead of writing directly to the loaded web page. We rewrite our helper:
class AsyncHelper { public static async Task ExecuteAsync(Action action) { await Task.Run(action); Log.Info($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}"); } }
Now there is nothing we need from the AspNetSynchronizationContext
after the await
statement so it’s safe to not restore it here. Context capturing can be disabled using ConfigureAwait(false)
after the awaited task. This will tell the awaited task to schedule the continuation on its current SynchronizationContext
instead. Since we’re using Task.Run
, the context is null
and therefore the continuation gets scheduled on the threadpool (without the sequential execution constraint).
Two details to keep in mind when using ConfigureAwait(false)
:
- There is no guarantee that the continuation will run on a different context when using
ConfigureAwait(false)
. It just tells the infrastructure to not restore the context, not to actively switch it to something else (useTask.Run
if you want to get rid of the context). - Disabling context capturing is only scoped to the
await
statement withConfigureAwait(false)
. On the nextawait
(in the same method, in the calling method or in the called method) statement, the context will again be captured and restored if not told otherwise. So you need to addConfigureAwait(false)
to allawait
statements in case you’re not relying on the context.
TL;DR;
Asynchronous code can behave quite differently in different environments because of their SynchronizationContext
. But when following the best practices, chances of running into issues can be reduced to a minimum. So make sure you’re familiar with the async/await best-practices and stick to them.
Want to learn more about async/await? Check out this awesome webinar series by Daniel Marbach: async/await best practices.
I’m digging in deep on all these async details and this was a good read, thanks for sharing Tim!
Tim – can you please explain more about this: “Since task.yield enqueues the remaining work and immediately returns control to the caller we have three waiting operations at the point we run into Task.WaitAll. Task.WaitAll is a blocking operation…and therefor blocks the current thread.” Can you please clarify though why this results in blocking in ASP.net but not in the console app?
Glad you like it Robert!
Hey Howard,
Task.Yield essentially causes the async method to return to the caller like it would do when calling a truly async IO operation. Instead of continuing processing once the IO operation completed, the remaining work in the async method will be immediately requeued for execution but now it’s at the end of the queue. This means that all three methods sit in the queue, waiting to be picked up again.
In the console app, this is no problem as every thread in the threadpool can just pick up work from this queue, even if one thread is blocked by Task.WaitAll. In ASP.NET this is different due to it’s SynchronizationContext which doesn’t allow other threads to just pick up the remaining work. This SynchronizationContext behaves very similar to a single threaded environment in that it can only execute one operation per HTTP request at a time. Since this operation is now waiting in Task.WaitAll, no other thread in the threadpool is allowed to pick up the remaining enqueued work. Does that help to clarify things?