Canceling Background Work
Starting background work is easy; stopping it is the part that needs a design. The obvious approaches all fail. Marking a thread as a background thread does make the process able to exit while that thread is still running, but it only helps at shutdown - it cannot stop one operation while the program keeps going - and it kills the work wherever it happens to be, with no chance to finish a write or save its state. The Thread.Abort method looks like the real answer and is far worse: tearing a thread down at an arbitrary instruction can leave shared state, and even the runtime's own internal structures, in an inconsistent state. It was dangerous enough that Microsoft removed it; on .NET Core, .NET 5 and everything since, calling it simply throws a PlatformNotSupportedException. There is no built-in way to stop a thread from outside, and that is a deliberate decision rather than an oversight. Work has to agree to stop, which means cooperative cancellation: somebody raises a flag, and the work checks it at moments when stopping is safe.
Writing that flag yourself is instructive, because every step of it explains a design decision in the class .NET actually gives you. A plain bool field looks sufficient and is not: as covered in the chapter on memory models, one core can set the flag in its own cache while another keeps reading a stale copy, and the compiler is entitled to notice that a loop never modifies the flag and to hoist the check right out of the loop. Both problems are invisible on a busy development machine and appear on a many-core production server, often only in release builds. Protecting the field with a lock fixes the correctness problem and creates a maintenance one, because the fix only works if every future reader remembers to take the lock. Wrapping the field in a class that owns all access to it solves that. And then one last problem remains: if the same object can both check the flag and raise it, any piece of code holding it can cancel everybody else's work, usually by accident. The fix is to split the class in two - one type that can only ask whether cancellation was requested, and another that can trigger it and hands out the first. That is exactly what .NET ships: CancellationToken is the read-only half, and CancellationTokenSource is the half with the button on it. Code that might cancel takes the source; code that might be cancelled takes only the token.
The single most important thing to understand about a CancellationToken is how little it does. It is a thread-safe, tamper-resistant bool, and nothing more. It does not know how to stop a loop, abort a query or close a socket; it cannot interrupt anything. If the work between two checks takes a minute, then cancellation takes up to a minute to be noticed, and no amount of API cleverness changes that. Cancellation granularity is a property of the code being cancelled, not of the token. This is why a long-running method that wants to be cancellable has to take the token as a parameter and check it inside its own loop, and it is why every cancellable API in .NET has a token parameter rather than some external kill switch.
The core example works through the whole API on one small piece of fake work: a list of five reports, each taking a hundred milliseconds. That number is chosen so the timing is easy to reason about and identical on every run. The work checks the token before each report, so the checks happen at zero, one hundred, two hundred, three hundred and four hundred milliseconds, and every cancellation in the example is triggered at two hundred and fifty milliseconds - comfortably between the check that still sees a live token and the one that sees a cancelled one. That is why every part below stops after exactly three reports:
Two versions of the work exist, because there are two ways for cancellable code to report that it gave up. ProcessReportsAsync uses the polling style: it checks IsCancellationRequested at the top of each iteration, breaks out of the loop, and returns the list of reports it did manage to finish, so the caller can see both that it stopped early and how far it got. ProcessReportsOrThrowAsync uses the exception style: it calls ThrowIfCancellationRequested, which is simply a shorthand for checking the flag and throwing an OperationCanceledException, and prints each report as it completes so the stopping point is visible in the output:
Part 1 runs the work twice. The first run passes CancellationToken.None, the token that can never be cancelled and exists precisely for calling an API that demands a token when you have no intention of cancelling anything; it is the readable spelling of new CancellationToken(false). All five reports finish. The second run creates a CancellationTokenSource, hands its Token to the work, and presses the button a quarter of a second later. Three reports finish. Notice what the code does not do: it never touches a thread, and the work is not interrupted mid-report - the fourth report is not half-written, it simply never starts.
Part 2 shows why the exception style is usually the better of the two. The polling style forces every caller to check a return value, which clutters call sites with conditionals, risks a future maintainer forgetting one, and burns the return value on a status flag so that a method with a real result has to resort to tuples or out parameters. Throwing solves all three at once, and the exception travels up through any number of intermediate frames without each one having to cooperate. It also integrates with Tasks in a way worth knowing: because OperationCanceledException carries the token that was cancelled, the Task does not end up Faulted like an ordinary failure but Canceled, which the output shows, and which lets calling code tell "this was stopped on purpose" apart from "this broke". The part closes with new CancellationToken(canceled: true), a token that is cancelled from birth - not something to reach for in production code, but genuinely handy in a unit test that needs to prove a method gives up immediately.
Part 3 covers the case where the work cannot be made to check anything, which happens with older libraries, native wrappers and remote clients that expose their own Cancel method or their own event-based protocol. Polling such a component from a spare thread would be wasteful, so CancellationToken.Register inverts it: the token calls you back the moment it is cancelled, and the callback is the natural place to invoke whatever shutdown method the component provides. Two behaviours in the output are worth committing to memory. Registering several callbacks runs all of them, and registering on a token that has already been cancelled does not silently do nothing - it runs the callback immediately, on the spot, which is what the second registration in the example demonstrates. Register hands back a registration object that can be disposed to unregister, which matters when the token outlives the thing the callback touches. One warning that the API cannot enforce: the callback runs on whichever thread called Cancel, not on the thread doing the cancelled work, so it must be thread-safe and it must be quick.
Part 4 is the most common use of cancellation in real systems, which is not a user clicking a button but a timeout. Waiting forever for a network reply is indistinguishable from waiting for a machine that does not exist, so at some point the wait has to be declared a failure. Wiring a timer to a CancellationTokenSource would work, and CancelAfter saves the trouble; it takes either a millisecond count or, more readably, a TimeSpan. Three of its behaviours are easy to miss: calling it again before the token is cancelled resets the countdown rather than adding a second one, calling it after the token is already cancelled does nothing at all, and calling CancelAfter(-1) cancels the timeout itself, leaving the token alive.
Part 5 answers the question that follows immediately: what if an operation should stop for more than one reason - the user pressed stop, or the time budget ran out, or the caller went away? A method that receives a CancellationToken cannot add a timeout to it, because a token has no button; only its source does, and that source belongs to somebody else. CancellationTokenSource.CreateLinkedTokenSource resolves this by building a brand new source whose token is cancelled automatically when any of the tokens it was built from is cancelled. The new source is yours: you can give it a timeout with CancelAfter, or cancel it by hand, and the work downstream only ever sees the single combined token. The example gives the linked source a six hundred millisecond timeout and then presses the stop button at two hundred and fifty, so the button wins and the work stops after three reports. The last two lines make the other half of the contract explicit: cancelling a linked source never travels back to the tokens it was built from, so the original token is still uncancelled afterwards. Linked sources hold a registration on each of their source tokens, which is why the example disposes them. Running the core example prints:
The real-world example puts the timeout and the linked source together in the shape they most often appear: a server handling a request that must not run forever, while also noticing when the caller has hung up. In a web framework the caller's token arrives from the framework itself - in ASP.NET Core it is the request-aborted token - and the handler adds its own time budget on top:
HandleRequestAsync takes the report to load, a queryMilliseconds parameter standing in for how slow the database happens to be that day, and callerGone, the token the framework cancels when the client disconnects. It links that token into a source of its own, gives it a three hundred millisecond budget with CancelAfter, and passes only the combined token down to the query. RunQueryAsync is the stand-in for real I/O, and it does what every well-behaved asynchronous API does: it accepts the token and hands it to the operation it performs, here Task.Delay, which throws a TaskCanceledException - a subclass of OperationCanceledException - the instant the token is cancelled, rather than sitting out the full delay:
The three calls in the scenario cover the three outcomes. A query needing a hundred milliseconds finishes inside the three hundred millisecond budget and returns its result normally. A query needing nine hundred milliseconds does not, so the linked token fires at three hundred and the handler answers with a gateway timeout instead of hanging. The third call starts the same slow query and then cancels the caller's token after a hundred milliseconds, and the work stops immediately even though the budget still had two hundred milliseconds left, because either source can cancel the combined token. The detail worth copying is inside the catch clause. Both cancellations arrive as the same exception type, so the exception cannot say which one happened; the handler asks callerGone.IsCancellationRequested to tell them apart, and reacts differently - a timeout is a problem worth reporting and possibly alerting on, whereas a disconnected caller is routine and there is nobody left to send a response to. Running the scenario prints:
The habits that follow are simple. Take a CancellationToken as the last parameter of any method that might run long, and pass it down to everything you call, because a token that stops at the top of the call stack cancels nothing. Check it, or hand it to an API that checks it, at intervals short enough to matter - the token's promptness is entirely determined by how often the work looks at it. Prefer ThrowIfCancellationRequested over polling and returning a status, unless the caller genuinely needs the partial result, as in the first example above. Never catch OperationCanceledException and carry on as if the work succeeded, and when several cancellation reasons are in play, check the individual tokens to find out which one fired. Dispose your sources, especially linked ones, since they register callbacks on the tokens they were built from. Use CancellationToken.None to say out loud that an operation is not meant to be cancellable, rather than passing a default token silently. And remember through all of it that a token is only ever a flag: it makes cancellation possible and it is your code that makes it happen. Both examples are run one after the other from the program entry point: