为什么TaskScheduler.Current是默认的TaskScheduler?

76

任务并行库很棒,我在过去几个月中经常使用它。然而,有一件事情真的困扰着我:TaskScheduler.Current 是默认任务调度程序,而不是 TaskScheduler.Default。这在文档和示例中一眼看上去绝对不明显。

Current 可能会导致微妙的错误,因为其行为取决于是否在另一个任务中。这很难确定。

假设我正在编写一个异步方法库,使用基于事件的标准异步模式,在原同步上下文中使用事件来信号完成,就像 .NET Framework 中的 XxxAsync 方法一样(例如 DownloadFileAsync)。我决定使用任务并行库进行实现,因为使用以下代码轻松实现此行为:

public class MyLibrary
{
    public event EventHandler SomeOperationCompleted;

    private void OnSomeOperationCompleted()
    {
        SomeOperationCompleted?.Invoke(this, EventArgs.Empty);
    }

    public void DoSomeOperationAsync()
    {
        Task.Factory.StartNew(() =>
        {
            Thread.Sleep(1000); // simulate a long operation
        }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default)
        .ContinueWith(t =>
        {
            OnSomeOperationCompleted(); // trigger the event
        }, TaskScheduler.FromCurrentSynchronizationContext());
    }
}

到目前为止,一切都运行良好。现在,在WPF或WinForms应用程序中点击按钮时,让我们调用这个库:

private void Button_OnClick(object sender, EventArgs args)
{
    var myLibrary = new MyLibrary();
    myLibrary.SomeOperationCompleted += (s, e) => DoSomethingElse();
    myLibrary.DoSomeOperationAsync(); // call that triggers the event asynchronously
}

private void DoSomethingElse() // the event handler
{
    //...
    Task.Factory.StartNew(() => Thread.Sleep(5000)); // simulate a long operation
    //...
}

在这里,编写库调用的人选择在操作完成时启动一个新的Task。没有什么不寻常的。他或她遵循了网上到处都可以找到的示例,只是使用了Task.Factory.StartNew而没有指定TaskScheduler(也没有简单的重载来指定第二个参数)。当DoSomethingElse方法单独调用时,它可以正常工作,但是一旦被事件调用,UI就会冻结,因为TaskFactory.Current将从我的库继续中重用同步上下文任务调度程序。

发现这可能需要一些时间,特别是如果第二个任务调用埋藏在一些复杂的调用堆栈中。当然,一旦您知道所有内容的工作原理,修复起来很简单:始终为您预计在线程池上运行的任何操作指定TaskScheduler.Default。但是,也许第二个任务是由另一个外部库启动的,它不知道这种行为并且天真地使用StartNew而没有特定的调度程序。我预计这种情况非常普遍。

在我理解之后,我无法理解编写TPL的团队使用TaskScheduler.Current而不是TaskScheduler.Default作为默认值的选择:

  • 这一点并不明显,Default 不是默认值!而且文档缺失严重。
  • Current 使用的真正任务调度器取决于调用堆栈!这种行为很难保持不变性。
  • 使用 StartNew 指定任务调度器很麻烦,因为你必须先指定任务创建选项和取消令牌,导致代码行变长、可读性降低。可以通过编写扩展方法或创建一个使用 DefaultTaskFactory 来缓解这种情况。
  • 捕获调用堆栈会增加额外的性能成本。
  • 当我真正希望一个任务依赖于另一个正在运行的父任务时,我更喜欢明确指定它以方便代码阅读,而不是依赖于调用堆栈魔法。

我知道这个问题听起来可能相当主观,但我找不到一个好的客观论据来说明为什么会出现这种行为。我相信我在这里漏掉了什么:这就是为什么我求助于你。


我很难完全按照你的示例操作,但是这里的问题不在于消费代码(DoSomethingElse),它假设它将在UI上下文中调用吗?(如果这是您试图提出的观点-即它正在创建不在UI上下文中的任务) - Damien_The_Unbeliever
它的相反情况是:DoSomethingElse 可以在此处的任何上下文中运行,但在这种特定情况下,它创建的任务将在父任务的上下文中运行,在 UI 线程上运行,而无需知道。如果使用了“Default”任务调度程序,则没有问题。我没有问题指定它,但我不能控制每个第三方库,也不总是意识到这一事实。我真正不理解的是为什么 Current 是默认值,而所有这些潜在危险的上下文都可能发生变化。然而,这个问题可能太具有争议性了。 - Julien Lebosquain
9
在.NET 4.5中,现在有Task.Run,其中TaskScheduler.Default是默认的TaskScheduler:http://blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspx - Matt Smith
1
你考虑过显式调用 UI 线程而不是使用计划程序吗?对我来说,这似乎是一场灾难。尽管如此,我与你意见一致,TPL 团队在逻辑上相当缺乏。 - Gusdor
2
另一篇博客文章:http://blog.stephencleary.com/2013/08/startnew-is-dangerous.html - Karsten
1
他们承认你是对的: “始终指定显式的TaskScheduler参数以避免默认的Current值,其行为由调用方定义并且可能在运行时发生变化。Current返回与当前正在运行的任何Task关联的调度程序。在某些情况下,使用Current可能会导致死锁或UI响应性问题,当它旨在在线程池上创建任务时,但它却等待返回到UI线程。” 来自:https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2008#rule-description - Triynko
5个回答

21

我认为当前的行为是有意义的。如果我创建了自己的任务调度器,并启动一些启动其他任务的任务,我可能希望所有任务都使用我创建的调度器。

我同意有时从UI线程开始一个任务会使用默认的调度器,有时则不会,这很奇怪。但如果我要设计它,我不知道该如何改进。

关于你的具体问题:

  • 我认为在指定的调度程序上启动新任务的最简单方法是new Task(lambda).Start(scheduler)。这种方法的缺点是,如果任务返回值,您必须指定类型参数。TaskFactory.Create可以为您推断类型。
  • 您可以使用Dispatcher.Invoke()而不是使用TaskScheduler.FromCurrentSynchronizationContext()

2
@Julien Lebosquain 那么在使用 TPL 调用时,您应该始终明确指定要使用的 TaskScheduler。虽然需要写一点额外的代码,但可以保证得到您想要的结果。 - Drew Marsh
3
我同意Julien的观点,这种行为是糟糕的设计,语义上更改"default"的含义,在API的某个部分表示"默认调度程序",但在另一个部分则表示"当前调度程序(如果您正在运行),否则使用默认值",这将引起麻烦。事实上,这已经让rx团队陷入了麻烦!http://social.msdn.microsoft.com/Forums/en-US/rx/thread/5a7fd5cf-073f-45d1-a4d4-93a80dac390a - DanH
1
我完全不同意:通常,我不希望父任务调度程序被选择为子任务的默认任务调度程序。例如,调度程序可能用于同步对共享资源的访问 - 在这种情况下,它可以作为串行执行上下文实现。或者它可能是一个专用的执行上下文,用于写入/读取IO。子任务通常不应在此调度程序上执行。如果调度程序被实现为串行执行上下文,并且操作被同步调用 - 如果子任务也使用相同的调度程序,则还会遇到死锁问题。 - CouchDeveloper
@svick 我给你的回答点了踩,是因为你在回答中的第一句话:“这很有道理”。然而,从OP的经验、我的经验,甚至是微软原始.NET开发人员的经验来看,选择“私有”调度程序(例如来自线程池的线程)似乎要好得多。确实有很多用例_这样做_是有意义的,在使用当前调度程序作为默认值时,会导致像问题描述中所述的问题。我认为,这不是一个观点 ;) - CouchDeveloper
@svick 为了平衡一下,我给 Matthias 的回答点了赞——他称其为“非常不幸的实现”。 - CouchDeveloper
显示剩余3条评论

9

[编辑] 以下仅涉及由Task.Factory.StartNew使用的调度程序问题。 然而,Task.ContinueWith有一个硬编码的TaskScheduler.Current。 [/编辑]

首先,有一个简单的解决方案-请参见本文底部。

这个问题背后的原因很简单:不仅有一个默认任务调度程序(TaskScheduler.Default),还有一个TaskFactory的默认任务调度程序(TaskFactory.Scheduler)。 当创建TaskFactory时,可以在其构造函数中指定此默认调度程序。

