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

发布于 2024-11-26 09:04:01 字数 2816 浏览 1 评论 0原文

任务并行库非常棒,在过去的几个月里我经常使用它。然而,有件事确实困扰着我: 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 来缓解。
  • 捕获调用堆栈会产生额外的性能成本。
  • 当我确实希望一个任务依赖于另一个正在运行的父任务时,我更喜欢显式指定它以方便代码阅读,而不是依赖调用堆栈魔法。

我知道这个问题可能听起来很主观,但我找不到一个很好的客观论据来解释为什么这种行为是这样的。我确信我在这里遗漏了一些东西:这就是我向你求助的原因。

The Task Parallel Library is great and I've used it a lot in the past months. However, there's something really bothering me: the fact that TaskScheduler.Current is the default task scheduler, not TaskScheduler.Default. This is absolutely not obvious at first glance in the documentation nor samples.

Current can lead to subtle bugs since its behavior is changing depending on whether you're inside another task. Which can't be determined easily.

Suppose I am writting a library of asynchronous methods, using the standard async pattern based on events to signal completion on the original synchronisation context, in the exact same way XxxAsync methods do in the .NET Framework (eg DownloadFileAsync). I decide to use the Task Parallel Library for implementation because it's really easy to implement this behavior with the following code:

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

So far, everything works well. Now, let's make a call to this library on a button click in a WPF or WinForms application:

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
    //...
}

Here, the person writing the library call chose to start a new Task when the operation completes. Nothing unusual. He or she follows examples found everywhere on the web and simply use Task.Factory.StartNew without specifying the TaskScheduler (and there is no easy overload to specify it at the second parameter). The DoSomethingElse method works fine when called alone, but as soon at it's invoked by the event, the UI freezes since TaskFactory.Current will reuse the synchronization context task scheduler from my library continuation.

Finding out this could take some time, especially if the second task call is buried down in some complex call stack. Of course, the fix here is simple once you know how everything works: always specify TaskScheduler.Default for any operation you're expecting to be running on the thread pool. However, maybe the second task is started by another external library, not knowing about this behavior and naively using StartNew without a specific scheduler. I'm expecting this case to be quite common.

After wrapping my head around it, I can't understand the choice of the team writing the TPL to use TaskScheduler.Current instead of TaskScheduler.Default as the default:

  • It's not obvious at all, Default is not the default! And the documentation is seriously lacking.
  • The real task scheduler used by Current depends of the call stack! It's hard to maintain invariants with this behavior.
  • It's cumbersome to specify the task scheduler with StartNew since you have to specify the task creation options and cancellation token first, leading to long, less readable lines. This can be alleviated by writing an extension method or creating a TaskFactory that uses Default.
  • Capturing the call stack has additional performance costs.
  • When I really want a task to be dependent on another parent running task, I prefer to specify it explicitly to ease code reading rather than rely on call stack magic.

I know this question may sound quite subjective, but I can't find a good objective argument as to why this behavior is as it. I'm sure I'm missing something here: that's why I'm turning to you.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(5

污味仙女 2024-12-03 09:04:01

我认为目前的行为是有道理的。如果我创建自己的任务计划程序,并启动一些启动其他任务的任务,我可能希望所有任务都使用我创建的计划程序。

我同意奇怪的是,有时从 UI 线程启动任务使用默认调度程序,有时则不使用。但我不知道如果我来设计它,我该如何让它变得更好。

关于您的具体问题:

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

I think the current behavior makes sense. If I create my own task scheduler, and start some task that starts other tasks, I probably want all the tasks to use the scheduler I created.

I agree that it's odd that sometimes starting a task from the UI thread uses the default scheduler and sometimes not. But I don't know how would I make this better if I was designing it.

Regarding your specific problems:

  • I think the easiest way to start a new task on a specified scheduler is new Task(lambda).Start(scheduler). This has the disadvantage that you have to specify type argument if the task returns something. TaskFactory.Create can infer the type for you.
  • You can use Dispatcher.Invoke() instead of using TaskScheduler.FromCurrentSynchronizationContext().
掩饰不了的爱 2024-12-03 09:04:01

[编辑]
以下仅解决 Task.Factory.StartNew 使用的调度程序的问题。
但是,Task.ContinueWith 有一个硬编码的TaskScheduler.Current
[/编辑]

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

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

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

s_factory = new TaskFactory();

正如你所看到的,没有指定TaskSchedulernull 用于默认构造函数 - 更好的是 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 });

我希望我能提供帮助我的回应虽然已经很晚了:-)

[EDIT]
The following only addresses the problem with the scheduler used by Task.Factory.StartNew.
However, Task.ContinueWith has a hardcoded TaskScheduler.Current.
[/EDIT]

First, there is an easy solution available - see the bottom of this post.

The reason behind this problem is simple: There is not only a default task scheduler (TaskScheduler.Default) but also a default task scheduler for a TaskFactory (TaskFactory.Scheduler).
This default scheduler can be specified in the constructor of the TaskFactory when it's created.

However, the TaskFactory behind Task.Factory is created as follows:

s_factory = new TaskFactory();

As you can see, no TaskScheduler is specified; null is used for the default constructor - better would be TaskScheduler.Default (the documentation states that "Current" is used which has the same consequences).
This again leads to the implementation of TaskFactory.DefaultScheduler (a private member):

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

Here you should see be able to recognize the reason for this behaviour: As Task.Factory has no default task scheduler, the current one will be used.

So why don't we run into NullReferenceExceptions then, when no Task is currently executing (i.e. we have no current TaskScheduler)?
The reason is simple:

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

TaskScheduler.Current defaults to TaskScheduler.Default.

I would call this a very unfortunate implementation.

However, there is an easy fix available: We can simply set the default TaskScheduler of Task.Factory to 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 });

I hope I could help with my response although it's quite late :-)

≈。彩虹 2024-12-03 09:04:01

) 而不是 Task.Factory.StartNew()

考虑使用: Task.Run(

,这将始终在线程池线程上执行。我刚刚遇到了问题中描述的同样的问题,我认为这是处理这个问题的好方法。

请参阅此博客条目:
Task.Run 与 Task.Factory.StartNew

Instead of Task.Factory.StartNew()

consider using: Task.Run()

This will always execute on a thread pool thread. I just had the same problem described in the question and I think that is a good way of handling this.

See this blog entry:
Task.Run vs Task.Factory.StartNew

凡尘雨 2024-12-03 09:04:01

一点也不明显,Default 不是默认!而且文档严重缺乏。

Default 是默认值,但并不总是Current

正如其他人已经回答的那样,如果您希望任务在线程池上运行,则需要通过将 Default 调度程序传递到 Current 调度程序到 Current 调度程序来显式设置 Current 调度程序>TaskFactory 或 StartNew 方法。

由于您的问题涉及一个库,所以我认为答案是您不应该做任何会更改库外部代码所看到的当前调度程序的事情。这意味着当您引发 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 调度程序(如果他们愿意) 。

It's not obvious at all, Default is not the default! And the documentation is seriously lacking.

Default is the default, but it's not always the Current.

As others have already answered, if you want a task to run on the thread pool, you need to explicitly set the Current scheduler by passing the Default scheduler into either the TaskFactory or the StartNew method.

Since your question involved a library though, I think the answer is that you should not do anything that will change the Current scheduler that's seen by code outside your library. That means that you should not use TaskScheduler.FromCurrentSynchronizationContext() when you raise the SomeOperationCompleted event. Instead, do something like this:

public void DoSomeOperationAsync() {
    var context = SynchronizationContext.Current;
    Task.Factory
        .StartNew(() => Thread.Sleep(1000) /* simulate a long operation */)
        .ContinueWith(t => {
            context.Post(_ => OnSomeOperationCompleted(), null);
        });
}

I don't even think you need to explicitly start your task on the Default scheduler - let the caller determine the Current scheduler if they want to.

爱情眠于流年 2024-12-03 09:04:01

我刚刚花了几个小时尝试调试一个奇怪的问题,即我的任务被安排在 UI 线程上,即使我没有指定它。事实证明,问题正是您的示例代码所演示的:在 UI 线程上安排了一个任务延续,并且在该延续中的某个位置启动了一个新任务,然后将该任务安排在 UI 线程上,因为当前正在执行的任务有一个特定的TaskScheduler集。

幸运的是,这些都是我拥有的代码,因此我可以通过确保我的代码在启动新任务时指定 TaskScheduler.Default 来修复它,但如果您没那么幸运,我的建议是使用 < code>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())));

虽然它的可读性有点差。

I've just spent hours trying to debug a weird issue where my task was scheduled on the UI thread, even though I didn't specify it to. It turned out the problem was exactly what your sample code demonstrated: A task continuation was scheduled on the UI thread, and somewhere in that continuation, a new task was started which then got scheduled on the UI thread, because the currently executing task had a specific TaskScheduler set.

Luckily, it's all code I own, so I can fix it by making sure my code specify TaskScheduler.Default when starting new tasks, but if you aren't so lucky, my suggestion would be to use Dispatcher.BeginInvoke instead of using the UI scheduler.

So, instead of:

var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
var task = Task.Factory.StartNew(() => Thread.Sleep(5000));
task.ContinueWith((t) => UpdateUI(), uiScheduler);

Try:

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

It's a bit less readable though.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文