Cancelling parallel tasks

UPDATE (7-June-2011): The post as it originally appeared had a bug in the code, the catch block in the task caught the wrong exception type. See the Gotcha section at the end for an explanation on why there are two types of exception for this.

I think, to date, I’ve mentioned most of the task lifecycle, but I’ve not talked about cancelling tasks yet. So here goes.

You can cancel tasks for what ever reason by passing in a cancellation token to the task. The task must be cooperative insomuch as it must watch the cancellation token to detect if a cancellation has been signalled then it can clean up and exit.

The basic program

So, the little example program to demonstrate this is this:

class Program
{
    static void Main(string[] args)
    {
        const int numTasks = 9;
        Task[] tasks = new Task[numTasks];
        for (int i = 0; i < 10; i++)
            tasks[i] = Task.Factory.StartNew(PerformTask);

        Task.WaitAll(tasks);

        foreach(Task t in tasks)
            Console.WriteLine("Tasks {0} state: {1}", t.Id, t.Status);

        Console.WriteLine("Program End");
        Console.ReadLine();
    }

    static void PerformTask()
    {
        Console.WriteLine("Task {0}: Starting", Task.CurrentId);
        for (int i = 0; i < 3; i++)
        {
            Console.WriteLine("Task {0}: {1}/3 In progress", Task.CurrentId, i+1);
            Thread.Sleep(500); // Simulate doing some work
        }
        Console.WriteLine("Task {0}: Finished", Task.CurrentId);
    }
}

So far this doesn’t do much. It starts 9 tasks and each runs to completion. Each task’s end state is RanToCompletion.

Setting up the CancellationToken

Now, if we introduce the cancellation token to the task we can cancel the task at some point during its execution. The Main method then gets changed to this:

static void Main(string[] args)
{
    const int numTasks = 9;

    CancellationTokenSource tokenSource = new CancellationTokenSource();
    CancellationToken token = tokenSource.Token;

    Task[] tasks = new Task[numTasks];
    for (int i = 0; i < numTasks; i++)
        tasks[i] = Task.Factory.StartNew(() => PerformTask(token), token);

    Thread.Sleep(1500);
    Console.WriteLine("Cancelling tasks");
    tokenSource.Cancel();
    Console.WriteLine("Cancellation Signalled");

    Task.WaitAll(tasks);

    foreach(Task t in tasks)
        Console.WriteLine("Tasks {0} state: {1}", t.Id, t.Status);


    Console.WriteLine("Program End");
    Console.ReadLine();
}

The PerformTask method now takes a CancellationToken (but doesn’t yet do anything with it)

If this code is run, the Task.WaitAll method call will throw an AggregateException with a number of TaskCanceledException objects.

Handling cancelled tasks

You therefore have to surround your WaitAll method with a try/catch block and look out for TaskCanceledException objects and handle them as you need (see also: handling AggregateException exceptions). In my example I’m just going to output the fact to the console. The try/catch block looks like this:

try
{
    Task.WaitAll(tasks);
}
catch (AggregateException aex)
{
    aex.Handle(ex =>
    {
        TaskCanceledException tcex = ex as TaskCanceledException;
        if (tcex != null)
        {
            Console.WriteLine("Handling cancellation of task {0}", tcex.Task.Id);
            return true;
        }
        return false;
    });
}

The tasks that were in progress at the time the cancel was signalled complete as normal. Cancelling the tasks will not stop any currently running task.

Responding to a cancellation request: IsCancellationRequested

If the tasks are sufficiently short running allowing them to complete may be perfectly acceptable.

However, if a task is long running or it is safe to cancel them then you can allow your task to cooperate and respond to the token being signalled to cancel.

The CancellationToken object has a property you can check called IsCancellationRequested. For example:

static void PerformTask(CancellationToken token)
{
    Console.WriteLine("Task {0}: Starting", Task.CurrentId);
    for (int i = 0; i < 4; i++)
    {
        if (token.IsCancellationRequested)
        {
            Console.WriteLine("Task {0}: Cancelling", Task.CurrentId);
            return;
        }
        Console.WriteLine("Task {0}: {1}/3 In progress", Task.CurrentId, i+1);
        Thread.Sleep(500); // Simulate doing some work
    }
    Console.WriteLine("Task {0}: Finished", Task.CurrentId);
}

If you simply exit from your task, like the above example, then the Status of the task will be RanToCompletion as if the task completed normally. If you do not need to know whether a task actually completed or was cancelled then this may be completely acceptable.

Responding to a cancellation request: ThrowIfCancellationRequested

If you need to perform clean up or the calling code needs to know that a task has been cancelled then using the CancellationToken’s ThrowIfCancellationRequested() method may be a better choice.

If you do need to perform clean up inside your task, ensure that the OperationCanceledExcption is thrown again so that the calling code knows that the task was cancelled.

static void PerformTask(CancellationToken token)
{
    try
    {
        Console.WriteLine("Task {0}: Starting", Task.CurrentId);
        for (int i = 0; i < 4; i++)
        {
            token.ThrowIfCancellationRequested();
            Console.WriteLine("Task {0}: {1}/3 In progress", Task.CurrentId, i + 1);
            Thread.Sleep(500); // Simulate doing some work
        }
        Console.WriteLine("Task {0}: Finished", Task.CurrentId);
    }
    catch (OperationCanceledException)
    {
        // Any clean up code goes here.
        Console.WriteLine("Task {0}: Cancelling", Task.CurrentId);
        throw; // To ensure that the calling code knows the task was cancelled.
    }
    catch(Exception)
    {
        // Clean up other stuff
        throw; // If the calling code also needs to know.
    }
}

Remember that if you allow other exceptions to escape your task then the task’s status will be Faulted.

Gotcha!

This section was added on 7-June-2011.

One thing to watch out for is that the exception you get inside the task is different from the exception you get inside the AggregateException outside the task. Normally, you’d expect that the exception is passed through and becomes one of the InnerExceptions in the aggregate exceptions.

It you want to keep the code consistent and only deal with one exception type for cancelled tasks you can simple deal with the OperationCanceledException throughout (both inside and outside the tasks) as that is the base class. Outside the task the exception object is actually a TaskCanceledException.

The advantage of referencing the more specific TaskCanceledException outside the task is that the exception object also contains a reference to the Task that was cancelled. Inside the task the exception that ThrowIfCancellationRequested throws is an OperationCanceledException (which doesn’t contain the Task object, however you are inside the task at this point)

The other point to note is that outside the task, the TaskCanceledException object in the AggregateException object doesn’t contain much of the information you’d expect to find in an Exception object (such as a Stack Trace).

Leave a Comment

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s