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() {
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()
);
}
}
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();
}
private void DoSomethingElse() {
...
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 aTaskFactory
that usesDefault
. - 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.
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:
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.Dispatcher.Invoke()
instead of usingTaskScheduler.FromCurrentSynchronizationContext()
.[EDIT] The following only addresses the problem with the scheduler used by
Task.Factory.StartNew
.However,
Task.ContinueWith
has a hardcodedTaskScheduler.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 aTaskFactory
(TaskFactory.Scheduler
). This default scheduler can be specified in the constructor of theTaskFactory
when it's created.However, the
TaskFactory
behindTask.Factory
is created as follows:As you can see, no
TaskFactory
is specified;null
is used for the default constructor - better would beTaskScheduler.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):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:
TaskScheduler.Current
defaults toTaskScheduler.Default
.I would call this a very unfortunate implementation.
However, there is an easy fix available: We can simply set the default
TaskScheduler
ofTask.Factory
toTaskScheduler.Default
I hope I could help with my response although it's quite late :-)
Default
is the default, but it's not always theCurrent
.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 theDefault
scheduler into either theTaskFactory
or theStartNew
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 useTaskScheduler.FromCurrentSynchronizationContext()
when you raise theSomeOperationCompleted
event. Instead, do something like this:I don't even think you need to explicitly start your task on the
Default
scheduler - let the caller determine theCurrent
scheduler if they want to.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: http://blogs.msdn.com/b/pfxteam/archive/2011/10/24/10229468.aspx
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 useDispatcher.BeginInvoke
instead of using the UI scheduler.So, instead of:
Try:
It's a bit less readable though.