Search code examples
c#.nettaskscheduled-tasks

different behavior between Factory.StartNew and Task.Run?


I'm trying to understand the difference between Factory.StartNew and Task.Run. I saw the equivalence in various places like here.

I think I have to use Factory.StartNew() in my case as I want to plug in my own TaskScheduler.

So to summ up, it seems that:

Task.Run(action)

Is strictly equivalent to:

Task.Factory.StartNew(action, 
    CancellationToken.None, 
    TaskCreationOptions.DenyChildAttach, 
    TaskScheduler.Default);

But, I ran a few tests with a simple SerialQueue grabbed from Microsoft's samples for Parallel Programming with the .NET Framework.

Here is the simple code:

/// <summary>Represents a queue of tasks to be started and executed serially.</summary>
public class SerialTaskQueue
{
    /// <summary>The ordered queue of tasks to be executed. Also serves as a lock protecting all shared state.</summary>
    private Queue<object> _tasks = new Queue<object>();
    /// <summary>The task currently executing, or null if there is none.</summary>
    private Task _taskInFlight;

    /// <summary>Enqueues the task to be processed serially and in order.</summary>
    /// <param name="taskGenerator">The function that generates a non-started task.</param>
    public void Enqueue(Func<Task> taskGenerator) { EnqueueInternal(taskGenerator); }

    /// <summary>Enqueues the task to be processed serially and in order.</summary>
    /// <param name="taskOrFunction">The task or functino that generates a task.</param>
    /// <remarks>The task must not be started and must only be started by this instance.</remarks>
    private void EnqueueInternal(object taskOrFunction)
    {
        // Validate the task
        if (taskOrFunction == null) throw new ArgumentNullException("task");
        lock (_tasks)
        {
            // If there is currently no task in flight, we'll start this one
            if (_taskInFlight == null) StartTask_CallUnderLock(taskOrFunction);
            // Otherwise, just queue the task to be started later
            else _tasks.Enqueue(taskOrFunction);
        }
    }

    /// <summary>Starts the provided task (or function that returns a task).</summary>
    /// <param name="nextItem">The next task or function that returns a task.</param>
    private void StartTask_CallUnderLock(object nextItem)
    {
        Task next = nextItem as Task;
        if (next == null) next = ((Func<Task>)nextItem)();

        if (next.Status == TaskStatus.Created) next.Start();

        _taskInFlight = next;
        next.ContinueWith(OnTaskCompletion);
    }


    /// <summary>Called when a Task completes to potentially start the next in the queue.</summary>
    /// <param name="ignored">The task that completed.</param>
    private void OnTaskCompletion(Task ignored)
    {
        lock (_tasks)
        {
            // The task completed, so nothing is currently in flight.
            // If there are any tasks in the queue, start the next one.
            _taskInFlight = null;
            if (_tasks.Count > 0) StartTask_CallUnderLock(_tasks.Dequeue());
        }
    }
}

And now here is my code of some simulated composed task (including await/continuation).

    public static async Task SimulateTaskSequence(int taskId)
    {
        Console.WriteLine("Task{0} - Start working 1sec (ManagedThreadId={1} IsThreadPoolThread={2})", taskId, Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread);
        Thread.Sleep(200);

        Console.WriteLine("Task{0} - Zzz 1st 1sec (ManagedThreadId={1} IsThreadPoolThread={2})", taskId, Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread);
        await Task.Delay(200);

        Console.WriteLine("Task{0} - Done (ManagedThreadId={1} IsThreadPoolThread={2})", taskId, Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread);
    }

Test1: using the queue with Task.Run():

static void Main(string[] args)
{
    Console.WriteLine($"Starting test program (ManagedThreadId={Thread.CurrentThread.ManagedThreadId} IsThreadPoolThread={Thread.CurrentThread.IsThreadPoolThread})");

    SerialTaskQueue co_pQueue = new SerialTaskQueue();

    for (int i = 0; i < 2; i++)
    {
        var local = i;
        co_pQueue.Enqueue(() => Task.Run(() => { return SimulateTaskSequence(local); }));
    }
}

And the result is correct, the queue is processed in the expected order (achieve Task0 before switching to Task1).

Starting test program (ManagedThreadId=1 IsThreadPoolThread=False)
Task0 - Start working 1sec (ManagedThreadId=5 IsThreadPoolThread=True)
Task0 - Zzz 1st 1sec (ManagedThreadId=5 IsThreadPoolThread=True)
Task0 - Done (ManagedThreadId=5 IsThreadPoolThread=True)
Task1 - Start working 1sec (ManagedThreadId=5 IsThreadPoolThread=True)
Task1 - Zzz 1st 1sec (ManagedThreadId=5 IsThreadPoolThread=True)
Task1 - Done (ManagedThreadId=8 IsThreadPoolThread=True)

Test 2: just using Factory.StartNew with its perfect equivalence:

static void Main(string[] args)
{
    Console.WriteLine($"Starting test program (ManagedThreadId={Thread.CurrentThread.ManagedThreadId} IsThreadPoolThread={Thread.CurrentThread.IsThreadPoolThread})");

    SerialTaskQueue co_pQueue = new SerialTaskQueue();

    for (int i = 0; i < 2; i++)
    {
        var local = i;
        co_pQueue.Enqueue(() => Task.Factory.StartNew(() => { return SimulateTaskSequence(local); }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default));
    }
}

But this time I get the following output:

Starting test program (ManagedThreadId=1 IsThreadPoolThread=False)
Task0 - Start working 1sec (ManagedThreadId=5 IsThreadPoolThread=True)
Task0 - Zzz 1st 1sec (ManagedThreadId=5 IsThreadPoolThread=True)
Task1 - Start working 1sec (ManagedThreadId=5 IsThreadPoolThread=True) WHAT?
Task1 - Zzz 1st 1sec (ManagedThreadId=5 IsThreadPoolThread=True)
Task0 - Done (ManagedThreadId=9 IsThreadPoolThread=True)
Task1 - Done (ManagedThreadId=5 IsThreadPoolThread=True)

I don't get the difference. Why is the behavior different? I thought it was equivalent?! (remember, the step after is plugging in my own scheduler)


Solution

  • The return type from the task factory is Task <Task>, the return type of Task.Run is just Task.

    You need to unwrap the inner task with the factory so that your ConinueWith in the queue code is running the continuation on the inner task instead of the outer task.

    static void Main(string[] args)
    {
        Console.WriteLine($"Starting test program (ManagedThreadId={Thread.CurrentThread.ManagedThreadId} IsThreadPoolThread={Thread.CurrentThread.IsThreadPoolThread})");
    
        SerialTaskQueue co_pQueue = new SerialTaskQueue();
    
        for (int i = 0; i < 2; i++)
        {
            var local = i;
            co_pQueue.Enqueue(() => Task.Factory.StartNew(() => { return SimulateTaskSequence(local); }, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default).Unwrap());
        }
    }
    

    Task.Run has a overload that accepts a Func<Task> that does this for you. If you declared the delegate in the Task.Run as a Func<object> you would see the same behavior from the Task.Run.