← Open in the full interactive course (progress tracking, search & more)

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.

Why this matters practically

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 Cancel() ACTUALLY PROPAGATES
source.Cancel() is called
Explicitly by your code, automatically after a CancelAfter(...) timer elapses, or because a linked source canceled.
The source flips its internal state, then walks its callback registry
Every registered callback (from every token.Register(...) call, including internal ones inside cancellation-aware BCL APIs) is invoked, synchronously.
token.IsCancellationRequested becomes true
Any code polling this flag in a loop sees it on its next check.
Registered callbacks run immediately, reactively
No polling needed — this is the fast, notification-driven path.

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

A Fire Alarm, Not a Checklist You Re-read Every Second

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

THE DISPOSAL-ORDERING GOTCHA
1. Cancel() RUNS CALLBACKS SYNCHRONOUSLY, ON THE CALLING THREAD
2. DISPOSING A SOURCE WHILE A CALLBACK MIGHT STILL BE RUNNING NEEDS CARE
3. THE SAFE PATTERN — LET Cancel() (OR THE OPERATION'S NATURAL COMPLETION) FINISH BEFORE DISPOSING
4. UNREGISTER CALLBACKS YOU NO LONGER NEED, VIA THE REGISTRATION HANDLE

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

Keep it proportionate

Mental Model

The callback registry = a subscription list on the source; Register(...) subscribes, Cancel() notifies every subscriber, synchronously
CancelAfter = an automatic, timer-driven Cancel() call, no external trigger required
CreateLinkedTokenSource = 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 thread

Remember:
· 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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.