📝 Lesson 3.1: Cancellation and Timeouts
A long-running operation the user no longer wants — or one that's taking too long — should stop. .NET has a unified, cooperative model for this built around CancellationToken, and it's the same mechanism for user cancellation and timeouts alike.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain .NET's cooperative cancellation model
- Create and signal cancellation with
CancellationTokenSource - Observe a
CancellationTokenin your own code - Implement timeouts with
CancelAfter/WaitAsync - Combine multiple cancellation sources with linked tokens
Estimated Time: 60 minutes
Project: Make a long operation cancellable and add a timeout.
In This Lesson
Cooperative Cancellation
.NET does not forcibly kill running work — abruptly aborting a thread could leave data half-written and locks held, corrupting state. Instead it uses cooperative cancellation: cancellation is a request, and the running code chooses safe points to notice it and stop cleanly.
📖 Definition
Cooperative cancellation: a caller signals a request via a CancellationToken; the operation periodically checks the token and, when it sees a request, stops gracefully. Both sides cooperate — nothing is force-killed.
(throw OperationCanceledException)"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style D fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
Two types work together: a CancellationTokenSource (the caller holds it and triggers cancellation) and a CancellationToken (a lightweight handle passed into the operation, which it observes).
Token and Source
The caller creates a source, passes its Token to the operation, and calls Cancel() when it wants to stop:
using var cts = new CancellationTokenSource();
// Start the work, giving it the token
Task work = DoWorkAsync(cts.Token);
// Later — e.g. user pressed a button — request cancellation
cts.Cancel();
try
{
await work;
}
catch (OperationCanceledException)
{
Console.WriteLine("Work was cancelled.");
}
💡 Cancellation is one-way and one-shot
Once a CancellationTokenSource is cancelled, its token stays cancelled — you can't "un-cancel." Create a fresh source for a new operation. And always Dispose the source (the using above does this) since it may hold timer resources.
✅ It's the standard parameter
By convention, cancellable methods take a CancellationToken as their last parameter, often defaulting to default (i.e. CancellationToken.None). Nearly every async .NET API — HttpClient, file streams, EF Core — accepts one. Passing it through is how cancellation propagates down a call chain.
Observing the Token
An operation must actively check the token to be cancellable. There are two idioms.
Throw at a check point
ThrowIfCancellationRequested() throws an OperationCanceledException if cancellation was requested — the cleanest way to bail out of a loop:
async Task ProcessAsync(IEnumerable<string> items, CancellationToken token)
{
foreach (string item in items)
{
token.ThrowIfCancellationRequested(); // stop point
await ProcessOneAsync(item);
}
}
Check the flag
Or inspect IsCancellationRequested to stop without throwing (e.g. to return partial results):
while (!token.IsCancellationRequested)
{
DoOneStep();
}
Pass it to async calls
Most importantly, forward the token to the async operations you call. They'll honor it internally — cancelling a slow HTTP request or delay promptly, rather than waiting for the next manual check:
async Task<string> FetchAsync(string url, CancellationToken token)
{
// The token cancels the wait/request itself
await Task.Delay(5000, token); // cancellable delay
return await _client.GetStringAsync(url, token); // cancellable request
}
⚠️ A token you never check does nothing
Cancellation is cooperative — if your loop or method never observes the token (and never passes it to inner async calls), calling Cancel() has no effect and the work runs to completion. Sprinkle check points into long CPU loops, and always forward the token to I/O calls.
Handling Cancellation
When cancellation fires, the operation throws OperationCanceledException (its TaskCanceledException subclass, which you saw around timeouts in Intermediate, derives from it). Catch OperationCanceledException to cover both:
try
{
await ProcessAsync(items, cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled — cleaning up partial work.");
}
💡 Cancellation is expected, not a failure
Treat OperationCanceledException as a normal control-flow signal, distinct from real errors. Catch it specifically (before a general catch) so you don't log a user-initiated cancel as an application fault. A common pattern: let it propagate, and handle it once at the top level where the operation was launched.
⚠️ Don't swallow the token in a catch-all
A broad catch (Exception) around cancellable code will also catch OperationCanceledException and can mask the fact that work was cancelled. Either re-check token.IsCancellationRequested, or catch OperationCanceledException first and rethrow/handle it deliberately.
Timeouts
A timeout is just cancellation on a timer — the same mechanism. A CancellationTokenSource can cancel itself automatically after a delay.
// Cancel automatically after 3 seconds
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try
{
string result = await FetchAsync(url, cts.Token);
Console.WriteLine(result);
}
catch (OperationCanceledException)
{
Console.WriteLine("Timed out after 3 seconds.");
}
You can also arm the timer after creating the source, or add a timeout to an existing task without threading a token through it, using WaitAsync (modern .NET):
// Arm later:
cts.CancelAfter(TimeSpan.FromSeconds(5));
// Add a timeout to any task:
try
{
string result = await SomeTask.WaitAsync(TimeSpan.FromSeconds(2));
}
catch (TimeoutException)
{
Console.WriteLine("The operation took too long.");
}
✅ One model, two triggers
User-initiated cancellation and timeouts are the same feature: a token that gets signaled — manually by Cancel(), or automatically by a timer. Your operation's code doesn't need to know which; it just observes the token and stops.
Linked Tokens
Sometimes an operation should stop if any of several conditions occurs — e.g. the user cancels or a timeout elapses or a parent operation is aborted. CreateLinkedTokenSource combines tokens: the linked token is cancelled when any of its sources is.
// One source for the user, one for a timeout
using var userCts = new CancellationTokenSource();
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// Linked: cancels if the user cancels OR the 10s timeout fires
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userCts.Token, timeoutCts.Token);
await DoWorkAsync(linkedCts.Token); // honors both triggers
💡 Registering a callback
You can run code the moment cancellation is requested with token.Register(callback) — handy for cleanup or to abort a resource that doesn't take a token directly. Dispose the registration when you're done to avoid leaks.
using var registration = token.Register(() => Console.WriteLine("Cancelling!"));
Exercise & Quiz
🏋️ Exercise: A Cancellable Worker with Timeout
Objective: Make a long operation observe a token, then add a timeout.
Instructions:
- Create a new project called
Cancellation. - Write
async Task<int> CountSlowlyAsync(int to, CancellationToken token)that loops from 1 toto, doesawait Task.Delay(200, token)each step, prints the number, and returns the count reached. - Start it counting to 100 with a
CancellationTokenSource; from another task,Cancel()after ~1 second and observe it stops withOperationCanceledException. - Add a timeout: use
new CancellationTokenSource(TimeSpan.FromSeconds(2))instead, and confirm the worker stops on its own after 2 seconds.
Starter Code:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try
{
int reached = await CountSlowlyAsync(100, cts.Token);
Console.WriteLine($"Finished at {reached}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Stopped early (cancelled or timed out).");
}
static async Task<int> CountSlowlyAsync(int to, CancellationToken token)
{
// TODO: loop 1..to, await Task.Delay(200, token), print each
// return how far it got
return 0;
}
💡 Hint
In the loop, await Task.Delay(200, token); will itself throw OperationCanceledException when the token fires — you don't even need a manual check for this one. Return i if the loop completes.
✅ Solution
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try
{
int reached = await CountSlowlyAsync(100, cts.Token);
Console.WriteLine($"Finished at {reached}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Stopped early (cancelled or timed out).");
}
static async Task<int> CountSlowlyAsync(int to, CancellationToken token)
{
int i = 0;
for (i = 1; i <= to; i++)
{
token.ThrowIfCancellationRequested(); // optional explicit check
await Task.Delay(200, token); // cancellable wait
Console.WriteLine(i);
}
return i - 1;
}
// Manual-cancel variant: start the task, then in another task Cancel() after 1s:
// _ = Task.Run(async () => { await Task.Delay(1000); cts.Cancel(); });
With the 2-second timeout, it prints about 1..10 and then "Stopped early" — the timer signalled the token, and Task.Delay observed it.
🎯 Quick Quiz
Question 1: What does "cooperative cancellation" mean?
Question 2: If your loop never checks the token or passes it to inner calls, what happens when the caller cancels?
Question 3: How is a timeout implemented in this model?
Summary
🎉 Key Takeaways
- .NET uses cooperative cancellation: cancellation is a request the operation must observe and honor — nothing is force-killed.
- The caller holds a
CancellationTokenSourceand callsCancel(); the operation receives aCancellationToken(conventionally the last parameter). - Observe it with
ThrowIfCancellationRequested()/IsCancellationRequested, and forward it to inner async calls. An unobserved token does nothing. - Cancellation surfaces as
OperationCanceledException— treat it as expected control flow, not a failure. - Timeouts are cancellation on a timer (
CancelAfter/new CTS(timespan)/WaitAsync); linked tokens combine multiple triggers.
📚 Additional Resources
- Cancellation in managed threads — Microsoft Docs
- CancellationToken — API reference
- Task cancellation
🚀 What's Next?
You can start, await, and now stop async work. Next, we produce async data over time. In Lesson 3.2: Async Streams and Channels, you'll use IAsyncEnumerable and channels to stream results as they arrive.
🎉 You can stop the work!
Cancellation and timeouts make async operations controllable. Next: streaming async data.