Why is TaskScheduler.Current the default TaskScheduler?
Asked Answered
L

5

77

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.

Longish answered 23/7, 2011 at 13:34 Comment(6)
I'm struggling to follow your example exactly, but isn't the fault here in the consuming code (DoSomethingElse) assuming that it's going to be called in the UI context? (If that's the point your trying to make - that it's creating tasks not in the UI context)Fragment
It the opposite: DoSomethingElse can run in any context here, but in this specific case, the task it creates will run in the context of a parent task, itself running on the UI thread, without knowing it. There is no problem if the Default task scheduler was used. I don't have any problem with specifying it, but I don't control every third party library, not always aware of this fact. What I don't understand is really why is Current the default with all those potentially dangerous changing contexts. This question is probably too argumentative though.Longish
In .NET 4.5, there is now Task.Run, where TaskScheduler.Default is the default TaskScheduler: blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspxOptional
Have you considered explicitly invoking to the UI thread rather than doing it with the scheduler? It seems like a recipe for disaster to me. I agree with you though, this is fairly lacking in logic on the TPL team's side.Anurous
Another blogpost: blog.stephencleary.com/2013/08/startnew-is-dangerous.htmlConsensus
They admit you're right: "Always specify an explicit TaskScheduler argument to avoid the default Current value, whose behavior is defined by the caller and may vary at run time. Current returns the scheduler associated with whatever Task is currently running on that thread. Using Current could lead to deadlocks or UI responsiveness issues in some situations, when it was intended to create the task on the thread pool, but instead it waits to get back onto the UI thread." From: learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/…Niki
C
22

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().
Cudlip answered 23/7, 2011 at 14:22 Comment(8)
Didn't though about the custom TaskScheduler, but that's probably where I'm bothered: if someone creates its own task scheduler and starts calling my code with a parent task, I don't want my code behavior to change, unless I really want it (specifying Current manually). Concerning my specific problems, I prefer the custom TaskFactory way, but thanks anyway for the solutions.Longish
@Julien Lebosquain Then you should always be explicit in specifying the TaskScheduler you do want to use in your TPL calls. Little extra code to type, but gives you the guarantee you get what you want.Tutty
I agree with Julien, this behaviour is poor design and to semantically change the meaning of default to mean 'default scheduler' in one part of the api, but 'current scheduler if you are running under one, else default' in another part of the api is asking for trouble. In fact it's caught out the rx team too! social.msdn.microsoft.com/Forums/en-US/rx/thread/…Measureless
@Drew, I think the point is not what Julien does in his own code, it's about what happens with other libraries when the authors of those libraries make the easy mistake of using Current when they really meant Default. As Dan points out, we've already seen this in Rx, and I have encountered it in other libraries too. IMO the way to address this is to deprecate the APIs that currently use Current by default (if you see what I mean!) and replace them with ones that require the scheduler to be specified explicitly.Paulinapauline
I disagree wholeheartly:Usually, I do NOT want the paren's task scheduler to be chosen as the default task scheduler for a child task. A scheduler for example might be used to synchronize access to shared resources - in which case it may be implemented as a serial execution context. Or it might be a dedicated execution context for writing/reading IO. A child task should usually not be executed on this scheduler. If the scheduler is implemented as a serial execution context and actions are invoked synchronously - you also run into a dead lock if the child also uses the same scheduler.Passade
@Passade I was trying to justify the existing behavior. I don't think you should downvote answers just because you disagree with the decisions the answer is trying to explain.Cudlip
@Cudlip I downvoted because of your first statement in your answer where you say "it makes sense". However, from the OPs experience, form my own experience and even from the experience of the original .NET developers at MS which designed this library and changed it later, it seems it is far better to choose a "private" scheduler (e.g. a thread from the thread-pool). There are really a lot of use cases where this makes sense, and where, when the current scheduler will be used as default, it will lead to issues as described in the question. I don't think, this is an opinion ;)Passade
@Cudlip To balance it, I upvoted Matthias 's answer - who called it "a very unfortunate implementation".Passade
B
9

[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 :-)

Browse answered 20/11, 2011 at 13:39 Comment(1)
I already saw the implementation and why it works like this, but this is a great answer nonetheless, thanks! Concerning the use of reflection to change the default scheduler, I won't do that in production code but it might help some.Longish
W
6

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

Weitzman answered 28/8, 2013 at 7:40 Comment(1)
and what if i need to specify TaskCreationOptions.LongRunning ? I believe that not all Task.Factory.StartNew() or new Task() places can be changed with Task.Run()Incommodity
B
4

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.

Beaconsfield answered 27/2, 2013 at 17:56 Comment(0)
K
0

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.

Kaykaya answered 31/5, 2013 at 9:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.