Почему 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()
    {
        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 отлично работает при вызове в одиночку, но как только он вызывается событием, пользовательский интерфейс зависает, поскольку TaskFactory.Current будет повторно использовать планировщик задач контекста синхронизации из продолжения моей библиотеки.

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

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

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

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


person Julien Lebosquain    schedule 23.07.2011    source источник
comment
Я изо всех сил пытаюсь точно следовать вашему примеру, но не является ли здесь ошибкой потребляющий код (DoSomethingElse), предполагающий, что он будет вызываться в контексте пользовательского интерфейса? (Если это то, что вы пытаетесь сделать - это создание задач не в контексте пользовательского интерфейса)   -  person Damien_The_Unbeliever    schedule 23.07.2011
comment
Наоборот: здесь DoSomethingElse может выполняться в любом контексте, но в данном конкретном случае создаваемая им задача будет выполняться в контексте родительской задачи, которая сама выполняется в потоке пользовательского интерфейса, не зная об этом. Нет проблем, если использовался планировщик задач Default. У меня нет проблем с его указанием, но я не контролирую каждую стороннюю библиотеку, не всегда зная об этом факте. Чего я действительно не понимаю, так это почему Current используется по умолчанию со всеми этими потенциально опасными меняющимися контекстами. Хотя этот вопрос, наверное, слишком спорный.   -  person Julien Lebosquain    schedule 23.07.2011
comment
В .NET 4.5 теперь есть Task.Run, где TaskScheduler.Default — это TaskScheduler по умолчанию: blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspx   -  person Matt Smith    schedule 05.03.2013
comment
Рассматривали ли вы возможность явного вызова потока пользовательского интерфейса вместо того, чтобы делать это с помощью планировщика? Мне это кажется рецептом катастрофы. Я согласен с вами, однако, со стороны команды TPL довольно не хватает логики.   -  person Gusdor    schedule 19.08.2013
comment
Другой пост в блоге: blog.stephencleary.com/2013/08/startnew-is -dangerous.html   -  person Karsten    schedule 13.12.2013


Ответы (5)


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

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

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

  • Я думаю, что самый простой способ запустить новую задачу в указанном планировщике — это new Task(lambda).Start(scheduler). Недостатком этого является то, что вы должны указать аргумент типа, если задача что-то возвращает. TaskFactory.Create может определить тип для вас.
  • Вы можете использовать Dispatcher.Invoke() вместо TaskScheduler.FromCurrentSynchronizationContext().
person svick    schedule 23.07.2011
comment
Хотя я не говорил о пользовательском TaskScheduler, но, вероятно, меня это беспокоит: если кто-то создает свой собственный планировщик задач и начинает вызывать мой код с родительской задачей, я не хочу, чтобы поведение моего кода менялось, если я действительно не хочу it (указав Current вручную). Что касается моих конкретных проблем, я предпочитаю собственный способ TaskFactory, но все равно спасибо за решения. - person Julien Lebosquain; 23.07.2011
comment
@Julien Lebosquain Тогда вы всегда должны четко указывать TaskScheduler, который вы хотите использовать в своих вызовах TPL. Небольшой дополнительный код для ввода, но дает вам гарантию, что вы получите то, что хотите. - person Drew Marsh; 23.07.2011
comment
Я согласен с Жюльеном, такое поведение имеет плохой дизайн и семантически изменить значение по умолчанию на «планировщик по умолчанию» в одной части API, но «текущий планировщик, если вы работаете под одним, иначе по умолчанию» в другой части. API напрашивается на неприятности. На самом деле это поймало и команду rx! social.msdn.microsoft. com/Forums/en-US/rx/thread/ - person DanH; 08.11.2011
comment
@Drew, я думаю, дело не в том, что Жюльен делает в своем собственном коде, а в том, что происходит с другими библиотеками, когда авторы этих библиотек делают легкую ошибку, используя Current, когда они действительно имели в виду Default. Как указывает Дэн, мы уже видели это в Rx, и я встречал это и в других библиотеках. IMO, способ решить эту проблему — отказаться от API, которые в настоящее время используют Current по умолчанию (если вы понимаете, что я имею в виду!) и заменить их теми, которые требуют явного указания планировщика. - person Mike Nunan; 23.11.2011
comment
Я полностью не согласен: обычно я НЕ хочу, чтобы планировщик задач родителя выбирался в качестве планировщика задач по умолчанию для дочерней задачи. Например, планировщик может использоваться для синхронизации доступа к общим ресурсам, и в этом случае он может быть реализован как контекст последовательного выполнения. Или это может быть выделенный контекст выполнения для записи/чтения ввода-вывода. Дочерняя задача обычно не должна выполняться в этом планировщике. Если планировщик реализован как контекст последовательного выполнения и действия вызываются синхронно — вы также столкнетесь с мертвой блокировкой, если дочерний элемент также использует тот же планировщик. - person CouchDeveloper; 18.03.2015
comment
@CouchDeveloper Я пытался оправдать существующее поведение. Я не думаю, что вам следует отрицать ответы только потому, что вы не согласны с решениями, которые пытается объяснить ответ. - person svick; 18.03.2015
comment
@svick Я проголосовал против из-за вашего первого утверждения в вашем ответе, где вы говорите, что это имеет смысл. Однако из опыта OP, моего собственного опыта и даже из опыта первоначальных разработчиков .NET в MS, которые разработали эту библиотеку и позже изменили ее, кажется, что гораздо лучше выбрать частный планировщик (например, поток из пул потоков). Существует действительно много случаев использования, когда это имеет смысл, и где, когда текущий планировщик будет использоваться по умолчанию, это приведет к проблемам, описанным в вопросе. Я не думаю, это мнение ;) - person CouchDeveloper; 19.03.2015
comment
@svick Чтобы сбалансировать это, я проголосовал за ответ Матиаса, который назвал его очень неудачной реализацией. - person CouchDeveloper; 19.03.2015

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

Во-первых, есть простое решение — смотрите внизу этого поста.

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

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

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

Так почему бы нам не столкнуться с 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 });

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

person Matthias    schedule 20.11.2011
comment
Я уже видел реализацию и то, почему она так работает, но тем не менее это отличный ответ, спасибо! Что касается использования отражения для изменения планировщика по умолчанию, я не буду делать это в рабочем коде, но некоторым это может помочь. - person Julien Lebosquain; 22.11.2011

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

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

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

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

person testalino    schedule 28.08.2013
comment
а что если мне нужно указать TaskCreationOptions.LongRunning ? Я считаю, что не все Task.Factory.StartNew() или new Task() места можно поменять с помощью Task.Run() - person isxaker; 10.05.2019

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

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

person Rory MacLeod    schedule 27.02.2013

Я только что потратил часы, пытаясь отладить странную проблему, когда моя задача была запланирована в потоке пользовательского интерфейса, хотя я этого не указывал. Оказалось, что проблема была именно в том, что продемонстрировал ваш пример кода: продолжение задачи было запланировано в потоке пользовательского интерфейса, и где-то в этом продолжении была запущена новая задача, которая затем была запланирована в потоке пользовательского интерфейса, потому что текущая выполняемая задача имела конкретный 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())));

Хотя это немного менее читабельно.

person René    schedule 31.05.2013