Cancelling tasks in C#

Here’s something I see from time to time: people new to Tasks in C# don’t know how to cancel them. Let’s look at some common misconceptions, and what you should actually do.

Take a look at this…

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

Task.Run(() =>
{
	Debug.WriteLine("Task Starting...");
	Thread.Sleep(1000); // Some long-running operation
	Debug.WriteLine("Task Finished");
}, cancellationToken);

// Oh no! Abort the Task
cancellationTokenSource.Cancel();

What will the code above output? We start a long-running Task, and pass a CancellationToken to Task.Run. We then cancel the CancellationToken. Will the Task abort?

Well, here’s the output:

Task Starting....
Task Finished

Our CancellationToken did nothing! What’s going on…?

How to cancel a Task

Well, the first thing to note is that the CancellationToken parameter of Task.Run (and of Task.Factory.StartNew, and Task.Start) does not cancel the Task. We’ll see what it does in a minute, but it doesn’t do what you expect.

So how do you cancel a Task? You manually check the CancellationToken, and you ask it to throw a OperationCanceledException if it has been cancelled. Let me repeat that: you have to keep checking if the CancellationToken has been cancelled.

Why, you ask? Doesn’t Thread.Abort simply abort the thread (by throwing a ThreadAbortException after whatever instruction that Thread happens to be executing)? Can’t Task do the same?

Yes, but it turns out that’s a terrible idea. Since the ThreadAbortException can be thrown anywhere, this can lead to memory leaks, weird behaviour, inconsistent state, or a lot of things you’re never going to anticipate. Task didn’t repeat the same mistake.

So instead, you have to keep checking whether the CancellationToken has been cancelled. You can do this in a number of ways:

Let’s see that in action…

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

Task.Run(() =>
{
	Debug.WriteLine("Task Starting...");
	for (int i = 0; i < 10; i++)
	{
		cancellationToken.ThrowIfCancellationRequested();
		Thread.Sleep(100); // Some long-running operation
	}
	Debug.WriteLine("Task Finished");
});

cancellationTokenSource.Cancel();

To summarise: Check CancellationToken yourself at regular intervals.

Pass the CancellationToken down

I cheated! Did you see that? There’s no way to get Thread.Sleep to stop sleeping when a CancellationToken is cancelled, so I cheated and broke it down into a few shorter sleeps.

While you obviously won’t have long-running tasks which only sleep, this does rather illustrate a point: in order to be able to call a long-running method but have it abort, you need to pass a CancellationToken down to it. Sometimes this means you’ll have to hunt around for an appropriate method to call (since CancellationToken was only introduced in .NET 4.0, and there will be some BCL classes - any many third-party libraries - which haven’t been updated to task advantage of CancellationTokens).

Here, I’ll use Task.Delay instead of Thread.Sleep, since it accepts a CancellationToken.

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

Task.Run(() =>
{
	Debug.WriteLine("Task Starting...");
	Task.Delay(1000, cancellationToken).Wait();
	Debug.WriteLine("Task Finished");
});

cancellationTokenSource.Cancel();

Rule time! When calling a method which will take a long time, pass in the CancellationToken if possible (this includes methods which you write).

Exceptions exeptions exceptions…

There’s something I haven’t yet discussed. See, that CancellationToken.ThrowIfCancellationRequested() line threw an exception, which hasn’t been caught. That exception is disappearing into the ether at the moment, but if we have a Task which we call .Wait() or .Result on (or we await it), that exception is going to be propagated.

Let’s modify our sample to return a value and see what happens…

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

Task<string> task = Task.Run(() =>
{
	Task.Delay(1000, cancellationToken).Wait();
	return "Some Result";
});

cancellationTokenSource.Cancel();

// Try and get the value (maybe another part of the code caused the cancellation?)
var result = task.Result;

BOOM!

What happened? The task.Result threw an AggregateException.

Why did it do that? Let’s check the docs for Task.Result… Sure enough, it throws an AggregateException when cancelled (with an InnerException which is an OperationCanceledException).

(Why does it throw an AggregateException and not the OperationCanceledException? The main reason is that Task is catching and re-throwing the OperationCanceledException. If it simply re-threw the same exception, the exception’s stack trace would be lost, so it’s wrapped in another exception which is thrown instead. AggregateException just happens to be the exception that was chosen for this role.)

This leads to another rule: Any Task which may be cancelled must have its .Result / .Wait() / await / etc wrapped with a try/catch for AggregateException / OperationCanceledException at the top level.

Why “AggregateException / OperationCanceledException”? Well. Task.Result, Task.Wait(), Task.WaitAll() all throw AggregateException (which has an InnerException which is an OperationCanceledException). await however throws an OperationCanceledException.

Just to keep you on your toes…

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

Task<string> task = Task.Run(() =>
{
	Task.Delay(1000, cancellationToken).Wait();
	return "Some Result";
});

cancellationTokenSource.Cancel();

// Try and get the value (maybe another part of the code caused the cancellation?)
try
{
	var result = task.Result;
}
catch (AggregateException e) when (e.InnerException is OperationCanceledException)
{ }

(That’s the C# 6 Exception filter syntax if you’re wondering).

Cancelled vs Faulted

But wait! There’s more! Task has the properties IsCompleted, IsCanceled and IsFaulted. We would expect IsCanceled to be set here, since we cancelled the Task. Shall we check?

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

Task<string> task = Task.Run(() =>
{
	Task.Delay(1000, cancellationToken).Wait();
	return "Some Result";
});

cancellationTokenSource.Cancel();

if (task.IsCanceled)
	Debug.WriteLine("Task is cancelled!");
else if (task.IsFaulted)
	Debug.WriteLine("Task is faulted!");

And the output is…

Task is faulted!

Were you expecting that?

We would have expected the Task to be cancelled, since we, uh, cancelled it. However, Task.Run doesn’t know that: it saw its delegate abort with an exception, and so went to the faulted state.

We have to set up Task.Run so that when it sees OperationCanceledExceptions which are thrown by a particular CancellationToken, it should go to the canceled state, rather than faulted. We do this by passing the CancellationToken to Task.Run. For example:

var cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;

Task<string> task = Task.Run(() =>
{
	Task.Delay(1000, cancellationToken).Wait();
	return "Some Result";
}, cancellationToken); // <- Pass the CancellationToken

cancellationTokenSource.Cancel();

if (task.IsCanceled)
	Debug.WriteLine("Task is cancelled!");
else if (task.IsFaulted)
	Debug.WriteLine("Task is faulted!");

Output:

Task is cancelled

This makes for another rule: Whenever you use a CancellationToken inside a delegate passed to Task.Run, also pass that CancellationToken to Task.Run.

Rounding Up

Let’s re-visit what we learnt:

  1. Check CancellationToken yourself at regular intervals by calling CancellationToken.ThrowIfCancellationRequested() (remeber, simply passing the CancellationToken to Task.Run will not abort the Task).
  2. When calling a method which will take a long time, pass in the CancellationToken if possible (you may need to find a suitable up-to-date library to use).
  3. Any Task which may be cancelled must have its .Result / .Wait() / await / etc wrapped with a try/catch for AggregateException / OperationCanceledException at the top level.
  4. Whenever you use a CancellationToken inside a delegate passed to Task.Run, also pass that CancellationToken to Task.Run