A Tour of Task, Part 9: Delegate Tasks
• CommentsLast time, we looked at some archaic ways to start Delegate Tasks. Today we’ll look at a few members for creating Delegate Tasks in more modern code. Unlike the task constructor, these methods return a Delegate Task that is already running (or at least already scheduled to run).
TaskFactory.StartNew
First up is the oft-overused TaskFactory.StartNew
method. There are a few overloads available:
The overloads containing an object
parameter simply pass that value through to the continuation delegate; this is just an optimization to avoid an extra allocation in some cases, so we can ignore those overloads for now. That leaves two sets of overloads, which act like default parameters for the two core methods:
StartNew
can take a delegate without a return value (Action
) or with a return value (Func<TResult>
), and returns an appropriate task type based on whether the delegate returns a value. Note that neither of these delegate types are async
-aware delegates; this causes complications when developers try to use StartNew
to start an asynchronous task.
TaskFactory.StartNew
doesn’t support async
-aware delegates. Task.Run
does.
The “default values” for the StartNew
overloads come from their TaskFactory
instance. The CancellationToken
parameter defaults to TaskFactory.CancellationToken
. The TaskCreationOptions
parameter defaults to TaskFactory.CreationOptions
. The TaskScheduler
parameter defaults to TaskFactory.Scheduler
. Let’s consider each of these parameters in turn.
CancellationToken
First, the CancellationToken
. This paramter is often misunderstood. I’ve seen many (smart) developers pass a CancellationToken
to StartNew
believing that the token can be used to cancel the delegate at any time during its execution. However, this is not what happens. The CancellationToken
passed to StartNew
is only effective before the delegate starts executing. In other words, it cancels the starting of the delegate, not the delegate itself. Once that delegate starts executing, the CancellationToken
argument cannot be used to cancel that delegate. The delegate itself must observe the CancellationToken
(e.g., with CancellationToken.ThrowIfCancellationRequested
) in order to support cancellation after it starts executing.
However, there is a minor difference in behavior if you do also pass a CancellationToken
to StartNew
. If the delegate itself observes the CancellationToken
, then it will raise an OperationCanceledException
. If the StartNew
call does not include that CancellationToken
, then the returned task is faulted with that exception. However, if the delegate raises an OperationCanceledException
from the same CancellationToken
passed to StartNew
, then the returned task is canceled instead of faulted, and the OperationCanceledException
is replaced with a TaskCanceledException
.
OK, that was a bit much to describe in words. If you want to see the same details expressed in code, see the unit tests in this gist.
However, this difference in behavior does not impact your code as long as you use one of the the common patterns for detecting cancellation. For asynchronous code, you’d await
the task and catch an OperationCanceledException
(for more complete examples, see the unit tests in this gist):
For synchronous code, you’d call Wait
(or Result
) on the task and expect an AggregateException
whose InnerException
is an OperationCanceledException
(for more complete examples, see the unit tests in this gist):
In conclusion, the CancellationToken
parameter of StarNew
is nearly useless. It introduces some subtle changes in behavior, and is confusing to many developers. I never use it, myself.
TaskCreationOptions
There are a couple of “scheduling options” that are just passed to the TaskScheduler
that schedules the task. PreferFairness
is a hint asking for FIFO behavior. LongRunning
is a hint that the task will execute for a long time. As of this writing, the TaskScheduler.Default
task scheduler will create a separate thread (outside the thread pool) for tasks with the LongRunning
flag; however, this behavior is not guaranteed. Note that both of these options are just hints; it is entirely appropriate for the TaskScheduler
to ignore them both.
There are a few more “scheduling options” that are not passed to the TaskScheduler
. The HideScheduler
option (introduced in .NET 4.5) will use the given task scheulder to schedule the task, but then will pretend that there is no current task scheduler while the task is executing; this can be used as a workaround for the unexpected default task scheduler (described below). The RunContinuationsAsynchronously
option (introduced in .NET 4.6) will force any continuations of this task to execute asynchronously.
The “parenting options” control how the task is attached to the currently-executing task. Attached child tasks change the behavior of their parent task in ways that are convenient in some dynamic task parallelism scenarios, but are unexpected and awkward anywhere outside that (extremely small) use case. AttachedToParent
will attach the task as a child task of the currently-executing task. In modern code, you almost never want this option; more importantly, you almost never want other code to attach child tasks to your tasks. For this reason, the DenyChildAttach
option was introduced in .NET 4.5, which prevents any other tasks from using AttachedToParent
to attach to this task.
Task.Factory.StartNew
has a non-optimal default option setting of TaskCreationOptions.None
. Task.Run
uses the more appropriate default of TaskCreationOptions.DenyChildAttach
.
TaskScheduler
The TaskScheduler
is used to schedule the continuation. A TaskFactory
may define its own TaskScheduler
which it uses by default. Note that the default TaskScheduler
of the static Task.Factory
instance is not TaskScheduler.Default
, but rather TaskScheduler.Current
. This fact has caused quite a bit of confusion over the years, because the vast majority of the time, developers expect (and desire) TaskScheduler.Default
. I’ve described this problem in detail before, but a little review never hurts.
The following code first creates a UI task factory to schedule work to the UI thread. Then, as a part of that work, it starts some work to run in the background.
The output on my system is:
UI on thread 9
Background work on thread 9
The problem is that while the outer StartNew
is running, TaskScheduler.Current
is the UI task scheduler. This is picked up as the default value for the TaskScheduler
parameter by the inner StartNew
, which causes the background work to be scheduled to the UI thread rather than a thread pool thread. This scenario can be avoided by passing HideScheduler
to the outer StartNew
task, or by passing an explicit TaskScheduler.Default
to the inner StartNew
.
Task.Factory.StartNew
has a confusing default scheduler TaskScheduler.Current
. Task.Run
always uses the appropriate default of TaskScheduler.Default
.
In conclusion, I do not recommend using Task.Factory.StartNew
at all, unless you are doing dynamic task parallelism (which is extremely rare). In modern code, you should almost always use Task.Run
instead. If you do have a custom TaskScheduler
(e.g., one of the schedulers in ConcurrentExclusiveSchedulerPair
), then it is appropriate to create your own TaskFactory
instance and use StartNew
on that; however, Task.Factory.StartNew
should be avoided.
Update, 2015-03-04 (suggested by Bar Arnon): If you do choose to use StartNew
(i.e., if you need to use a custom TaskScheduler
), bear in mind that StartNew
does not automatically handle asynchronous delegates. To run asynchronous code on a custom task scheduler, you’ll need to use Unwrap
.
Task.Run
Task.Run
is the modern, preferred method for queueing work to the thread pool. It does not work with custom schedulers, but provides a simpler API than Task.Factory.StartNew
, and is async
-aware to boot:
There are three axis of overloading going on here: whether or not there is a CancellationToken
, whether the delegate returns a TResult
value, and whether the delegate is synchronous (Action
/Func<TResult>
) or asynchronous (Func<Task>
/Func<Task<TResult>>
). Technically, Task.Run
does not always create a Delegate Task; when it is given an asynchronous delegate, it actually returns a Promise Task. But conceptually, Task.Run
is specifically for executing delegates on the thread pool, so I’m covering this set of overloads along with StartNew
(which always does create Delegate Tasks).
The CancellationToken
parameter sadly has the same problems described above for StartNew
. That is, it really only cancels the scheduling of the delegate, which happens almost immediately. The presence of the CancellationToken
argument does change the semantics slightly, similarly to StartNew
. The full unit tests are in this gist, which has only one result that may be surprising: if an asynchronous delegate explicitly observes a CancellationToken
, the returned task will be canceled instead of faulted. Just like TaskFactory.StartNew
, these minor differences in semantics don’t matter if the consuming code uses the standard pattern for detecting cancellation.
So, I conclude that the CancellationToken
parameter of Task.Run
is pretty much useless.
However, the other overloads are quite useful, and Task.Run
is the best modern way for most code to queue work to the thread pool.