Mechanisms for Creating Tasks

Stephen Toub - MSFT

The core entity in the Task Parallel Library around which everything else revolves is System.Threading.Tasks.Task.  The most common way of creating a Task will be through the StartNew method on the TaskFactory class, a default instance of which is exposed through a static property on Task, e.g.

var t = Task.Factory.StartNew(() =>
{
    … // body of the task goes here
});

There are, however, other ways of creating tasks.  For example, while using StartNew is the preferred mechanism to create a Task and schedule/start it, we do support separating those two operations into two discrete actions, e.g.

var t = new Task(() =>
{
    … // body of the task goes here
});
… // the task has been created but not scheduled
t.Start(); // now schedule it

Moreover, StartNew isn’t alone on TaskFactory; other methods like ContinueWhenAll, ContinueWhenAny, and FromAsync may all be used to create Task instances. Task also exposes a ContinueWith mechanism that can be used to create a Task that will be scheduled when the antecedent task (the Task on which ContinueWith is being called) completes.

Finally, the TaskCompletionSource<TResult> type can be used to create a Task<TResult> completely controlled by the completion source instance, through its SetResult, SetException, and SetCanceled methods (and their TrySet* variants).

Each of these different ways of creating a Task has different behaviors associated with it.  The differences between them may not be obvious at first, but with a little thought, it should be clear why things behave the way they do.  For example, calling Start on a Task created by StartNew is invalid (i.e. results in an exception)… you can’t start an already started Task.  In contrast, a Task created by a Task’s constructor won’t have been scheduled, so it’s perfectly valid to call Start on it.  Calling Start on a Task returned by a TaskCompletionSource<TResult> makes little sense, as there’s nothing to “start”, so that’s invalid.  It’s invalid to call Start on a continuation task (e.g. one created by ContinueWith, ContinueWhenAny, or ContinueWhenAll) because the work should only be scheduled when the antecedent(s) has completed.  And it’s invalid to call Start on a task created by FromAsync, because the work being done has already been initiated through a call to the beginMethod passed to FromAsync.

These kinds of behavioral differences can be quite useful when building up abstractions on top of tasks.  For example, let’s say I want to implement a factory method for creating “delayed” tasks, ones that won’t actually be scheduled until some user-supplied timeout has occurred.  One way to write this would be as follows:

public static Task StartNewDelayed(int millisecondsDelay, Action action)
{
    // Validate arguments
    if (millisecondsDelay < 0)
        throw new ArgumentOutOfRangeException(“millisecondsDelay”);
    if (action == null) throw new ArgumentNullException(“action”);

    // Create the task
    var t = new Task(action);
    // Start a timer that will trigger it
    var timer = new Timer(
        _ => t.Start(), null, millisecondsDelay, Timeout.Infinite); 
    t.ContinueWith(_ => timer.Dispose());
    return t;
}

This implementation creates a new Task to run the provided action, but doesn’t immediately start it.  Instead, it creates a Timer with the user-supplied delay, and when the timer expires, the Timer’s callback starts the task.  Once the timer has been started, the Task is returned to the user.

One problem with this implementation, which you might have guess based on earlier paragraphs, is that the Task returned to the user was created using the Task’s constructor.  This means it can be explicitly Start’d.  And that means the Task returned from StartNewDelayed could be started by the consumer prior to the Timer firing.  That’s bad for two reasons: one, it breaks expectations about the behavior of the Task and the associated delay, and two, a Task may only be started once.  If the Task is explicitly started and then the timer’s callback tries to Start the Task, kaboom: Start will throw an exception (since the Task was already started), the exception will go unhandled, and the app will come crumbling down.

Given what we now know about behaviors associated with creating tasks, we can use a different mechanism for creating a task that doesn’t allow the Task to be explicitly started.

public static Task StartNewDelayed(int millisecondsDelay, Action action)
{
    // Validate arguments
    if (millisecondsDelay < 0)
        throw new ArgumentOutOfRangeException(“millisecondsDelay”);
    if (action == null) throw new ArgumentNullException(“action”);

    // Create a trigger used to start the task
    var tcs = new TaskCompletionSource<object>();

    // Start a timer that will trigger it
    var timer = new Timer(
        _ => tcs.SetResult(null), null, millisecondsDelay, Timeout.Infinite);

    // Create and return a task that will be scheduled when the trigger fires.
    return tcs.Task.ContinueWith(_ =>
    {
        timer.Dispose();
        action();
    });
}

In this new implementation, I’ve taken advantage of the fact that a continuation task can’t be explicitly started and can be used to run arbitrary user code.  The timer is used to resolve a TaskCompletionSource<TResult>, and a continuation off of that completion source is used to run the action.  It’s that continuation that’s returned.

0 comments

Discussion is closed.

Feedback usabilla icon