是否可以保证Task.Factory.StartNew()使用调用线程以外的其他线程?


75

我正在从一个函数启动一个新任务,但我不希望它在同一线程上运行。我不在乎它在哪个线程上运行,只要它在另一个线程上即可(因此在此问题中无济于事)。

我是否保证下面的代码TestLockTask t再次允许输入之前总是退出?如果不是,为防止重入,推荐的设计模式是什么?

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

我怀疑您可以保证在调用时将创建一个新线程StartNew。任务定义为异步操作,它不一定意味着新线程。也可以在某个地方使用现有线程,或者使用另一种异步方式。
托尼狮子

如果您使用C#5,考虑更换t.Wait()await tWait不完全符合TPL的理念。
CodesInChaos 2012年

2
是的,这是真的,在这种情况下,我不介意。但是我更喜欢确定性行为。
欧文·梅耶

2
如果使用仅在特定线程上运行任务的调度程序,则否,该任务不能在其他线程上运行。SynchronizationContext确保任务在UI线程上运行很常见。如果您StartNew像这样运行在UI线程上调用的代码,则它们都将在同一线程上运行。唯一的保证是任务将在StartNew调用中异步运行(至少在不提供RunSynchronously标志的情况下。)
Peter Ritchie 2012年

7
如果要“强制”创建新线程,请使用“ TaskCreationOptions.LongRunning”标志。例如:Task.Factory.StartNew(() => { this.Test(stop: true); }, TaskCreationOptions.LongRunning); 如果您的锁可以使线程长时间处于等待状态,这是一个好主意。
彼得·里奇

Answers:


83

我就此问题邮寄了PFX小组成员Stephen Toub 。他很快就回来了,提供了很多详细信息-我将在这里复制并粘贴他的文字。我还没有全部引用,因为阅读大量引用的文本最终会变得不像香草黑白色那么舒服,但是实际上,这是斯蒂芬-我不知道这么多东西:)我做了这个答案社区Wiki反映出以下所有优点并不是我真正的内容:

如果您调用已完成Wait()Task,则不会有任何阻塞(如果任务以TaskStatus而不是来完成RanToCompletion,否则将抛出异常,否则将返回nop)。如果您调用Wait()已经执行的Task,则它必须阻塞,因为它无法合理地做其他事(当我说阻塞时,我同时包括了基于内核的真正的等待和旋转,因为通常它将两者混合)。同样,如果您调用Wait()的任务具有CreatedWaitingForActivation状态该任务将阻塞直到任务完成。这些都不是正在讨论的有趣案例。

有趣的情况是,当您Wait()在该WaitingToRun状态下调用Task时,这意味着该任务先前已排队到TaskScheduler中,但该TaskScheduler尚未真正开始运行Task的委托。在这种情况下,Wait通过调用调度程序的TryExecuteTaskInline方法,对的调用将询问调度程序是否可以在当前线程上运行该任务。这称为内联。调度程序可以选择通过调用来内联任务base.TryExecuteTask,也可以返回'false'表示它没有执行任务(通常使用类似...的逻辑来完成)

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

TryExecuteTask返回布尔值的原因是它处理同步以确保给定Task仅执行一次。因此,如果调度程序希望在期间完全禁止内联该任务Wait,则可以将其实现为:return false; 如果调度程序希望始终尽可能允许内联,则可以将其实现为:

return TryExecuteTask(task);

在当前的实现中(.NET 4和.NET 4.5,并且我个人不希望这会改变),针对ThreadPool的默认调度程序允许内联,如果当前线程是ThreadPool线程,并且该线程是一个先前已将任务排队的人。