然而,在Task.Factory后面的TaskFactory是如下创建的:

s_factory = new TaskFactory();

正如您所看到的,没有指定TaskScheduler; 默认构造函数使用null - 更好的选择是使用TaskScheduler.Default(文档说明使用“Current”具有相同的后果)。
这再次导致实现TaskFactory.DefaultScheduler(一个私有成员):

private TaskScheduler DefaultScheduler 
{ 
   get
   { 
      if (m_defaultScheduler == null) return TaskScheduler.Current;
      else return m_defaultScheduler;
   }
}

在这里,您应该能够认识到这种行为的原因:由于Task.Factory没有默认的任务调度程序,因此将使用当前的任务调度程序。

那么,当没有任务正在执行时(即我们没有当前的TaskScheduler)为什么我们不会遇到NullReferenceExceptions呢?
原因很简单:

public static TaskScheduler Current
{
    get
    {
        Task internalCurrent = Task.InternalCurrent;
        if (internalCurrent != null)
        {
            return internalCurrent.ExecutingTaskScheduler;
        }
        return Default;
    }
}

TaskScheduler.Current 默认为 TaskScheduler.Default

我认为这是一种非常不幸的实现方式。

然而,我们有一个简单的解决方法:我们可以将 Task.Factory 的默认 TaskScheduler 设置为 TaskScheduler.Default

TaskFactory factory = Task.Factory;
factory.GetType().InvokeMember("m_defaultScheduler", BindingFlags.SetField | BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly, null, factory, new object[] { TaskScheduler.Default });

虽然我回复得有点迟,但我希望我的回答能对你有所帮助 :-)


1
我已经看到了实现方式以及为什么会这样工作,但是这仍然是一个很好的答案,谢谢!关于使用反射来更改默认调度程序,我不会在生产代码中这样做,但它可能会对一些人有所帮助。 - Julien Lebosquain

6

如果我需要指定 TaskCreationOptions.LongRunning 会怎么样?我相信并非所有的 Task.Factory.StartNew()new Task() 都可被替换为 Task.Run() - isxaker

4
这并不明显,"Default"并非默认选项!而且文档也相当缺乏。
"Default"是默认选项,但它并不总是"Current"。正如其他答案已经回答的那样,如果你想在线程池上运行一个任务,你需要通过将"Default"调度程序传递到"TaskFactory"或"StartNew"方法中来显式设置"Current"调度程序。
由于你的问题涉及一个库,我认为答案是:你不应该做任何会改变代码外部可见的"Current"调度程序的事情。这意味着,在触发"SomeOperationCompleted"事件时,你不应该使用"TaskScheduler.FromCurrentSynchronizationContext()"。而是要像这样做:
public void DoSomeOperationAsync() {
    var context = SynchronizationContext.Current;
    Task.Factory
        .StartNew(() => Thread.Sleep(1000) /* simulate a long operation */)
        .ContinueWith(t => {
            context.Post(_ => OnSomeOperationCompleted(), null);
        });
}

我认为您甚至不需要在Default调度程序中显式启动任务 - 如果调用方希望,让其决定Current调度程序即可。


0
我刚刚花了几个小时来调试一个奇怪的问题,我的任务被安排在UI线程上,尽管我没有指定它。事实证明,问题正是你的示例代码所展示的:一个任务继续被安排在UI线程上,在那个继续中的某个地方,启动了一个新任务,然后被安排在UI线程上,因为当前正在执行的任务有一个特定的TaskScheduler设置。
幸运的是,这都是我自己的代码,所以我可以通过确保我的代码在启动新任务时指定TaskScheduler.Default来修复它,但如果你没有这么幸运,我的建议是使用Dispatcher.BeginInvoke而不是使用UI调度程序。
所以,代替:
var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
var task = Task.Factory.StartNew(() => Thread.Sleep(5000));
task.ContinueWith((t) => UpdateUI(), uiScheduler);

尝试:

var uiDispatcher = Dispatcher.CurrentDispatcher;
var task = Task.Factory.StartNew(() => Thread.Sleep(5000));
task.ContinueWith((t) => uiDispatcher.BeginInvoke(new Action(() => UpdateUI())));

不过可读性稍差一些。


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