Context Matters

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 or Task.WaitAny. A console application’s Main 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 or Task.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 (use Task.Run if you want to get rid of the context).
  • Disabling context capturing is only scoped to the await statement with ConfigureAwait(false). On the next await (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 add ConfigureAwait(false) to all await 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.

About the author

Tim Bussmann

3 comments

  • 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?

By Tim Bussmann

Recent Posts