Cancellation: CancellationToken end to end

 
 
  • Gérald Barré

This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!

An async operation that cannot be stopped is a resource leak with good manners. CancellationToken is how .NET stops them, and it is cooperative: nothing is aborted, nothing is interrupted. The operation checks, and gives up.

That cooperation is the part people underuse, and the details around it are the part people get wrong.

#The two halves

CancellationTokenSource is the control. CancellationToken is the observation. You hand out tokens and keep the source to yourself:

C#
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await DoWorkAsync(cancellation.Token);

The split is deliberate. A method taking a CancellationToken can observe cancellation but cannot trigger it, so passing your token down a call chain is safe.

A token can be observed three ways, and each suits a different situation:

C#
// Polling, for a loop you control.
while (!cancellationToken.IsCancellationRequested) { ... }

// Throwing, at a checkpoint between units of work.
cancellationToken.ThrowIfCancellationRequested();

// Callback, for wrapping something that has its own cancellation mechanism.
using var registration = cancellationToken.Register(() => socket.Close());

#Canceled is not faulted

This is the distinction that matters most, and it is invisible until it bites:

C#
var task = Task.Delay(Timeout.Infinite, cancellation.Token);
await cancellation.CancelAsync();

// TaskStatus.Canceled, not Faulted
Assert.Equal(TaskStatus.Canceled, task.Status);
Assert.False(task.IsFaulted);

A canceled task lands in TaskStatus.Canceled and awaiting it throws OperationCanceledException. A failed task lands in Faulted. They are different states, and treating cancellation as an error is how you end up with error logs full of noise every time a user navigates away.

The reason it works is ThrowIfCancellationRequested throwing an OperationCanceledException that carries the token:

C#
var exception = Assert.Throws<OperationCanceledException>(cancellation.Token.ThrowIfCancellationRequested);
Assert.Equal(cancellation.Token, exception.CancellationToken);

That token is what lets the infrastructure tell "the caller cancelled me" from "something unrelated threw an OperationCanceledException". If you throw one yourself, always pass the token:

C#
throw new OperationCanceledException(cancellationToken);  // yes
throw new OperationCanceledException();                   // loses the distinction

#Cancelling versus giving up

These look similar and are not the same thing:

C#
// Cancel the operation. It is told to stop, and it stops.
await DoWorkAsync(cancellation.Token);

// Give up waiting. The operation keeps running, unobserved.
await DoWorkAsync().WaitAsync(TimeSpan.FromSeconds(5));

WaitAsync (added in .NET 6) puts a timeout on your wait, not on the work. The sample tests this explicitly: after the TimeoutException, the underlying task is still not complete.

Sometimes that is exactly right, when you are calling something that does not accept a token and you refuse to wait forever. But it leaves the operation running, holding its connection and its memory, with nobody watching its result. Prefer a real token when the callee accepts one.

#Linked sources

The common shape is "cancel if the caller cancels, or if my own timeout expires":

C#
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);

await DoWorkAsync(linked.Token);

The linked source cancels when any of its parents does. Two things about it are easy to get wrong.

Dispose it. A linked source registers callbacks on every parent. Not disposing it leaves those registrations attached to a token that may live for the whole request, or for the lifetime of the application.

You lose the reason. Once cancelled, the linked token cannot tell you which parent fired. If you need to distinguish "the user went away" from "we timed out", check the original token in the catch:

C#
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
    throw new TimeoutException();
}

#Registrations leak if you let them

Register returns a CancellationTokenRegistration that must be disposed. It is not a courtesy:

C#
for (var i = 0; i < 100; i++)
{
    // Without the using, all 100 stay attached to longLived for its entire lifetime.
    using var registration = longLived.Token.Register(() => callbackCount++);
}

Every undisposed registration keeps its delegate, its captured state, and everything they reference alive as long as the source lives. On a long-lived token, such as an application lifetime token, a registration created per request is an unbounded leak. This is one of the more common causes of "why is memory growing" in ASP.NET Core services.

#Where the callbacks run

Cancel() runs every registered callback synchronously, on the thread that called it, one after another, before returning. If a callback is slow, the canceller waits. If one throws, the rest still run and the exceptions come back as an AggregateException.

.NET 8 added CancelAsync for exactly this:

C#
await cancellation.CancelAsync();

which runs the callbacks without blocking the caller. The sample verifies both behaviours: Cancel runs the callback on the cancelling thread, CancelAsync does not.

Prefer CancelAsync when you do not control the callbacks, which in a framework or a library is always.

#What it costs

MethodMeanAllocated
CheckIsCancellationRequested0.0000 ns-
ThrowIfCancellationRequested0.0000 ns-
RegisterAndDispose7.8628 ns-
CreateSource2.2506 ns48 B
CreateLinkedSource11.0183 ns64 B
CreateSourceWithTimeout39.7501 ns144 B
CheckNoneToken0.0140 ns-
Benchmark project, measured with BenchmarkDotNet

Checking a token is below the resolution of the timer. There is no performance argument for not checking cancellation, and the common excuse for omitting the parameter does not survive contact with the numbers.

Registration costs about 8 ns and allocates nothing, which is why every cancellable await in the BCL can afford one. Creating a source is 48 bytes. Adding a timeout is the expensive one at 144 bytes and 40 ns, because it arms a timer: worth knowing if you create one per operation in a hot loop.

CancellationToken.None costs nothing at all. Passing it explicitly, rather than omitting the parameter, is free and documents that you meant it.

#Threading it through

The mechanical advice, in order of how often it is skipped:

  1. Take a CancellationToken on every public async method. Last parameter, defaulted to default.
  2. Pass it to everything you call. A token that stops at the first layer is decoration.
  3. Do not swallow it. catch (OperationCanceledException) { } around your whole operation turns cancellation into silent success.
  4. Use [EnumeratorCancellation] on async iterators, or WithCancellation silently does nothing.
  5. Prefer TimeProvider over CancellationTokenSource(TimeSpan) in code you want to test, so timeouts can be driven by a fake clock instead of real seconds.

#What to take away

  • Cancellation is cooperative. Nothing is aborted; the operation checks and gives up.
  • Canceled is a distinct task state from Faulted. Do not log cancellation as an error.
  • Always pass the token when throwing OperationCanceledException, and catch that base type rather than TaskCanceledException.
  • WaitAsync gives up waiting; it does not cancel the operation. The work keeps running.
  • Dispose linked sources and registrations, or they leak for as long as the parent token lives.
  • Cancel runs callbacks on the calling thread. CancelAsync does not, and is the safer default.
  • Checking a token is free. There is no performance reason to skip it.

The next post is about combining operations rather than stopping them: WhenAll, WhenAny, WhenEach and the exception semantics that come with each.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?