Почему TaskScheduler.Current является TaskScheduler по умолчанию?

Библиотека параллельных заданий великолепна, и я много использовал ее в последние месяцы. Однако, что-то действительно беспокоит меня: тот факт, что TaskScheduler.Current является планировщиком заданий по умолчанию, а не TaskScheduler.Default, Это абсолютно не очевидно на первый взгляд ни в документации, ни в образцах.

Current может привести к незначительным ошибкам, поскольку его поведение меняется в зависимости от того, находитесь ли вы внутри другой задачи. Который не может быть определен легко.

Предположим, я пишу библиотеку асинхронных методов, используя стандартный асинхронный шаблон, основанный на событиях, для оповещения о завершении в исходном контексте синхронизации точно так же, как методы XxxAsync в.NET Framework (например, DownloadFileAsync). Я решил использовать Task Parallel Library для реализации, потому что это действительно легко реализовать с помощью следующего кода:

public class MyLibrary {
    public event EventHandler SomeOperationCompleted;

    private void OnSomeOperationCompleted() {
        var handler = SomeOperationCompleted;
        if (handler != null)
            handler(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()
                            , TaskScheduler.FromCurrentSynchronizationContext()
                            );
    }
}

Пока все работает хорошо. Теперь давайте вызовем эту библиотеку нажатием кнопки в приложении WPF или WinForms:

private void Button_OnClick(object sender, EventArgs args) {
    var myLibrary = new MyLibrary();
    myLibrary.SomeOperationCompleted += (s, e) => DoSomethingElse();
    myLibrary.DoSomeOperationAsync();
}

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

Здесь человек, пишущий библиотечный вызов, решил начать новый Task когда операция завершится. Ничего необычного Он или она следует за примерами, найденными всюду в сети и просто использует Task.Factory.StartNew без указания TaskScheduler (и нет легкой перегрузки, чтобы указать его во втором параметре). DoSomethingElse Метод работает нормально, когда вызывается один, но как только он вызывается событием, пользовательский интерфейс зависает, так как TaskFactory.Current будет повторно использовать планировщик задач контекста синхронизации из продолжения моей библиотеки.

Обнаружение этого может занять некоторое время, особенно если второй вызов задачи скрыт в некотором сложном стеке вызовов. Конечно, исправить это просто, если вы знаете, как все работает: всегда указывайте TaskScheduler.Default для любой операции, которую вы ожидаете запустить в пуле потоков. Однако, возможно, вторая задача запускается другой внешней библиотекой, не зная об этом поведении и наивно используя StartNew без конкретного планировщика. Я ожидаю, что этот случай будет довольно распространенным.

Обернувшись вокруг этого, я не могу понять выбор команды, пишущей TPL для использования TaskScheduler.Current вместо TaskScheduler.Default по умолчанию:

  • Это совсем не очевидно, Default не по умолчанию! И документации серьезно не хватает.
  • Планировщик реальных задач, используемый Current зависит от стека вызовов! С таким поведением трудно поддерживать инварианты.
  • Трудно указать планировщик задач с StartNew поскольку сначала нужно указать параметры создания задачи и маркер отмены, что приведет к длинным, менее читаемым строкам. Это можно облегчить, написав метод расширения или создав TaskFactory который использует Default,
  • Захват стека вызовов имеет дополнительные затраты производительности.
  • Когда я действительно хочу, чтобы задача зависела от другой выполняющейся родительской задачи, я предпочитаю указывать ее явно, чтобы упростить чтение кода, а не полагаться на магию стека вызовов.

Я знаю, что этот вопрос может звучать довольно субъективно, но я не могу найти хороший объективный аргумент в отношении того, почему это поведение таково. Я уверен, что чего-то здесь не хватает: вот почему я обращаюсь к вам.

5 ответов

Я думаю, что текущее поведение имеет смысл. Если я создаю свой собственный планировщик задач и запускаю задачу, которая запускает другие задачи, я, вероятно, хочу, чтобы все задачи использовали созданный мной планировщик.

Я согласен, что странно, что иногда при запуске задачи из потока пользовательского интерфейса используется планировщик по умолчанию, а иногда нет. Но я не знаю, как бы сделать это лучше, если бы я проектировал это.

