Cancellation in .NET in Depth: Tokens, Callbacks and Linking
A CancellationToken isn't a flag you poll — it's a subscription list, and Cancel() is what fires every callback on it.
Lesson 153 taught you the cooperative cancellation pattern: a CancellationTokenSource creates a CancellationToken, you pass the token down through your async call chain, and the operation checks it and stops cleanly. That's the pattern you'll use in practically every real method. What that lesson didn't open up is the machinery underneath — how a token actually notices it's been canceled without polling in a loop somewhere, what happens when you combine a caller's token with your own internal timeout, and a real disposal gotcha that catches experienced developers.
In this lesson, you'll learn how CancellationTokenSource actually works internally — the callback registry that token.Register(...) adds to and Cancel() walks — timer-based cancellation with CancelAfter(...), linking multiple cancellation sources together with CreateLinkedTokenSource(...), a disposal-ordering gotcha worth knowing before it bites you in production, and solid patterns for checking cancellation at the right points inside your own long-running loops.
What Is It?
The Simple Explanation, Revisited
You already know the shape: a CancellationTokenSource is the trigger, a CancellationToken is the listener, and calling Cancel() flips every token the source produced into a canceled state. What this lesson adds is how that flip actually reaches code that's waiting on it — and the answer is not "something polls a flag in a loop somewhere in the background." It's closer to a subscription list.
The Technical Definition — the Callback Registry
Internally, a CancellationTokenSource maintains a registry of callbacks — delegates that code has asked to be invoked at the exact moment Cancel() is called. token.Register(Action callback) adds an entry to that registry and returns a CancellationTokenRegistration — itself a handle you can use to remove that specific registration later (via Dispose() on it), if the code that registered it no longer cares. When Cancel() runs, the source walks its entire registry and invokes every callback still registered, synchronously, one after another, on the thread that called Cancel() — token.IsCancellationRequested flipping to true and every awaiting Task from a cancellation-aware API throwing OperationCanceledException are themselves implemented as consequences of this same registry mechanism, not a separate, parallel system.
IsCancellationRequested is a value you check yourself, in your own loop. Register(...) is how you get notified, without polling, the instant cancellation happens — which is exactly what lets a Task-returning API built on top of a token transition to a canceled state immediately, rather than only noticing on its next manual check.
Why Does It Exist?
The Problem — Lesson 153's Pattern Doesn't Cover Every Real Scenario
The basic "pass the token, check it periodically" pattern handles the common case well. But real systems need more: what if you want an operation to cancel itself automatically after a timeout, with no external trigger at all? What if a method needs to combine two reasons to cancel — a caller's explicit token and its own internal timeout, so that either one stops the work? What if you need code to run reactively the instant cancellation happens — closing a socket, releasing a lock — rather than only at the next point your own loop happens to check the flag?
The Solution — a Richer Toolkit Built on the Same Token/Source Foundation
CancellationTokenSource provides exactly these tools, all built on the same registry idea: CancelAfter(...) for automatic, timer-based cancellation with no external caller needed; CreateLinkedTokenSource(...) for combining multiple independent cancellation signals into one token that fires when any of them does; and Register(...) for reactive, callback-driven cleanup the instant cancellation happens, rather than only at your next manual check.
Big Picture
How It Works
Part 1 — CancelAfter: Timer-Based Automatic Cancellation
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(10)); // auto-cancel if not finished within 10 seconds
await LongRunningOperationAsync(cts.Token);
CancelAfter(...) starts an internal timer; if that timer elapses before anything else cancels the source first, it calls Cancel() automatically — no separate Task.Delay race or manual timer code needed. It can also be called more than once to reset the countdown (each call restarts the timer), and calling Cancel() manually before the timer fires cancels the source immediately, exactly as if CancelAfter had never been called.
Part 2 — CreateLinkedTokenSource: Combining Multiple Cancellation Reasons
A very common real scenario: a method receives a caller's CancellationToken (they might cancel for their own reasons), but the method also wants its own internal timeout — either signal should stop the operation.
public async Task<Report> GenerateReportAsync(CancellationToken callerToken)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // internal timeout
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(callerToken, timeoutCts.Token);
// linkedCts.Token is canceled if EITHER callerToken is canceled OR the 30-second timeout elapses
return await BuildReportAsync(linkedCts.Token);
}
CreateLinkedTokenSource returns a brand-new CancellationTokenSource whose token becomes canceled the moment any of the source tokens passed into it become canceled — internally, it does this by registering its own callback (via the exact Register(...) mechanism from Part 1) on each of the tokens you hand it, so that when any one of them fires, that callback cancels the linked source in turn. From the caller of BuildReportAsync's point of view, there's just one token to check — the fact that it represents "either of two independent reasons" is entirely hidden behind that single linked token.
Simple Example
Registering a callback directly, to see the notification-driven path in action rather than polling:
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
using CancellationTokenRegistration registration = token.Register(() =>
{
Console.WriteLine("Cancellation requested — releasing the connection now.");
connection.Close();
});
Console.WriteLine("Waiting...");
cts.Cancel(); // synchronously invokes every registered callback, including the one above, right now
Console.WriteLine("Cancel() has returned — the callback above already ran.");
Code → Meaning → Result: The lambda passed to Register is added to the source's registry. Nothing runs it — nothing polls anything — until Cancel() is actually called. At that exact moment, the source walks its registry and invokes the callback synchronously, before Cancel() itself returns. This is why the order of the two console lines is guaranteed: "Cancellation requested..." always prints before "Cancel() has returned...", because Cancel() doesn't return until every registered callback has finished running.
Real-World Example — Cooperative Cancellation Inside Your Own Long-Running Loop
A background service processing a large batch of records is exactly where cooperative cancellation design decisions matter — checking at the wrong points can make cancellation feel unresponsive, or worse, leave data half-processed at an inconsistent point:
public async Task ProcessBatchAsync(IReadOnlyList<Order> orders, CancellationToken token)
{
foreach (var order in orders)
{
token.ThrowIfCancellationRequested(); // checked at the TOP of every iteration — a sensible, regular point
await ValidateAsync(order, token); // token passed down into every cancellation-aware call
await ChargePaymentAsync(order, token); // if this throws OperationCanceledException, the record is left
// in a known, safe state — not partially charged and partially shipped
await ShipOrderAsync(order, token);
// NOT checked again mid-record — each record is treated as an atomic unit here,
// so cancellation takes effect between records, not partway through one
}
}
Notice the check happens once per iteration, at a point where stopping leaves the system in a clean, well-defined state — not, say, in the middle of charging a payment. This is the real design skill cooperative cancellation requires: checking often enough that cancellation feels responsive, but only at points where "stop here" is actually safe to do.
Analogy
Polling IsCancellationRequested in a loop is like periodically glancing up from your desk to check whether the building's fire light is on. Register(...) is installing an actual fire alarm — something that goes off and reacts the instant the alarm is triggered, whether or not anyone happened to be looking up at that exact moment. CreateLinkedTokenSource is wiring two separate alarm systems (a manual pull station and an automatic smoke detector) into one shared bell — either one triggers the same evacuation, and everyone downstream only needs to listen for that one bell.
Under the Hood
- As shown in the Simple Example, every registered callback runs to completion before
Cancel()returns. This is deliberate and predictable — but it also means a callback could, in principle, still be executing at the exact moment something else on a different thread decides toDispose()theCancellationTokenSource.
CancellationTokenSource.Dispose()releases the underlying resources the source uses (including, notably, any timer set up byCancelAfter). If a callback registered viatoken.Register(...)is concurrently executing on another thread whileDispose()is called, there's a genuine race between "the callback is still using resources the source owns" and "the source is being torn down." Getting this wrong is a documented source of subtle bugs — a disposed source's resources being accessed from a still-running callback, or a callback registration racing against disposal.
- The safest, most common pattern — exactly what the Simple Example and Real-World Example both use — is a
usingdeclaration on theCancellationTokenSourceat the scope that owns it, so disposal happens naturally after theawaited operation (and any cancellation it triggers) has already fully unwound, rather than being raced against from a separate thread. Avoid manually disposing a source from one thread while another thread might still be in the middle of callingCancel()or running a registered callback on it.
token.Register(...)returns aCancellationTokenRegistration. If the code that registered a callback finishes its work before the token is ever canceled, disposing that registration (which theusingin the Simple Example does automatically) removes the callback from the registry — preventing it from firing later for an operation that's already long since finished, and letting the registry release the reference to that no-longer-needed callback.
Common Confusion
1. "Cancellation is like an exception thrown from the outside" — no, it's entirely cooperative
Lesson 153 already made this point, and it's worth repeating with the registry mechanism now visible: nothing about Cancel() forcibly interrupts running code the way a thread-abort would. It flips a flag and runs registered callbacks — but a piece of code that never checks IsCancellationRequested, never calls ThrowIfCancellationRequested(), and never uses a cancellation-aware API keeps right on running, completely unaffected, no matter how many times Cancel() is called.
2. "A linked token source's token is the same object as the tokens it was created from" — no, it's a new, independent token
CreateLinkedTokenSource(a, b) returns a brand-new CancellationTokenSource with its own, distinct token — one that happens to be wired, via registered callbacks, to become canceled when a or b does. Canceling the linked source directly does not cancel a or b themselves — the relationship only flows one direction, from the originals into the linked source, never back out.
Common Mistakes
Mistake 1 — Only checking cancellation at the very start of a long-running method
Wrong:
public async Task ProcessAllAsync(List<Item> items, CancellationToken token)
{
token.ThrowIfCancellationRequested(); // checked once, at the top — then never again
foreach (var item in items)
await ProcessItemAsync(item); // could run for minutes across thousands of items with no way to stop
}
Fix: check at sensible, regular points throughout the loop — as the Real-World Example demonstrated, once per iteration is usually the right granularity for per-item work.
Mistake 2 — Checking cancellation so often it hurts readability or performance for no real benefit
Calling ThrowIfCancellationRequested() after every single line inside a tight, fast inner loop that completes in microseconds anyway. Match the check frequency to how long each unit of work actually takes — checking once per outer-loop iteration over meaningful units of work (a record, a file, a batch) is almost always the right granularity; checking inside a sub-millisecond inner loop adds noise without meaningfully improving responsiveness.
Mistake 3 — Disposing a CancellationTokenSource from a different thread than the one that might still be canceling it
Manually calling .Dispose() on a shared CancellationTokenSource from one thread while another thread might concurrently call .Cancel() on it or still be running a registered callback — exactly the race Under the Hood point 2 described. Prefer a using declaration scoped to the code that owns the source, so disposal happens after the operation (and any cancellation triggered during it) has already fully completed — the pattern used throughout this lesson's examples.
When Should I Use It?
Reach for these tools when
CancelAfter— any operation that should have a hard timeout with no external caller needed to enforce it.CreateLinkedTokenSource— a method that needs to honor a caller's token AND enforce its own internal timeout or other stop condition.Register(...)— reactive cleanup that must happen the instant cancellation occurs, not merely at your code's next manual check.- Writing any long-running loop of your own — decide deliberately where checking cancellation is both responsive and safe.
Keep it proportionate
- Short, fast operations rarely need explicit cancellation checks beyond passing the token through to whatever async APIs they call.
- Don't reach for
Registercallbacks for logic that's simpler to express as a straightforward periodic check in your own loop. - Cancellation support adds real complexity — add it where an operation is genuinely long-running or externally interruptible, not reflexively everywhere.
Mental Model
Register(...) subscribes, Cancel() notifies every subscriber, synchronouslyCancelAfter = an automatic, timer-driven
Cancel() call, no external trigger requiredCreateLinkedTokenSource = one new token, wired via the same registry mechanism to fire when any of several source tokens does
The disposal gotcha = don't tear down a source while
Cancel() or a registered callback on it might still be running on another threadRemember:
· Cancellation is cooperative — checking is opt-in, never forced from outside.
·
IsCancellationRequested/ThrowIfCancellationRequested() = polling; Register(...) = reactive notification — both driven by the same underlying registry.· Check cancellation at sensible, regular points in your own loops — not just once at the top, and not so often it adds noise.
· Scope a
CancellationTokenSource's lifetime with using so disposal happens after the operation naturally completes.
Key Takeaway
- A
CancellationTokenSourcemaintains a callback registry;token.Register(...)subscribes a callback, andCancel()synchronously walks and invokes every registered callback — the mechanism underneath both reactive notification and theIsCancellationRequestedflag. CancelAfter(...)gives you automatic, timer-based cancellation with no external caller needed to trigger it.CancellationTokenSource.CreateLinkedTokenSource(...)combines multiple independent cancellation signals — e.g. a caller's token and an internal timeout — into one new token that fires when any of them does.- Disposing a
CancellationTokenSourcewhileCancel()or a registered callback might still be running on another thread is a real race condition to design around — prefer scoping the source's lifetime withusingso disposal happens after the operation has fully completed. - In your own long-running loops, check
ThrowIfCancellationRequested()/IsCancellationRequestedat sensible, regular points — a natural per-record or per-batch boundary is usually right — not just once at the very start.
Check Your Understanding
You've gone past the basic pattern from lesson 153 into the actual mechanics of cancellation. Let's check your understanding.
1. What does CancellationTokenSource actually do internally when Cancel() is called?
Show answer
Correct: B
Why B is correct: As "What Is It?" and the Simple Example demonstrated, Cancel() walks the source's callback registry and invokes every registered callback synchronously — that registry mechanism is also what underlies IsCancellationRequested and the exceptions thrown by cancellation-aware APIs.
Why A is incorrect: This is exactly the misconception Common Confusion #1 corrects — cancellation is cooperative, never a forced thread termination.
Why C is incorrect: Cancellation is entirely a managed, in-process concept — it has no direct relationship to OS-wide I/O control.
Why D is incorrect: Nothing about Cancel() automatically throws on unrelated threads — code that never checks the token, or isn't built on a cancellation-aware API, is entirely unaffected.
Reinforcement: Cancel() is a registry walk, not a forced interruption — cooperative by design, all the way down.
2. What is the purpose of CancellationTokenSource.CreateLinkedTokenSource(tokenA, tokenB)?
Show answer
Correct: B
Why B is correct: As How It Works Part 2 and Common Confusion #2 explained, the linked source is a new, independent source whose token fires when any of the input tokens fire — the relationship flows one direction only, into the linked token, never back to the originals.
Why A is incorrect: Canceling the linked source does not cancel tokenA or tokenB — the wiring only flows from the originals into the new linked token, not the reverse.
Why C is incorrect: tokenA and tokenB remain entirely intact, independent, and usable elsewhere — nothing about them is destroyed or altered by linking.
Why D is incorrect: This describes an AND relationship; CreateLinkedTokenSource implements an OR relationship — either source alone is sufficient to cancel the linked token.
Reinforcement: Linked tokens are "cancel if ANY of these fire" — a one-directional OR, built from the same registry mechanism as ordinary Register calls.
3. A long-running batch-processing loop only checks token.ThrowIfCancellationRequested() once, before the loop starts, and never again inside it. What is the practical consequence?
Show answer
Correct: B
Why B is correct: As Common Mistakes #1 demonstrated, checking only once at the top means cancellation requested any time after that point has no effect until the loop naturally completes — cooperative cancellation only takes effect at points the code actually checks.
Why A is incorrect: There's no automatic, runtime-driven re-checking — cancellation is entirely opt-in; the code itself must call ThrowIfCancellationRequested() or check IsCancellationRequested at each point it wants to respond.
Why C is incorrect: This is exactly the "forced from outside" misconception this lesson corrects — nothing throws automatically at arbitrary points; only an explicit check (or a cancellation-aware API call) does.
Why D is incorrect: There's no such automatic disposal behavior tied to check frequency — this isn't how CancellationTokenSource lifetime management works.
Reinforcement: Cooperative cancellation only takes effect exactly where the code checks for it — placement matters as much as passing the token at all.
4. Why is disposing a CancellationTokenSource from one thread, while another thread might still be executing Cancel() or a registered callback on it, a real concern?
Show answer
Correct: B
Why B is correct: As Under the Hood point 2 explained, this is a real, documented gotcha — Cancel() and registered callbacks can be mid-execution on another thread at the exact moment Dispose() runs, creating a genuine race over shared resources like an internal timer.
Why A is incorrect: This is exactly the false confidence this lesson is warning against — the race is real, which is why the recommended pattern is scoping disposal with using rather than manual, arbitrarily-timed disposal from another thread.
Why C is incorrect: Dispose() does not wait for an in-flight Cancel() call or callback to finish — that's precisely why the race exists in the first place.
Why D is incorrect: CancellationTokenSource does implement IDisposable and is routinely disposed via using — the concern here is about disposal timing relative to concurrent Cancel() calls, not about whether disposal is possible at all.
Reinforcement: The using-declaration pattern shown throughout this lesson exists specifically to sidestep this exact race.
5. A batch-processing loop processes one order per iteration (validate, charge, ship) and checks ThrowIfCancellationRequested() once at the top of each iteration, but not in the middle of processing a single order. Why is this a reasonable design choice, rather than a mistake?
Show answer
Correct: B
Why B is correct: As the Real-World Example explained, the check placement is a deliberate design choice — stopping between orders leaves the system in a clean state, while stopping mid-charge could leave a payment half-processed, which is a worse outcome than waiting slightly longer to honor the cancellation.
Why A is incorrect: Nothing prevents checking the token as many times and at as many points as a method needs — the choice here is deliberate, not a language limitation.
Why C is incorrect: There's no such once-per-method restriction — CancellationToken can be checked as often as makes sense for the operation.
Why D is incorrect: Checking the token multiple times in one method is completely normal and doesn't throw anything by itself — only an actual cancellation request combined with ThrowIfCancellationRequested() throws.
Reinforcement: Good cancellation design is about choosing safe stopping points, not just checking as frequently as technically possible.
You've completed the internals half of Part IV — Task, SynchronizationContext, ThreadPool, ValueTask, and Cancellation. Next: putting concurrency itself to work, with Parallel programming and thread-safe collections.