Task.Factory.StartNew()保证使用的线程与调用线程不同吗?

78
我从一个函数开始了一个新任务,但是我不希望它在同一个线程上运行。只要它在不同的线程上运行,我就不在意它运行在哪个线程上(因此,这个问题中提供的信息没有帮助)。
我是否保证下面的代码将始终在允许Task t再次进入之前退出TestLock?如果不是,那么推荐使用什么设计模式来防止重入?
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();
}

编辑:根据Jon Skeet和Stephen Toub的下面答案,一种简单的确定性方法来防止重入是通过传递CancellationToken,如此扩展方法所示:

public static Task StartNewOnDifferentThread(this TaskFactory taskFactory, Action action) 
 {
    return taskFactory.StartNew(action: action, cancellationToken: new CancellationToken());
}

2
是的,这是真的,对于这种特定情况我并不介意。但我更喜欢确定性行为。 - Erwin Mayer
2
如果您使用的调度程序仅在特定线程上运行任务,那么不,该任务无法在其他线程上运行。使用“同步上下文”确保任务在UI线程上运行非常常见。如果像那样在UI线程上运行调用“StartNew”的代码,则它们都将在同一线程上运行。唯一的保证是任务将异步地从“StartNew”调用中运行(至少如果您没有提供“RunSynchronously”标志)。 - Peter Ritchie
8
如果您想“强制”创建一个新线程,可以使用'TaskCreationOptions.LongRunning'标志。例如:Task.Factory.StartNew(() => { this.Test(stop: true); }, TaskCreationOptions.LongRunning); 如果您的锁可能会使线程进入长时间等待状态,那么这是一个不错的选择。 - Peter Ritchie
@piers7:这个想法是防止在获取锁之前退出函数(这只是一个示例操作)。在实际情况中,您应该将此语句移到锁之前。 - Erwin Mayer
LongRunningTask并不保证会创建一个新线程,它只是向TPL提供了一个提示。可能会使用新线程,但不能保证。正如正确答案所示,只有CancellationToken才能保证这一点。 - Ricardo Rodrigues
显示剩余7条评论
4个回答

84
我给 PFX 团队 的成员 Stephen Toub 发了一封邮件询问这个问题。他非常迅速地回复了我,并提供了大量详细信息 - 所以我将在此处直接复制粘贴他的文字。由于阅读大量引用文本比起普通黑白色更加不舒适,因此我没有全部引用。但是,这确实是 Stephen - 我不知道这么多 :) 我将此答案设为社区 wiki,以反映下面所有好东西并不是我的内容:

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.


编辑:好的,看起来我完全错了,当前正在等待任务的线程是可以被劫持的。这里有一个更简单的例子:

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);
        }
    }
}

样例输出:

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

正如您所看到的,有很多时候等待线程被重用来执行新任务。即使线程已经获取了锁,这也可能发生。这是令人讨厌的重新进入问题。我感到非常震惊和担忧 :(


1
如果只有一个可用线程,他的代码将会死锁,因为“在使用该线程的代码已经完成后”从未发生。 - CodesInChaos
1
如果你“强制”创建一个新线程(即不是线程池线程),那么你将看不到Wait的任何内联:Task.Factory.StartNew(Launch, TaskCreationOptions.LongRunning).Wait() - Peter Ritchie
1
@svick:我在考虑像在拥有锁的同时调用Wait这样的事情。锁是可重入的,因此如果您正在等待的任务也尝试获取该锁,则会成功——因为它已经拥有该锁,即使逻辑上属于另一个任务。那应该会死锁,但它不会……当它是可重入的时候。基本上,可重入性总体上让我感到紧张;它感觉像是违反了各种假设。 - Jon Skeet
1
让我感到遗憾的是没有在这个项目中使用F#,被迫编写可重入友好代码。;) - Erwin Mayer
1
@ErwinMayer:我不想保证那个。我怀疑它可能是真的,但永远不要说“从来没有” :) (特别是,如果你是从一个任务中调用它,然后该任务完成,新任务在同一线程上运行是完全合理的。) - Jon Skeet
显示剩余21条评论

4
为什么不直接为此进行设计,而要费尽周折确保它不会发生?
TPL在这里是一个转移话题,只要您可以创建循环,并且不确定堆栈帧的“南”面会发生什么,就可以在任何代码中发生重入。同步重入是最好的结果,至少你不能那么容易地自我死锁。
锁管理跨线程同步。它们与管理重入无关。除非您正在保护真正的单个使用资源(可能是物理设备,在这种情况下,您应该使用队列),否则为什么不仅确保实例状态一致,以便重入可以“正常工作”。
(旁边的想法:是否可在不减量的情况下重入信号量?)

0
Erwin提出的使用new CancellationToken()的解决方案对我不起作用,内联仍然会发生。
所以我最终使用了Jon和Stephen建议的另一个条件 (...或者如果您传递了非无限超时...):
  Task<TResult> task = Task.Run(func);
  task.Wait(TimeSpan.FromHours(1)); // Whatever is enough for task to start
  return task.Result;

注意:为简单起见,此处省略了异常处理等内容,您在生产代码中应该注意这些。


0
你可以轻松地通过编写一个快速应用程序,在线程/任务之间共享套接字连接来测试这一点。
任务在向套接字发送消息并等待响应之前会获取锁。一旦这个任务被阻塞并变得空闲(IOBlock),就在同一块中设置另一个任务执行相同的操作。如果它没有在获取锁时被阻塞,而第二个任务被允许通过锁,因为它是由同一个线程运行的,则说明存在问题。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接