When “ExecuteSynchronously” doesn’t execute synchronously

Stephen Toub - MSFT

When creating a task continuation with ContinueWith, developers have the opportunity to provide a TaskContinuationOptions enum value, which could include the TaskContinuationOptions.ExecuteSynchronously flag.  ExecuteSynchronously is a request for an optimization to run the continuation task on the same thread that completed the antecedent task off of which we continued, in effect running the continuation as part of the antecedent’s transition to a final state (the default behavior for ContinueWith if this flag isn’t specified is to run the continuation asynchronously, meaning that when the antecedent task completes, the continuation task will be queued rather than executed).  I explicitly used the word “request” in the previous sentence, because while TPL strives to honor this as much as possible, there are in fact a few cases where continuations created with TaskContinuationOptions.ExecuteSynchronously will still run asynchronously.  (What follows is a discussion of TPL’s current implementation in .NET 4 and the .NET 4.5 Developer Preview.)

One condition that will force a continuation to run asynchronously is if there is a thread abort pending on the thread completing the antecedent task.  It could be dangerous for TPL to continue running arbitrary amounts of work on a thread that the CLR is trying to tear down, so when a task completes and a thread abort is pending, all ContinueWith continuations will be queued rather than executed.  Here’s a code sample to demonstrate this behavior:

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        var tcs = new TaskCompletionSource<bool>();
        var cont = tcs.Task.ContinueWith(delegate 
        { 
            Console.WriteLine(Thread.CurrentThread.ManagedThreadId); 
        }, TaskContinuationOptions.ExecuteSynchronously);

        new Thread(() =>
        {
            Console.WriteLine(Thread.CurrentThread.ManagedThreadId); 
            try
            {
                Thread.CurrentThread.Abort();
            }
            finally
            {
                tcs.SetResult(true);
            }
        }).Start();

        cont.Wait();
    }
}

If you try running this code, you’ll find that it outputs two different thread IDs, one for the thread that’s completing the antecedent task and one for the thread that’s running the continuation.  If you then comment out the line that’s issuing the Abort, you’ll find that it’ll output the same ID twice, since the continuation is then running synchronously on the same thread that completed the antecedent task.

Another condition that may force a continuation to run asynchronously has to do with stack overflows.  TPL has logic used in a few places (e.g. Wait inlining, ExecuteSynchronously) to determine whether it’s too deep on the stack to safely execute.  If TPL detects that it’s too close to the thread’s guard page for comfort, such that running the continuation synchronously has a high risk of overflowing, then TPL will force the continuation to run asynchronously.  We can see this behavior with the following code example:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        var tcs = new TaskCompletionSource<bool>();
        var t = (Task)tcs.Task;

        var counts = new List<int>();
        int lastThreadId = Thread.CurrentThread.ManagedThreadId;
        int curCount = 0;

        for (int i = 0; i < 1000000; i++)
        {
            t = t.ContinueWith(delegate
            {
                int threadId = Thread.CurrentThread.ManagedThreadId;
                if (threadId == lastThreadId)
                {
                    curCount++;
                }
                else
                {
                    lastThreadId = threadId;
                    counts.Add(curCount);
                    curCount = 0;
                }
            }, TaskContinuationOptions.ExecuteSynchronously);
        }
        tcs.SetResult(true);
        t.Wait();
        Console.WriteLine((int)counts.Average());
    }
}

Here, we’re creating a chain of a million ExecuteSynchronously continuations.  With a default stack size of 1MB, and with multiple stack frames require per continuation execution, this program would certainly overflow the stack if TPL didn’t account for it.  However, if we run this, we should see that it successfully executes without crashing.  Note that this code is keeping track of how many continuations contiguously run on the same thread so that we can see approximately how many continuations end up running synchronously before one is forced to run asynchronously in order to avoid an overflow (this isn’t a 100% accurate measure, as it’s theoretically possible the continuation could be queued to run asynchronously but then end up running on the same thread, if the thread unwound the whole stack and then picked up the continuation task to run it before any other thread did, but for our purposes it’s good enough).  When I run this, I see that on average we run approximately 2200 continuations synchronously before we force one to run asynchronously.

The third current condition where ExecuteSynchronously continuations won’t run synchronously is when the target scheduler doesn’t allow it.  A TaskScheduler has the ability to say whether tasks are able to run on the current thread or not.  For example, if you created a custom StaTaskScheduler whose job was to run tasks on STA threads, it shouldn’t allow a task to run on an MTA thread, so that scheduler should be able to override the ExecuteSynchronously behavior in the case where the antecedent isn’t completing on an STA thread.  When TPL goes to run a synchronous continuation, it does so via the target TaskScheduler’s TryExecuteTaskInline method.  The scheduler can either choose to execute the task in that call, or it can choose to return false, meaning that it refused to run the task, and in the latter case, TPL will then queue the continuation to the scheduler to run asynchronously.  Here’s an example:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        var tcs = new TaskCompletionSource<bool>();
        var cont = tcs.Task.ContinueWith(delegate
        {
            Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, new DummyTaskScheduler());
        tcs.SetResult(true);
        cont.Wait();
    }
}

class DummyTaskScheduler : TaskScheduler
{
    protected override void QueueTask(Task task)
    {
        ThreadPool.QueueUserWorkItem(delegate { TryExecuteTask(task); });
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        //return TryExecuteTask(task);
        return false;
    }

    protected override IEnumerable<Task> GetScheduledTasks()
    {
        return null;
    }
}

Note that our dummy TaskScheduler’s TryExecuteTaskInline is returning false and not executing the task; thus, even though the continuation has ExecuteSynchronously specified, the continuation will still run asynchronously.  As with the previous examples, you can see this by running the code, which will output two different thread IDs.  If you then modify TryExecuteTaskInline by commenting out the existing “return false;” and uncommenting in the “return TryExecuteTask(task);” line, then the program should output the same thread ID twice.

0 comments

Discussion is closed.

Feedback usabilla icon