Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Confused by TaskScheduler and SynchronizationContext for sync-on-async, can't control the sync context?

The Issue

I have an ASP.NET 4.0 WebForms page with a simple web-service WebMethod. This method is serving as a synchronous wrapper to asynchronous / TPL code. The problem I'm facing, is that the inner Task sometimes has a null SynchronizationContext (my preference), but sometimes has a sync context of System.Web.LegacyAspNetSynchronizationContext. In the example I've provided, this doesn't really cause a problem, but in my real-world development scenario can lead to dead-locks.

The first call to the service always seems to run with null sync context, the next few might too. But a few rapid-fire requests and it starts popping onto the ASP.NET sync context.

The Code

[WebMethod]
public static string MyWebMethod(string name)
{
    var rnd = new Random();
    int eventId = rnd.Next();
    TaskHolder holder = new TaskHolder(eventId);

    System.Diagnostics.Debug.WriteLine("Event Id: {0}. Web method thread Id: {1}",
        eventId,
        Thread.CurrentThread.ManagedThreadId);

    var taskResult = Task.Factory.StartNew(
        function: () => holder.SampleTask().Result,
        creationOptions: TaskCreationOptions.None,
        cancellationToken: System.Threading.CancellationToken.None,
        scheduler: TaskScheduler.Default)
        .Result;

    return "Hello " + name + ", result is " + taskResult;
}

The definition of TaskHolder being:

public class TaskHolder
{
    private int _eventId;
    private ProgressMessageHandler _prg;
    private HttpClient _client;

    public TaskHolder(int eventId)
    {
        _eventId = eventId;
        _prg = new ProgressMessageHandler();
        _client = HttpClientFactory.Create(_prg);
    }

    public Task<string> SampleTask()
    {
        System.Diagnostics.Debug.WriteLine("Event Id: {0}. Pre-task thread Id: {1}",
            _eventId,
            Thread.CurrentThread.ManagedThreadId);

        return _client.GetAsync("http://www.google.com")
            .ContinueWith((t) =>
                {
                    System.Diagnostics.Debug.WriteLine("Event Id: {0}. Continuation-task thread Id: {1}",
                        _eventId,
                        Thread.CurrentThread.ManagedThreadId);

                    t.Wait();

                    return string.Format("Length is: {0}", t.Result.Content.Headers.ContentLength.HasValue ? t.Result.Content.Headers.ContentLength.Value.ToString() : "unknown");
                }, scheduler: TaskScheduler.Default);
    }
}

Analysis

My understanding of TaskScheduler.Default is that it's the ThreadPool scheduler. In other words, the thread won't end up on the ASP.NET thread. As per this article, "The default scheduler for Task Parallel Library and PLINQ uses the .NET Framework ThreadPool to queue and execute work". Based on that, I would expect the SynchronizationContext inside SampleTask to always be null.

Furthermore, my understanding is that if SampleTask were to be on the ASP.NET SynchronizationContext, the call to .Result in MyWebMethod may deadlock.

Because I'm not going "async all the way down", this is a "synchronous-on-asynchronous" scenario. Per this article by Stephen Toub, in the section titled "What if I really do need “sync over async”?" the following code should be a safe wrapper:

Task.Run(() => holder.SampleTask()).Result

According to this other article, also by Stephen Toub, the above should be functionally equivalent to:

Task.Factory.StartNew(
    () => holder.SampleTask().Result, 
    CancellationToken.None, 
    TaskCreationOptions.DenyChildAttach, 
    TaskScheduler.Default);

Thanks to being in .NET 4.0, I don't have access to TaskCreationOptions.DenyChildAttach, and I thought this was my issue. But I've run up the same sample in .NET 4.5 and switched to TaskCreationOptions.DenyChildAttach and it behaves the same (sometimes grabs the ASP.NET sync context).

I decided then to go closer to the "original" recommendation, and implement in .NET 4.5:

Task.Run(() => holder.SampleTask()).Result

And this does work, in that it always has a null sync context. Which, kind of suggests the Task.Run vs Task.Factory.StartNew article has it wrong?

The pragmatic approach would be to upgrade to .NET 4.5 and use the Task.Run implementation, but that would involve development time that I'd rather spend on more pressing issues (if possible). Plus, I'd still like to figure out what's going on with the different TaskScheduler and TaskCreationOptions scenarios.

I've coincidentally found that TaskCreationOptions.PreferFairness in .NET 4.0 appears to behave as I'd wish (all executions have a null sync context), but without knowing why this works, I'm very hesitant to use it (it may not work in all scenarios).

Edit

Some extra info... I've updated my sample code with one that does deadlock, and includes some debug output to show what threads the tasks are running on. A deadlock will occur if either the pre-task or continuation-task outputs indicate the same thread id as the WebMethod.

Curiously, if I don't use ProgressMessageHandler, I don't seem able to replicate the deadlock. My impression was that this shouldn't matter, that regardless of down-stream code, I should be able to safely "wrap" an asynchronous method up in a synchronous context using the right Task.Factory.StartNew or Task.Run method. But this doesn't seem to be the case?

like image 641
Snixtor Avatar asked Feb 20 '13 06:02

Snixtor


1 Answers

First, using sync-over-async in ASP.NET often doesn't make much sense. You're incurring the overhead of creating and scheduling Tasks, but you don't benefit from it in any way.

Now, to your question:

My understanding of TaskScheduler.Default is that it's the ThreadPool scheduler. In other words, the thread won't end up on the ASP.NET thread.

Well, ASP.NET uses the same ThreadPool too. But that's not really relevant here. What's relevant is that if you Wait() (or call Result, that's the same) on a Task that's scheduled to run (but didn't start yet), the TaskScheduler my decide to just run your Task synchronously. This is known as “task inlining”.

What this means is that your Task ends up running on the SynchronizationContext, but it wasn't actually scheduled through it. This means there is actually no risk of deadlocks.

Thanks to being in .NET 4.0, I don't have access to TaskCreationOptions.DenyChildAttach, and I thought this was my issue.

This has nothing to do with DenyChildAttach, there are no Tasks that would be AttachedToParent.

I've coincidentally found that TaskCreationOptions.PreferFairness in .NET 4.0 appears to behave as I'd wish (all executions have a null sync context), but without knowing why this works, I'm very hesitant to use it (it may not work in all scenarios).

This is because PreferFairness schedules the Task to the global queue (instead of the thread-local queue that each ThreadPool thread has), and it seems Tasks from the global queue won't be inlined. But I wouldn't rely on this behavior, especially since it can change in the future.

EDIT:

Curiously, if I don't use ProgressMessageHandler, I don't seem able to replicate the deadlock.

There's nothing curious about that, that's exactly your problem. ProgressMessageHandler reports progress on the current synchronization context. And because of task inlining, that is the ASP.NET context, which you're blocking by waiting synchronously.

What you need to do is to make sure GetAsync() is run on a thread without the synchronization context set. I think the best way to do that is to call SynchronizationContext.SetSynchronizationContext(null) before calling GetAsync() and restoring it afterwards.

like image 185
svick Avatar answered Oct 08 '22 15:10

svick