A Tour of Task, Part 7: Continuations
• CommentsRecent posts have considered several members that wait for tasks to complete (Wait
, WaitAll
, WaitAny
, Result
, and GetAwaiter().GetResult()
). One common disadvantage that all of these have is that they synchronously block the calling thread while waiting for the task to complete.
Today’s post talks about continuations. A continuation is a delegate that you can attach to a task and tell the task “run this when you’re done.” When the task completes, it will then schedule its continuations. The task that a continuation attaches to is called the “antecedent” task.
Continuations are important because they don’t block any threads. Instead of (synchronously) waiting for a task to complete, a thread may just attach a continuation for the task to run whenever it does complete. This is the essence of asynchrony, and the async
/await
system uses continuations whenever it deals with tasks.
ContinueWith
The most low-level way to attach continuations to a task is to use its ContinueWith
method. There are quite a number of overloads, but the general idea is to attach a delegate as a continuation for the task:
Whew, that’s a lot of overloads! Let’s break it down a little. First, the overloads containing an object
parameter just 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:
There’s also three optional parameters: a CancellationToken
(defaulting to CancellationToken.None
), a set of TaskContinuationOptions
(defaulting to TaskContinuationOptions.None
), and a TaskScheduler
(defaulting to TaskScheduler.Current
). So this list of overloads can be further simplified to:
The Task<T>
type has its own matching set of overloads. I won’t bore you with the details - there are another 20 method signatures, which simplify in the same manner down to:
At this point, it should be clear that there are two primary types of continuation delegates that can be passed to ContinueWith
: one has a result value (Func<...>
) and the other does not (Action<...>
). The continuation delegate always receives a task as a parameter. This is the task that the continuation is attaching to, so if you were to call task.ContinueWith(t => ...)
, then task
and t
refer to the same antecedent task instance.
ContinueWith
also returns a task. This task represents the continuation itself. So, each continuation is itself a task, and may have its own continuations, and so on.
Let’s talk a bit more about the optional parameters.
First, the CancellationToken
. If you cancel the token before the continuation is scheduled, then the continuation delegate never actually runs - it’s cancelled. However, note that the token does not cancel the continuation once it has started. In other words, the CancellationToken
cancels the scheduling of the continuation, not the continuation itself. For this reason, I think the CancellationToken
parameter is misleading, and I never use it myself.
The next parameter is TaskContinuationOptions
, a collection of options for the continuation. Most options either have to do with conditions, scheduling, or parenting for the continuation. The None
option means to use the default behavior; however, in modern applications, these defaults are only appropriate for dynamic task parallelism, which is extremely rare.
The “condition options” will only schedule the continuation if the antecedent task completes in a matching state. OnlyOnRanToCompletion
, OnlyOnFaulted
, and OnlyOnCanceled
will only schedule the continuation if the antecedent task completes in a specific state. NotOnRanToCompletion
, NotOnFaulted
, and NotOnCanceled
will only schedule the continuation if the antecedent task completes in another state. All of these “condition options” are roughly equivalent to just checking the task’s Status
from within the continuation.
Update, 2015-01-30 (suggested by Bar Arnon): If the condition option is met by the antecedent task (e.g., the task completes in a RanToCompletion
state and the continuation specified the OnlyOnRanToCompletion
option), then the continuation is scheduled normally. However, if the condition option is not met (e.g., the task completes in a faulted state but the continuation specified the OnlyOnRanToCompletion
option), then the continuation is cancelled. The continuation delegate is never executed and the continuation task immediately moves to the canceled state.
Several “scheduling options” are passed along to the TaskScheduler
that is responsible for scheduling the continuation. PreferFairness
is a hint asking for FIFO behavior. LongRunning
is a hint that the continuation will execute for a long time. ExecuteSynchronously
is a request that the continuation be scheduled on the same thread that completes the antecedent task. Note that all of these are just hints; it is entirely appropriate for the TaskScheduler
to ignore them all; in particular, ExecuteSynchronously
does not guarantee that the continuation will execute synchronously.
As of this writing, in the .NET 4.6 preview, there is another option called RunContinuationsAsynchronously
, which seems to force continuations to execute asynchronously. Currently, there is no way to absolutely force continuations to be synchronous or asynchronous; forcing asynchronous continuations would certainly be useful in some situations.
Update, 2015-02-02: The .NET team has released a post describing the RunContinuationsAsynchronously
option. As the name implies, it does in fact run the continuation asynchronously.
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 continuation, but then will pretend that there is no current task scheduler while the continuation is executing; this can be used as a workaround for the unexpected default task scheduler (described below). LazyCancellation
(introduced in .NET 4.5) is an option that ensures the continuation is completed (canceled) only after its antecedent completes. Without LazyCancellation
, if the cancellation token passed to ContinueWith
is cancelled, it could cancel the continuation before the original task even completed.
The “parenting options” control how the continuation task is attached to the antecedent 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 continuation as a child task of the antecedent 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 continuations from using AttachedToParent
.
The final optional parameter is a TaskScheduler
that is used to schedule the continuation. Unfortunately, the default value for this parameter 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
. Task.Factory.StartNew
has a similar problem that I have described earlier. Since this default value is unexpected (and almost always undesirable), I recommend that you always pass a TaskScheduler
value to ContinueWith
. Many companies have run into this issue and enforce similar rules on their codebase.
In conclusion, I do not recommend using ContinueWith
at all, unless you are doing dynamic task parallelism (which is extremely rare). In modern code, you should almost always use await
instead of ContinueWith
. There are several benefits to await
.
One benefit is when working with other asynchronous code. As mentioned above, ContinueWith
can take only a limited number of delegates, none of which are async
-aware delegates. When dealing with asynchronous continuations, ContinueWith
will treat them as though they were synchronous. This can cause some manner of confusion when working with continuations of those continuations. Also, this means the scheduling options (e.g., LongRunning
) do not work as most developers expect; they are only applied to the initial synchronous portion of an asynchronous delegate. In contrast, await
works naturally with asynchronous continuations.
Another benefit is a better default task scheduler. Code using ContinueWith
should always explicitly specify a task scheduler to reduce confusion, but await
has much more reasonable default behavior. Modern code almost never uses task schedulers; it either uses SynchronizationContext.Current
or the thread pool scheduler.
The last benefit is that await
uses the most appropriate options by default. When you await
an incomplete task, under the hood await
does use ContinueWith
to schedule a continuation for you. However, it will automatically use the appropriate options (DenyChildAttach
and ExecuteSynchronously
), and doesn’t allow you to specify options that will not work correctly (e.g., AttachedToParent
or LongRunning
).
In short, prefer await
over ContinueWith
. ContinueWith
is useful when doing dynamic task parallelism, but in every other scenario, await
is preferred.
TaskFactory.ContinueWhenAny
ContinueWhenAny
is a way of executing a single continuation when any of a set of tasks completes. So, it’s a way to attach a single continuation to multiple tasks, and only have that continuation run when the first task completes.
The TaskFactory
type has a set of ContinueWhenAny
overloads that are somewhat similar to ContinueWith
:
Each of those groups of four overloads simplify down to a central method:
The overloads with a TAntecedentResult
generic parameter are for when the antecedent tasks all have the same result type. The overloads with a TResult
are for when the continuation returns a result of its own. The TaskFactory<TResult>
type only has overloads supporting continuations that return a result, so it has half the overloads that TaskFactory
does.
The default parameter values work similarly to ContinueWith
, except that they are specified by the TaskFactory
properties. So, the default CancellationToken
is TaskFactory.CancellationToken
, the default ContinuationOptions
value is TaskFactory.ContinuationOptions
, and the default TaskScheduler
is TaskFactory.Scheduler
, all of which may be set by passing the desired values into the TaskFactory
constructor.
Note that the default TaskScheduler
is still dangerous: anytime a TaskFactory
is constructed without an explicit TaskScheduler
, it will default to the value of TaskScheduler.Current
at the time ContinueWhenAny
is called. This causes the same surprising behavior as it does for ContinueWith
. Note that the static TaskFactory
instance Task.Factory
does have this problematic default task scheduler.
I recommend not using these overloads at all; instead, use await Task.WhenAny(...)
(see below) to asynchronously wait for one of a set of tasks to complete.
TaskFactory.ContinueWhenAll
ContinueWhenAll
is just like ContinueWhenAny
, except the logic is that the continuation is executed once after all the antecedent tasks have completed. There are sixteen overloads on TaskFactory
and eight on TaskFactory<TResult>
, exactly like ContinueWhenAny
. The same default parameter logic applies.
And the same default TaskScheduler
is still dangerous.
And I recommend not using these overloads at all, either; instead, use await Task.WhenAll(...)
(see below).
Task.WhenAll
Task.WhenAll
returns a task that completes when all of the antecedent tasks have completed. This is conceptually similar to TaskFactory.ContinueWhenAll
, but works much more nicely with await
:
The IEnumerable<>
overloads allow you to pass in a sequence of tasks, such as a LINQ expression. The sequence is immediately reified (i.e., copied to an array). For example, this allows you to pass the results of a Select
expression directly to WhenAll
. Personally, I usually prefer to explicitly reify the sequence by calling ToArray()
so that it’s obvious that’s what’s happening, but some folks like the ability to pass the sequence directly in.
The overloads with the TResult
generic parameter will retrieve all the results of those tasks, as an array. This is very convenient when you have multiple operations of a similar nature. For example, you can do two concurrent downloads as such:
This is also powerful when combined with LINQ. The code below will simultaneously download whatever urls are in the source sequence:
Task.WhenAny
Task.WhenAny
is similar to Task.WhenAll
, but instead of asynchronously waiting for all antecedent tasks to complete, it asynchronously waits for only one. It has a similar set of overloads:
The IEnumerable<>
and TResult
overloads serve the same purposes as they do for WhenAll
. However, the return type of WhenAny
is interesting. WhenAny
returns a task that is completed when any of the antecedent tasks complete. The result of that task is the antecedent task that completed.
This means that applying a single await
to a call to WhenAny
will give you the task that completed. This allows you to do things like do two operations at the same time and see which finishes first:
Usually, when you use WhenAny
, you actually don’t care about the tasks that don’t complete first. That is, only the results of the first task are important. In this scenario, you can make use of the rare but legal “double await”:
If you find the “double await” confusing, just break it out and specify the types. The code above is the same as:
I do recommend using await
to retrieve the results of the completed task. In this case, it might seem that await
is supurfluous, since we know that the task is already completed. However, await
is still better than Task.Result
because await
will not wrap exceptions inside an AggregateException
.