Why is TaskScheduler.Current the default TaskScheduler?

Julien Lebosquain picture Julien Lebosquain · Jul 23, 2011 · Viewed 18.9k times · Source

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.

Answer

svick picture svick · Jul 23, 2011

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