C# / Intermediate: Composition and Asynchronous Work / Lesson 10
Cancellation and Operation Lifetimes
Propagate cooperative cancellation and distinguish requesting a stop from observing that every owned operation has ended.
On this page
Lesson goals
- Assign CancellationTokenSource ownership.
- Pass tokens through actual blocking operations.
- Classify cancellation without hiding unrelated failures.
Cancellation is a request, not forced termination
CancellationTokenSource owns the cancellation signal; copies of its CancellationToken let operations observe it. Calling Cancel requests cooperation and can run registered callbacks, but does not automatically kill threads or wait for every task to stop. The owning operation must still await its work before releasing resources that work may use.
Pass a token as an explicit parameter through relevant layers. A method accepting a token but never forwarding or checking it is not meaningfully cancellable. CPU loops can check at sensible bounded intervals, while I/O should use supported token-aware overloads or another deliberate interruption mechanism.
Observe a controlled cancelled operation
The first program cancels before starting the delay, so the token-aware operation ends through cancellation without waiting a second. The catch filter checks that the exception belongs to the intended token. It prints a stable application message instead of relying on localized exception wording or a measured elapsed duration.
OperationCanceledException is the general cancellation exception; TaskCanceledException derives from it. Cancellation is distinct from a successful result and from unrelated I/O failure. A broad catch that converts every exception into cancelled can hide a real defect. Preserve the operation’s documented failure categories.
using System;
using System.Threading;
using System.Threading.Tasks;
using var source = new CancellationTokenSource();
source.Cancel();
try
{
await Task.Delay(1000, source.Token);
}
catch (OperationCanceledException exception) when (exception.CancellationToken == source.Token)
{
Console.WriteLine("cancelled");
}
cancelled
The delay observes cancellation immediately; no sleep is needed to predict the output.
Combine parent and local cancellation deliberately
CreateLinkedTokenSource can combine a caller’s lifetime with a local cancellation budget. Disposing a linked source releases registrations; it is not the same operation as cancelling and joining work. Keep the source alive while its token is being used under the intended protocol, and dispose only after the owner has observed completion.
The second program links a child to a parent, cancels the parent, and inspects the child signal synchronously. It starts no background task. A timeout-based variant would add a timer and still require disposal and completion ownership. Do not create a fresh unlinked token deep in a request and silently discard the caller’s cancellation.
using System;
using System.Threading;
using var parent = new CancellationTokenSource();
using var child = CancellationTokenSource.CreateLinkedTokenSource(parent.Token);
parent.Cancel();
Console.WriteLine($"parent={parent.IsCancellationRequested} child={child.IsCancellationRequested}");
parent=True child=True
Cancelling the parent propagates to the linked child before the states are printed.
Define what cancellation means for effects
Cancellation can race with successful completion. Once a durable effect is committed, returning cancelled must not falsely imply nothing happened. Choose a commit boundary and document whether partial results are returned, discarded, or recoverable. A token is not a transaction manager and cannot roll back arbitrary external work.
Retries after cancellation or timeout need idempotency reasoning. A response may be lost after an action succeeds. Bound retries by the caller’s lifetime and application budget, and avoid reusing an already-cancelled source for unrelated work. Cleanup that intentionally outlives a request should have its own bounded lifetime rather than an accidental uncancellable task.
Test cancellation at every blocking stage
Test an already-cancelled token, cancellation during a wait, success racing with cancellation, and downstream failure. Use explicit task-completion signals instead of sleeping and hoping. Require every owned task to finish and verify resource state, not just that Cancel returned. Disposing a semaphore, stream, or token source too early can introduce a separate lifetime error.
Avoid callbacks that perform long blocking work or acquire locks in an order that can deadlock the cancelling thread. Review callback exceptions under the source’s documented behavior. Keep cancellation reporting at the boundary that owns the operation so normal cooperative shutdown is not logged repeatedly as an unexpected fatal error.
Common errors and fixes
Cancel returns while work still uses disposed state.
- Why it happens
- The caller treated a stop request as completion.
- How to fix it
- Await all owned tasks before disposing their dependencies.
- Prevent it next time
- Document stop and completion separately.
A timeout in an inner layer ignores caller cancellation.
- Why it happens
- The local source is not linked to the parent token.
- How to fix it
- Link lifetimes or forward the parent appropriately.
- Prevent it next time
- Test cancellation from the outermost call.
Every exception becomes a cancelled result.
- Why it happens
- A broad catch hides unrelated failures.
- How to fix it
- Match cancellation under the documented token policy.
- Prevent it next time
- Test an actual operation failure beside cancellation.
Check your understanding
1. A method accepts a CancellationToken but calls a tokenless long I/O operation. Why is checking the token once at entry insufficient?
Show worked solution
Cancellation after entry cannot interrupt that already-running call. Use a supported token-aware overload, an explicit deadline or owned-resource interruption contract, or a different bounded API. Starting a wrapper task only moves the uncancellable work elsewhere. The owner must still observe completion before releasing dependencies.
2. What should an API document when cancellation arrives after an update has committed but before the response is delivered?
Show worked solution
State whether the operation reports committed success, cancellation with a recoverable operation identifier, or another explicit partial-result status. Do not promise automatic rollback. Callers need an idempotency or status-query policy before retrying. Tests should force cancellation around the commit boundary and verify both reported status and actual stored effect in a synthetic implementation.
Key takeaways
- Cancellation is cooperative and does not join tasks.
- Link caller and local lifetimes intentionally and dispose sources after use.
- Commit boundaries and retry policy remain application responsibilities.