Что касается ваших конкретных проблем:

  • Я думаю, что самый простой способ запустить новую задачу в указанном планировщике new Task(lambda).Start(scheduler), Это имеет тот недостаток, что вы должны указывать аргумент типа, если задача что-то возвращает. TaskFactory.Create могу вывести тип для вас.
  • Ты можешь использовать Dispatcher.Invoke() Вместо того, чтобы использовать TaskScheduler.FromCurrentSynchronizationContext(),

[РЕДАКТИРОВАТЬ] Следующее только решает проблему с планировщиком, используемым Task.Factory.StartNew,
Тем не мение, Task.ContinueWith имеет жесткий код TaskScheduler.Current, [/РЕДАКТИРОВАТЬ]

Во-первых, доступно простое решение - см. Нижнюю часть этого поста.

Причина этой проблемы проста: существует не только планировщик задач по умолчанию (TaskScheduler.Default), но также планировщик задач по умолчанию для TaskFactory (TaskFactory.Scheduler). Этот планировщик по умолчанию может быть указан в конструкторе TaskFactory когда это будет создано.

Тем не менее TaskFactory позади Task.Factory создается следующим образом:

s_factory = new TaskFactory();

Как видите, нет TaskFactory указан; null используется для конструктора по умолчанию - лучше будет TaskScheduler.Default (в документации указано, что используется "Current", что имеет те же последствия).
Это снова приводит к реализации TaskFactory.DefaultScheduler (частный участник):

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

Здесь вы должны увидеть, как можно распознать причину такого поведения: поскольку Task.Factory не имеет планировщика заданий по умолчанию, будет использоваться текущий.

Так почему бы нам не столкнуться с NullReferenceExceptions тогда, когда в данный момент не выполняется ни одна Задача (т.е. у нас нет текущей TaskScheduler)?
Причина проста:

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

TaskScheduler.Current по умолчанию TaskScheduler.Default,

Я бы назвал это очень неудачной реализацией.

Тем не менее, есть простое исправление: мы можем просто установить значение по умолчанию TaskScheduler из Task.Factory в 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 });

Я надеюсь, что смогу помочь с моим ответом, хотя уже довольно поздно:-)

Вместо Task.Factory.StartNew()

рассмотреть возможность использования: Task.Run()

Это всегда будет выполняться в потоке пула потоков. У меня была та же самая проблема, описанная в вопросе, и я думаю, что это хороший способ справиться с этим.

Смотрите эту запись в блоге: http://blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspx

Это не очевидно, по умолчанию не по умолчанию! И документации серьезно не хватает.

Default по умолчанию, но это не всегда Current,

Как уже отвечали другие, если вы хотите, чтобы задача выполнялась в пуле потоков, вам нужно явно установить Current планировщик, передавая Default планировщик либо в TaskFactory или StartNew метод.

Поскольку ваш вопрос связан с библиотекой, я думаю, что ответ заключается в том, что вы не должны делать ничего, что изменит Current планировщик, который виден кодом за пределами вашей библиотеки. Это означает, что вы не должны использовать TaskScheduler.FromCurrentSynchronizationContext() когда вы поднимаете SomeOperationCompleted событие. Вместо этого сделайте что-то вроде этого:

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 Планировщик, если они хотят.

Я просто часами пытался отладить странную проблему, когда моя задача была запланирована в потоке пользовательского интерфейса, хотя я не указывал ее. Оказалось, что проблема была именно в том, что продемонстрировал ваш пример кода: продолжение задачи было запланировано в потоке пользовательского интерфейса, и где-то в этом продолжении была запущена новая задача, которая затем была запланирована в потоке пользовательского интерфейса, потому что текущая выполняемая задача имела конкретный TaskScheduler задавать.

К счастью, это весь код, которым я владею, поэтому я могу это исправить, убедившись, что мой код указывает TaskScheduler.Default при запуске новых задач, но если вам не так повезло, я бы предложил использовать Dispatcher.BeginInvoke вместо использования планировщика пользовательского интерфейса.

Итак, вместо:

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

Это немного менее читабельно, хотя.

Другие вопросы по тегам