请注意,这里没有任意可重入性,因为默认的调度程序在等待任务时不会泵送任意线程...它只会允许该任务被内联,当然,任何内联该任务都会决定去做。另请注意,Wait在某些情况下甚至都不会询问调度程序,而宁愿阻塞。例如,如果您传递了一个可取消的CancellationToken,或者您传递了一个非无限超时,则它不会尝试内联,因为内联任务的执行可能要花费任意长的时间,或者是全部或全部,最终可能会大大延迟取消请求或超时。总体而言,TPL试图在浪费正在做的线程之间取得平衡。Wait过多地重复使用该线程。这种内联对于递归的“分而治之”问题(例如QuickSort)非常重要,在该问题中您会生成多个任务,然后等待所有任务完成。如果这样做是在没有内联的情况下进行的,那么当您耗尽池中的所有线程以及它想要提供给您的任何将来线程时,您很快就会陷入僵局。

与分开Wait,也可能(远程)结束Task.Factory.StartNew调用的执行,然后再执行此操作,前提是所使用的调度程序选择作为QueueTask调用的一部分同步运行任务。.NET中内置的任何调度程序都不会做到这一点,我个人认为对于调度程序来说这是一个糟糕的设计,但从理论上讲是可能的,例如:

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

那的重载Task.Factory.StartNew不能接受TaskScheduler来自的使用调度器TaskFactory,在Task.Factory目标的情况下TaskScheduler.Current。这意味着,如果您Task.Factory.StartNew从排队到这个神话的Task内调用RunSynchronouslyTaskScheduler,它也将排队到RunSynchronouslyTaskScheduler,导致该StartNew调用同步执行Task。如果您对此很担心(例如,您正在实现一个库,但您不知道从何处调用),则可以显式传递TaskScheduler.Default给该StartNew调用,使用Task.Run(总是转到TaskScheduler.Default),或使用TaskFactory创建的定位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 2012年

1
例如,如果您“强制”要创建的新线程(即不是线程池线程),那么您将看不到任何内联WaitTask.Factory.StartNew(Launch, TaskCreationOptions.LongRunning).Wait()
Peter Ritchie 2012年

1
@svick:我正在考虑Wait在您拥有锁时打电话的事情。锁是可重入的,因此,如果您正在等待的任务也尝试获取该锁,则它将成功-因为它已经拥有该锁,即使从逻辑上讲它在另一个任务中也是如此。那应该陷入僵局,但是当重新进入时不会。基本上重新进入让我总体上感到紧张。感觉好像违反了各种假设。
乔恩·斯基特

1
让我感到遗憾的是,没有为该项目使用F#而被迫编写对重入友好的代码;)
Erwin Mayer 2012年

1
@ErwinMayer:您可以传递可取消的令牌,而永远不要取消它。看起来可以解决问题。或传递超时时间:)
Jon Skeet 2012年

4

为什么不只是为它设计,而不是向后弯腰以确保它不会发生?

TPL在这里是一个红鲱鱼,只要您可以创建一个循环,任何代码中都可以发生可重入,并且您不确定要在堆栈框架的“南部”发生什么。同步重入是最好的结果-至少您不能(如此轻松)自我解锁。

锁管理跨线程同步。它们与重新进入管理正交。除非您要保护真正的一次性使用资源(可能是物理设备,在这种情况下,您可能应该使用队列),否则为什么不只是确保实例状态一致,以便重新进入就可以“正常工作”。

(想法:信号量是否在不减少的情况下重新进入?)


0

您可以通过编写一个在线程/任务之间共享套接字连接的快速应用程序来轻松地对此进行测试。

该任务将获得一个锁,然后再通过套接字发送消息并等待响应。一旦该块变为空闲(IOBlock),则在同一块中设置另一个任务以执行相同的操作。如果没有,它应该阻止获取锁,并且第二个任务被允许通过该锁,因为它由同一线程运行,那么您就遇到了问题。


0

new CancellationToken()Erwin提出的解决方案对我不起作用,无论如何都发生内联。

因此,我最终使用了Jon和Stephen(... or if you pass in a non-infinite timeout ...)建议的另一种条件:

  Task<TResult> task = Task.Run(func);
  task.Wait(TimeSpan.FromHours(1)); // Whatever is enough for task to start
  return task.Result;

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.