Подтвердить что ты не робот

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

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

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

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

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.
  • Захват стека вызовов имеет дополнительные затраты на производительность.
  • Когда я действительно хочу, чтобы задача зависела от другой выполняемой задачи родителя, я предпочитаю явно указывать ее, чтобы облегчить чтение кода, а не полагаться на магию стека вызовов.

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

4b9b3361

Ответ 1

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

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

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

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

Ответ 2

[EDIT] Ниже приведена только проблема с планировщиком, используемым Task.Factory.StartNew.
Однако Task.ContinueWith имеет жестко закодированный TaskScheduler.Current. [/EDIT]

Во-первых, доступно простое решение - см. нижнюю часть этой публикации.

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

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

s_factory = new TaskFactory();

Как вы можете видеть, не указано TaskFactory; null используется для конструктора по умолчанию - лучше будет TaskScheduler.Default (в документации указано, что используется "Текущий", который имеет те же последствия).
Это снова приводит к реализации 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 });

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

Ответ 3

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

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

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

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

Ответ 4

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

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, если они этого захотят.

Ответ 5

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

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

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

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

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