Почему 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())));
Это немного менее читабельно, хотя.