Is Task.Factory.StartNew() guaranteed to use another thread than the calling thread?

35,547

Solution 1

I mailed Stephen Toub - a member of the PFX Team - about this question. He's come back to me really quickly, with a lot of detail - so I'll just copy and paste his text here. I haven't quoted it all, as reading a large amount of quoted text ends up getting less comfortable than vanilla black-on-white, but really, this is Stephen - I don't know this much stuff :) I've made this answer community wiki to reflect that all the goodness below isn't really my content:

If you call Wait() on a Task that's completed, there won't be any blocking (it'll just throw an exception if the task completed with a TaskStatus other than RanToCompletion, or otherwise return as a nop). If you call Wait() on a Task that's already executing, it must block as there’s nothing else it can reasonably do (when I say block, I'm including both true kernel-based waiting and spinning, as it'll typically do a mixture of both). Similarly, if you call Wait() on a Task that has the Created or WaitingForActivation status, it’ll block until the task has completed. None of those is the interesting case being discussed.

The interesting case is when you call Wait() on a Task in the WaitingToRun state, meaning that it’s previously been queued to a TaskScheduler but that TaskScheduler hasn't yet gotten around to actually running the Task's delegate yet. In that case, the call to Wait will ask the scheduler whether it's ok to run the Task then-and-there on the current thread, via a call to the scheduler's TryExecuteTaskInline method. This is called inlining. The scheduler can choose to either inline the task via a call to base.TryExecuteTask, or it can return 'false' to indicate that it is not executing the task (often this is done with logic like...

return SomeSchedulerSpecificCondition() ? false : TryExecuteTask(task);

The reason TryExecuteTask returns a Boolean is that it handles the synchronization to ensure a given Task is only ever executed once). So, if a scheduler wants to completely prohibit inlining of the Task during Wait, it can just be implemented as return false; If a scheduler wants to always allow inlining whenever possible, it can just be implemented as:

return TryExecuteTask(task);

In the current implementation (both .NET 4 and .NET 4.5, and I don’t personally expect this to change), the default scheduler that targets the ThreadPool allows for inlining if the current thread is a ThreadPool thread and if that thread was the one to have previously queued the task.

Note that there isn't arbitrary reentrancy here, in that the default scheduler won’t pump arbitrary threads when waiting for a task... it'll only allow that task to be inlined, and of course any inlining that task in turn decides to do. Also note that Wait won’t even ask the scheduler in certain conditions, instead preferring to block. For example, if you pass in a cancelable CancellationToken, or if you pass in a non-infinite timeout, it won’t try to inline because it could take an arbitrarily long amount of time to inline the task's execution, which is all or nothing, and that could end up significantly delaying the cancellation request or timeout. Overall, TPL tries to strike a decent balance here between wasting the thread that’s doing the Wait'ing and reusing that thread for too much. This kind of inlining is really important for recursive divide-and-conquer problems (e.g. QuickSort) where you spawn multiple tasks and then wait for them all to complete. If such were done without inlining, you’d very quickly deadlock as you exhaust all threads in the pool and any future ones it wanted to give to you.

Separate from Wait, it’s also (remotely) possible that the Task.Factory.StartNew call could end up executing the task then and there, iff the scheduler being used chose to run the task synchronously as part of the QueueTask call. None of the schedulers built into .NET will ever do this, and I personally think it would be a bad design for scheduler, but it’s theoretically possible, e.g.:

protected override void QueueTask(Task task, bool wasPreviouslyQueued)
{
    return TryExecuteTask(task);
}

The overload of Task.Factory.StartNew that doesn’t accept a TaskScheduler uses the scheduler from the TaskFactory, which in the case of Task.Factory targets TaskScheduler.Current. This means if you call Task.Factory.StartNew from within a Task queued to this mythical RunSynchronouslyTaskScheduler, it would also queue to RunSynchronouslyTaskScheduler, resulting in the StartNew call executing the Task synchronously. If you’re at all concerned about this (e.g. you’re implementing a library and you don’t know where you’re going to be called from), you can explicitly pass TaskScheduler.Default to the StartNew call, use Task.Run (which always goes to TaskScheduler.Default), or use a TaskFactory created to target TaskScheduler.Default.


EDIT: Okay, it looks like I was completely wrong, and a thread which is currently waiting on a task can be hijacked. Here's a simpler example of this happening:

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

namespace ConsoleApplication1 {
    class Program {
        static void Main() {
            for (int i = 0; i < 10; i++)
            {
                Task.Factory.StartNew(Launch).Wait();
            }
        }

        static void Launch()
        {
            Console.WriteLine("Launch thread: {0}", 
                              Thread.CurrentThread.ManagedThreadId);
            Task.Factory.StartNew(Nested).Wait();
        }

        static void Nested()
        {
            Console.WriteLine("Nested thread: {0}", 
                              Thread.CurrentThread.ManagedThreadId);
        }
    }
}

Sample output:

Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 3
Nested thread: 3
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4
Launch thread: 4
Nested thread: 4

As you can see, there are lots of times when the waiting thread is reused to execute the new task. This can happen even if the thread has acquired a lock. Nasty re-entrancy. I am suitably shocked and worried :(

Solution 2

Why not just design for it, rather than bend over backwards to ensure it doesn't happen?

The TPL is a red herring here, reentrancy can happen in any code provided you can create a cycle, and you don't know for sure what's going to happen 'south' of your stack frame. Synchronous reentrancy is the best outcome here - at least you can't self-deadlock yourself (as easily).

Locks manage cross thread synchronisation. They are orthogonal to managing reentrancy. Unless you are protecting a genuine single use resource (probably a physical device, in which case you should probably use a queue), why not just ensure your instance state is consistent so reentrancy can 'just work'.

(Side thought: are Semaphores reentrant without decrementing?)

Share:
35,547
Erwin Mayer
Author by

Erwin Mayer

I am happy! SOreadytohelp

Updated on May 26, 2020

Comments

  • Erwin Mayer
    Erwin Mayer almost 4 years

    I am starting a new task from a function but I would not want it to run on the same thread. I don't care which thread it runs on as long as it is a different one (so the information given in this question does not help).

    Am I guaranteed that the below code will always exit TestLock before allowing Task t to enter it again? If not, what is the recommended design pattern to prevent re-entrency?

    object TestLock = new object();
    
    public void Test(bool stop = false) {
        Task t;
        lock (this.TestLock) {
            if (stop) return;
            t = Task.Factory.StartNew(() => { this.Test(stop: true); });
        }
        t.Wait();
    }
    

    Edit: Based on the below answer by Jon Skeet and Stephen Toub, a simple way to deterministically prevent reentrancy would be to pass a CancellationToken, as illustrated in this extension method:

    public static Task StartNewOnDifferentThread(this TaskFactory taskFactory, Action action) 
     {
        return taskFactory.StartNew(action: action, cancellationToken: new CancellationToken());
    }
    
    • Tony The Lion
      Tony The Lion over 11 years
      I doubt you can guarantee that a new thread will be created when calling StartNew. A task is defined as an asynchronous operation, which doesn't necessarily imply a new thread. May use an existing thread somewhere too, or another way of doing async.
    • CodesInChaos
      CodesInChaos over 11 years
      If you're using C# 5, consider replacing t.Wait() with await t. Wait doesn't really fit with the philosophy of TPL.
    • Jon Hanna
      Jon Hanna over 11 years
      In the code given, the most efficient behaviour would be if it did use the calling thread. That thread is sitting there doing nothing after all.
    • Erwin Mayer
      Erwin Mayer over 11 years
      Yes, this is true, and I wouldn't mind in this specific case. But I prefer a deterministic behavior.
    • Peter Ritchie
      Peter Ritchie over 11 years
      If you use a scheduler that only runs tasks on a specific thread then no, the task can't be run on a different thread. It's quite common to use a SynchronizationContext to make sure tasks are run on the UI thread. If you ran the code that called StartNew on the UI thread like that, then they would both run on the same thread. The only guarantee is that the task will be run asynchronously from the StartNew call (at least if you don't provide a RunSynchronously flag.
    • Peter Ritchie
      Peter Ritchie over 11 years
      If you want to "force" a new thread to be created, use the 'TaskCreationOptions.LongRunning' flag. e.g.: Task.Factory.StartNew(() => { this.Test(stop: true); }, TaskCreationOptions.LongRunning); Which is a good idea if your locks could put the thread into a wait state for an extended period of time.
    • user3199601
      user3199601 over 11 years
      Erwin, if you really want to run on separate threads won't it be better to actually create and use threads explicitly?
    • Erwin Mayer
      Erwin Mayer over 11 years
      @stic: The only requirement is to run on another thread, so it would be overkill as the ThreadPool (especially via the Task library) is well able to handle that properly.
    • piers7
      piers7 over 11 years
      Why is the 'if(stop) return' check within the lock here? That bool is local, no need to protect on read (surely)
    • Erwin Mayer
      Erwin Mayer over 11 years
      @piers7: The idea was to prevent exiting the function (which is just a sample action) until the lock can be aquired. In a real situation you'd be right to move this statement before the lock.
    • Nikos Baxevanis
      Nikos Baxevanis over 11 years
      @PeterRitchie 's solution worked for me :)
    • Ricardo Rodrigues
      Ricardo Rodrigues over 10 years
      LongRunningTask does NOT guarantee a new thread, just provides a hint to TPL. A new thread might be used, but are no guarantees. As ilustrated by the correct answer, only CancellationToken does that
  • CodesInChaos
    CodesInChaos over 11 years
    In the case of a single available thread, his code would deadlock because "after the code using that thread already has finished with it" never happens.
  • svick
    svick over 11 years
    What about task inlining? The task can “hijack” the thread when it calls Wait(), no?
  • Jon Skeet
    Jon Skeet over 11 years
    @svick: I don't think so. I would certainly be surprised if it did so, and I'd consider that to be broken. I think the PFX team is sufficiently wary of reentrancy to stop that :)
  • svick
    svick over 11 years
    @JonSkeet I was focusing on the part of the question that is about the same thread and somehow ignored the part about the lock. But my test shows that task inlining does what I thought: when you call Wait() the task can “hijack” the current thread, so the second call will run on the same thread, before the first one finishes. But since the call to Wait() is outside the lock, you are guaranteed that the second call won't run on the same thread while the first call still holds the lock.
  • Jon Skeet
    Jon Skeet over 11 years
    @svick: It's hard for me to to follow your test, as it's got two different calls to StartNew, both starting the same kind of task. I think I'll have a look tonight to see what's going on, but if you're easily able to refactor your test, that would be helpful...
  • svick
    svick over 11 years
    @JonSkeet Well, I just took the code from this question (which already does that weird recursion), added some logging and started it on the ThreadPool.
  • Erwin Mayer
    Erwin Mayer over 11 years
    @svick Thanks for your test; if t.Wait() is outside the lock the task will run on the same thread, but if t.Wait() is inside the lock another thread will be used and as expected the program will deadlock.
  • Erwin Mayer
    Erwin Mayer over 11 years
    Actually, I tested several other times and the behavior is not consistent; it will sometimes use the same thread even if t.Wait() is inside the lock. Argh.
  • Erwin Mayer
    Erwin Mayer over 11 years
    I forked your test application on git: gist.github.com/3610738/…
  • Jon Skeet
    Jon Skeet over 11 years
    @ErwinMayer: Thanks for that - I've edited my answer to show a simpler (IMO) example which demonstrates the problem really easily, without any locking involved.
  • Peter Ritchie
    Peter Ritchie over 11 years
    Calling Wait on a threadpool thread could inline the call to the task's delegate on the current thread. If the thread that called Wait() is not a threadpool thread then the invocation of the task will not occur on the same thread.
  • Peter Ritchie
    Peter Ritchie over 11 years
    e.g. if you "force" a new thread to be created (i.e. not a threadpool thread) then you won't see any inlining from Wait: Task.Factory.StartNew(Launch, TaskCreationOptions.LongRunning).Wait()
  • svick
    svick over 11 years
    Why would this re-entrancy be so bad? I think the only case where it would be different than normal multithreading is if you were using ThreadStatic fields, or something like that.
  • Jon Skeet
    Jon Skeet over 11 years
    @svick: I'm thinking of things like calling Wait while you own a lock. Locks are re-entrant, so if the task you're awaiting tries to acquire the lock as well, it will succeed - because it already owns the lock, even though that's logically in another task. That ought to deadlock, but it won't... when it's re-entrant. Basically re-entrancy makes me nervous in general; it feels like it's violating all kinds of assumptions.
  • Erwin Mayer
    Erwin Mayer over 11 years
    @JonSkeet That is exactly my worry too... Re-entrency is not compatible with the idea of dealing with tasks. What would you recommend that could act as a reliable TaskLock? Do we have to go the ManualResetEvent route?
  • Erwin Mayer
    Erwin Mayer over 11 years
    @JonSkeet Thanks for your clear test program and contacting Stephen Toub about the issue! Hopefully he'll help us see the light.
  • Erwin Mayer
    Erwin Mayer over 11 years
    Makes me regret not to be using F# for this project to be forced to write re-entrancy-friendly code ;)
  • Erwin Mayer
    Erwin Mayer over 11 years
    @svick check this Wikipedia article on reentrancy to get a feel of why it can be painful.
  • Jon Skeet
    Jon Skeet over 11 years
    @ErwinMayer: Stephen has replied, and I'll edit his (comprehensive) mail into my answer.
  • Erwin Mayer
    Erwin Mayer over 11 years
    @JonSkeet: Very interesting reading, thanks, though apart from implementing another Scheduler (or maybe a ManualResetEvent as I suggested earlier), there doesn't appear to be a simple way to always prevent re-entrancy...
  • Jon Skeet
    Jon Skeet over 11 years
    @ErwinMayer: You can pass a cancelable token, and just never cancel it. That looks like it would do the trick. Or pass in a timeout which is several years :)
  • Erwin Mayer
    Erwin Mayer over 11 years
    @JonSkeet: Indeed! I missed that.
  • Erwin Mayer
    Erwin Mayer almost 10 years
    @JonSkeet, Do you know if Task.Run() is now a safe option achieving exactly the same goal (without the need to pass a new CancellationToken each time)?
  • Jon Skeet
    Jon Skeet almost 10 years
    @ErwinMayer: I'm not sure what you mean by "now a safe option". I don't know of any changes in this area, if that's what you were asking.
  • Erwin Mayer
    Erwin Mayer almost 10 years
    @JonSkeet I was wondering if using Task.Run() is guaranteed to always run the task on a different thread (never on the same thread), like what Task.Factory.StartNew(action: action, cancellationToken: new CancellationToken()) does.
  • Jon Skeet
    Jon Skeet almost 10 years
    @ErwinMayer: I wouldn't like to guarantee that. I suspect it may be true, but never say never :) (In particular, if you're calling it from a task, and then that task completes, it would be entirely reasonable for the new task to run on the same thread.)