High-Performance .NET: Async, Multithreading, and Parallel Programming Concurrency Created: 27 Aug 2026 Updated: 27 Aug 2026

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:

// The fixed work list every example below walks through.
private static readonly string[] ReportNames = ["sales", "stock", "payroll", "returns", "shipping"];
/// <summary>
/// Core concept example.
/// </summary>
public static async Task Run()
{
Console.WriteLine("=== Canceling background work: core concept ===");
Console.WriteLine();

Console.WriteLine("--- Part 1: a token is a flag the work has to check ---");

// CancellationToken.None is the token that can never be cancelled.
List<string> all = await ProcessReportsAsync(CancellationToken.None);
Console.WriteLine($"with CancellationToken.None: {string.Join(", ", all)}");

CancellationTokenSource userCancel = new();

// The source hands out the token; only the source can cancel it.
Task<List<string>> counting = ProcessReportsAsync(userCancel.Token);

await Task.Delay(250);
userCancel.Cancel();

List<string> done = await counting;
Console.WriteLine($"cancelled after 250 ms: {string.Join(", ", done)}");
Console.WriteLine($"the token reports cancelled: {userCancel.IsCancellationRequested}");
Console.WriteLine();

Console.WriteLine("--- Part 2: ThrowIfCancellationRequested reports it as an exception ---");

CancellationTokenSource throwingCancel = new();
Task processing = ProcessReportsOrThrowAsync(throwingCancel.Token);

await Task.Delay(250);
throwingCancel.Cancel();

try
{
await processing;
}
catch (OperationCanceledException)
{
Console.WriteLine("await rethrew an OperationCanceledException");
}

// A cancelled task is Canceled, not Faulted, because the exception carries the token.
Console.WriteLine($"the task ended as: {processing.Status}");

try
{
// A token that is born cancelled, which is mostly useful in tests.
await ProcessReportsOrThrowAsync(new CancellationToken(canceled: true));
}
catch (OperationCanceledException)
{
Console.WriteLine("an already cancelled token stops the work on its very first check");
}

Console.WriteLine();

Console.WriteLine("--- Part 3: Register runs a callback when the token is cancelled ---");

CancellationTokenSource shutdown = new();

using CancellationTokenRegistration registration =
shutdown.Token.Register(() => Console.WriteLine(" callback: closing the legacy connection"));

shutdown.Cancel();

// Registering on a token that is already cancelled runs the callback straight away.
using CancellationTokenRegistration lateRegistration =
shutdown.Token.Register(() => Console.WriteLine(" callback registered afterwards runs immediately"));

Console.WriteLine();

Console.WriteLine("--- Part 4: CancelAfter turns a token into a timeout ---");

CancellationTokenSource timeout = new();
timeout.CancelAfter(TimeSpan.FromMilliseconds(250));

List<string> beforeTimeout = await ProcessReportsAsync(timeout.Token);
Console.WriteLine($"stopped by the 250 ms timeout: {string.Join(", ", beforeTimeout)}");
Console.WriteLine();

Console.WriteLine("--- Part 5: a linked source cancels for either reason ---");

CancellationTokenSource stopButton = new();

using CancellationTokenSource combined = CancellationTokenSource.CreateLinkedTokenSource(stopButton.Token);
combined.CancelAfter(TimeSpan.FromMilliseconds(600));

Task<List<string>> linkedWork = ProcessReportsAsync(combined.Token);

await Task.Delay(250);
stopButton.Cancel();

List<string> beforeStop = await linkedWork;
Console.WriteLine($"stopped by the button, not the 600 ms timeout: {string.Join(", ", beforeStop)}");
Console.WriteLine($"the linked token followed the button: {combined.IsCancellationRequested}");

// Cancelling a linked source never travels back to the token it was built from.
CancellationTokenSource otherButton = new();
using CancellationTokenSource otherCombined = CancellationTokenSource.CreateLinkedTokenSource(otherButton.Token);
otherCombined.Cancel();

Console.WriteLine($"cancelling the linked source left the original token: {otherButton.IsCancellationRequested}");
Console.WriteLine();
}

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:

/// <summary>
/// Walks the report list and stops between items when cancellation is requested.
/// </summary>
private static async Task<List<string>> ProcessReportsAsync(CancellationToken cancellationToken)
{
List<string> finished = [];

foreach (string reportName in ReportNames)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}

// Stands in for the work on one report.
await Task.Delay(100);

finished.Add(reportName);
}

return finished;
}

/// <summary>
/// The same walk, reporting cancellation by throwing instead of by returning a list.
/// </summary>
private static async Task ProcessReportsOrThrowAsync(CancellationToken cancellationToken)
{
foreach (string reportName in ReportNames)
{
cancellationToken.ThrowIfCancellationRequested();

await Task.Delay(100);

Console.WriteLine($" finished the {reportName} report");
}
}

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:

=== Canceling background work: core concept ===

--- Part 1: a token is a flag the work has to check ---
with CancellationToken.None: sales, stock, payroll, returns, shipping
cancelled after 250 ms: sales, stock, payroll
the token reports cancelled: True

--- Part 2: ThrowIfCancellationRequested reports it as an exception ---
finished the sales report
finished the stock report
finished the payroll report
await rethrew an OperationCanceledException
the task ended as: Canceled
an already cancelled token stops the work on its very first check

--- Part 3: Register runs a callback when the token is cancelled ---
callback: closing the legacy connection
callback registered afterwards runs immediately

--- Part 4: CancelAfter turns a token into a timeout ---
stopped by the 250 ms timeout: sales, stock, payroll

--- Part 5: a linked source cancels for either reason ---
stopped by the button, not the 600 ms timeout: sales, stock, payroll
the linked token followed the button: True
cancelling the linked source left the original token: False

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:

/// <summary>
/// Real-world example: a request that gives up on a timeout or when the caller leaves.
/// </summary>
public static async Task RunRequestTimeoutScenario()
{
Console.WriteLine("=== Real-world scenario: a request that gives up on time ===");

// The query finishes well inside the budget.
using CancellationTokenSource fastCaller = new();
Console.WriteLine(await HandleRequestAsync("fast-report", queryMilliseconds: 100, fastCaller.Token));

// The query is far too slow, so the timeout wins.
using CancellationTokenSource patientCaller = new();
Console.WriteLine(await HandleRequestAsync("slow-report", queryMilliseconds: 900, patientCaller.Token));

// The caller hangs up before the timeout would have fired.
using CancellationTokenSource leavingCaller = new();
Task<string> pending = HandleRequestAsync("abandoned-report", queryMilliseconds: 900, leavingCaller.Token);

await Task.Delay(100);
leavingCaller.Cancel();

Console.WriteLine(await pending);
Console.WriteLine();
}

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:

/// <summary>
/// Handles one request under both a caller token and a server-side time budget.
/// </summary>
private static async Task<string> HandleRequestAsync(string reportName, int queryMilliseconds, CancellationToken callerGone)
{
using CancellationTokenSource request = CancellationTokenSource.CreateLinkedTokenSource(callerGone);
request.CancelAfter(TimeSpan.FromMilliseconds(300));

try
{
string rows = await RunQueryAsync(reportName, queryMilliseconds, request.Token);

return $"{reportName}: 200 OK, {rows}";
}
catch (OperationCanceledException)
{
// The exception alone cannot say why, so ask the caller's own token.
return callerGone.IsCancellationRequested
? $"{reportName}: caller disconnected, work abandoned"
: $"{reportName}: 504 Gateway Timeout after 300 ms";
}
}

/// <summary>
/// Stands in for a database query that honours the token it is given.
/// </summary>
private static async Task<string> RunQueryAsync(string reportName, int queryMilliseconds, CancellationToken cancellationToken)
{
await Task.Delay(queryMilliseconds, cancellationToken);

return $"{reportName} rows loaded";
}

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:

=== Real-world scenario: a request that gives up on time ===
fast-report: 200 OK, fast-report rows loaded
slow-report: 504 Gateway Timeout after 300 ms
abandoned-report: caller disconnected, work abandoned

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:

using ConsoleApp;

await CancelingBackgroundWorkExample.Run();
await CancelingBackgroundWorkExample.RunRequestTimeoutScenario();


Share this lesson: