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.
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.
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.
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?
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.
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.
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.
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.
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.
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.
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 to Dispose() the CancellationTokenSource.CancellationTokenSource.Dispose() releases the underlying resources the source uses (including, notably, any timer set up by CancelAfter). If a callback registered via token.Register(...) is concurrently executing on another thread while Dispose() 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.using declaration on the CancellationTokenSource at the scope that owns it, so disposal happens naturally after the awaited 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 calling Cancel() or running a registered callback on it.token.Register(...) returns a CancellationTokenRegistration. If the code that registered a callback finishes its work before the token is ever canceled, disposing that registration (which the using in 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.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.
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.
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.
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.
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.
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.Register callbacks for logic that's simpler to express as a straightforward periodic check in your own loop.Register(...) subscribes, Cancel() notifies every subscriber, synchronouslyCancel() call, no external trigger requiredCancel() or a registered callback on it might still be running on another threadIsCancellationRequested/ThrowIfCancellationRequested() = polling; Register(...) = reactive notification — both driven by the same underlying registry.CancellationTokenSource's lifetime with using so disposal happens after the operation naturally completes.
CancellationTokenSource maintains a callback registry; token.Register(...) subscribes a callback, and Cancel() synchronously walks and invokes every registered callback — the mechanism underneath both reactive notification and the IsCancellationRequested flag.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.CancellationTokenSource while Cancel() 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 with using so disposal happens after the operation has fully completed.ThrowIfCancellationRequested()/IsCancellationRequested at sensible, regular points — a natural per-record or per-batch boundary is usually right — not just once at the very start.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?
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)?
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.