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:
CancellationToken.ThrowIfCancellationRequested()
: checks whether theCancellationToken
has ben cancelled, and throws aOperationCanceledException
if it has.CancellationToken.IsCancellationRequested
: Check whether theCancellationToken
has been cancelled. This can be useful you need to do some tidying up before aborting, but you’ll still need to callCancellationToken.ThrowIfCancellationRequested()
afterwards to throw the properOperationCanceledException
.CancellationToken.Register
: Register anAction
that’s called when theCancellationToken
is cancelled.
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:
- Check
CancellationToken
yourself at regular intervals by callingCancellationToken.ThrowIfCancellationRequested()
(remeber, simply passing theCancellationToken
toTask.Run
will not abort the Task). - 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). - Any Task which may be cancelled must have its
.Result
/.Wait()
/await
/ etc wrapped with a try/catch forAggregateException / OperationCanceledException
at the top level. - Whenever you use a
CancellationToken
inside a delegate passed toTask.Run
, also pass thatCancellationToken
toTask.Run