Over the past few days, I worked on a microservice that heavily relied on asynchronous tasks, synchronized code sections, and monitoring jobs to keep track of the task status. While doing this, I struggled a bit to fully understand how the cancellation token pattern works in C#. In this article, I’ll share what I learned.
Async Programming in C
Async programming in C# is powerful—but as with great power comes great responsibility.
Long-running tasks can quickly become resource hogs if you don’t give them a way to stop when they’re no longer needed. That’s exactly where cancellation tokens come in.
In this guide, we’ll explore:
How to use
CancellationToken
How to combine multiple cancellation sources with
CreateLinkedTokenSource()
A practical demo: a fake file download manager with workers that can be cancelled by the user or automatically when they timeout
Why Cancellation Matters
Imagine starting a big file download—or any task that risks running forever in the background. Now imagine the user closes the app or decides to cancel. Without cancellation support, your tasks will continue running until completion, wasting CPU and memory.
Cancellation tokens provide a cooperative mechanism to signal when a task should stop.
A First Taste of Cancellation
Our demo will have the following requirements:
Start 5 tasks simulating file downloads
Each download should take roughly 5–10 seconds
Show progress in the console
Allow the user to cancel all downloads by pressing 'c'
Support a timeout simulation on one of the tasks that cancels itself
Print a success message if all tasks complete
static void Main(string[] args)
{
var externalCancellation = new CancellationTokenSource();
var app = new Program();
Task.Run(() =>
{
Console.WriteLine("Press 'c' to cancel within 3 seconds after work begins.");
Console.WriteLine("Or let the task time out by doing nothing.");
if (Console.ReadKey(true).KeyChar == 'c')
externalCancellation.Cancel();
});
// Wait 1 second before starting the main work
Thread.Sleep(1000);
Task[] tasks = new Task[5];
for (int i = 0; i < 5; i++)
{
int workerIndex = i; // capture the current value
tasks[i] = Task.Run(() =>
{
var timerTimeout = workerIndex == 1 ? 2000 : (int?)null;
var worker = new BackgroundDownloadWorker(workerIndex, timerTimeout);
worker.DoWork(externalCancellation.Token);
});
}
try
{
Task.WhenAll(tasks).Wait();
Console.WriteLine("Main: All workers completed successfully.");
}
catch (AggregateException ae)
{
foreach (var e in ae.InnerExceptions)
{
if (e is OperationCanceledException)
{
if (externalCancellation.IsCancellationRequested)
Console.WriteLine("Main: Operation cancelled by user.");
else
Console.WriteLine("Main: Operation cancelled due to timeout or other reasons.");
}
}
}
Console.WriteLine("Press a key to exit");
Console.ReadKey();
externalCancellation.Dispose();
}
Key Takeaways from the Main Program:
A separate task monitors for user cancellation via key press.
externalCancellation
is used to cancel all running tasks when the user presses'c'
.Each worker executes a simulated file download, with a unique index for console logging.
When cancellation occurs, an
OperationCanceledException
is thrown and caught, distinguishing between user-triggered and internal cancellations.
BackgroundDownloadWorker
class BackgroundDownloadWorker
{
private CancellationTokenSource internalCancellation = new CancellationTokenSource();
private CancellationToken internalCancellationToken;
private int _workerIndex;
private Timer _timer;
public BackgroundDownloadWorker(int index, int? timerTimeout = null)
{
_workerIndex = index + 1;
if (timerTimeout.HasValue)
_timer = new Timer(TimeoutAction, null, timerTimeout.Value, Timeout.Infinite);
}
public void DoWork(CancellationToken externalCancellationToken)
{
internalCancellationToken = internalCancellation.Token;
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalCancellationToken, externalCancellationToken);
try
{
DoInternalWork(linkedCts.Token);
}
catch (OperationCanceledException)
{
if (internalCancellationToken.IsCancellationRequested)
Console.WriteLine($"Worker {_workerIndex}:: download timeout");
if (externalCancellationToken.IsCancellationRequested)
Console.WriteLine($"Worker {_workerIndex}:: download cancelled by user");
}
}
private void DoInternalWork(CancellationToken cancellationToken)
{
int secondsOfWork = _workerIndex switch
{
1 => 5000,
2 => 8000,
3 => 10000,
_ => 7000
};
Console.WriteLine($"Worker {_workerIndex}:: downloading");
for (int i = 0; i < secondsOfWork; i += 1000)
{
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"Worker {_workerIndex}:: Working... {i / 1000 + 1} seconds elapsed.");
Thread.Sleep(1000);
}
Console.WriteLine($"Worker {_workerIndex}:: Download completed successfully.");
}
private void TimeoutAction(object state)
{
Console.WriteLine($"Worker {_workerIndex}:: timeout expired");
internalCancellation.Cancel();
_timer.Dispose();
}
}
1. Fields
private CancellationTokenSource internalCancellation = new CancellationTokenSource();
private CancellationToken internalCancellationToken;
private int _workerIndex;
private Timer _timer;
internalCancellation: A CancellationTokenSource that allows this worker to cancel itself, e.g., on timeout.
internalCancellationToken: Holds the CancellationToken from internalCancellation, to be used for checking if the task was canceled internally.
_workerIndex: Stores the worker’s index, mainly for logging purposes.
_timer: A System.Threading.Timer to enforce a timeout on the work
2. Constructor
public BackgroundDownloadWorker(int index, int? timerTimeout = null)
{
_workerIndex = index + 1;
if (timerTimeout.HasValue)
_timer = new Timer(TimeoutAction, null, timerTimeout.Value, Timeout.Infinite);
}
index: Used to differentiate workers.
timerTimeout: Optional parameter that sets a timeout in milliseconds.
If a timeout is provided, a timer is created, which will call _TimeoutAction _when the timeout expires.
3. Method
public void DoWork(CancellationToken externalCancellationToken)
{
internalCancellationToken = internalCancellation.Token;
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalCancellationToken, externalCancellationToken);
try
{
DoInternalWork(linkedCts.Token);
}
catch (OperationCanceledException)
{
if (internalCancellationToken.IsCancellationRequested)
Console.WriteLine($"Worker {_workerIndex}:: download timeout");
if (externalCancellationToken.IsCancellationRequested)
Console.WriteLine($"Worker {_workerIndex}:: download cancelled by user");
}
}
Accepts an external cancellation token, e.g., user requested cancellation.
Combines internal and external tokens into a linked token. This allows cancellation to occur from either source.
Calls DoInternalWork, which simulates the download.
-
Catches OperationCanceledException and logs the reason for cancellation:
- Internal → timeout
- External → user cancellation
Combining Multiple Cancellation Sources
Sometimes cancellation can come from multiple sources:
External cancellation – user input, signals, etc.
Internal cancellation – timeout, watchdog, or business logic
C# provides a neat solution:
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalCancellationToken, externalCancellationToken);
If either token is cancelled, the linked token triggers cancellation for the task.
4. DoInternalWork Method
private void DoInternalWork(CancellationToken cancellationToken)
{
int secondsOfWork = _workerIndex switch
{
1 => 5000,
2 => 8000,
3 => 10000,
_ => 7000
};
Console.WriteLine($"Worker {_workerIndex}:: downloading");
for (int i = 0; i < secondsOfWork; i += 1000)
{
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"Worker {_workerIndex}:: Working... {i / 1000 + 1} seconds elapsed.");
Thread.Sleep(1000);
}
Console.WriteLine($"Worker {_workerIndex}:: Download completed successfully.");
}
Simulates a download task by looping and sleeping 1 second at a time.
secondsOfWork is a rough duration for the task, based on _workerIndex.
Checks cancellationToken every second. If cancellation is requested, throws OperationCanceledException.
Logs progress to the console.
5. TimeoutAction Method
private void TimeoutAction(object state)
{
Console.WriteLine($"Worker {_workerIndex}:: timeout expired");
internalCancellation.Cancel();
_timer.Dispose();
}
called when the timer expires.
Cancels the worker using internal cancellation.
Disposes the timer to clean up resources.
Test Cases
To see the cancellation pattern in action, you can test two main scenarios:
1️⃣ User Cancels with 'c'
Scenario: The user decides to stop all downloads before they complete.
Steps:
Run the program.
When prompted, press
'c'
within a few seconds.Observe the console logs.
Expected Behavior:
- Each worker (1,3) prints a cancellation message
- Worker (2) cancelled because of timeout expired
2️⃣ Let the Program Run Without Interruption
Scenario: The program is allowed to complete normally.
Steps:
Run the program.
Do not press
'c'
.Wait for all workers to finish.
Expected Behavior:
- Workers (1,3) complete their downloads successfully
- Worker (2) cancelled because of timeout expired
Conclusion
In this article, we explored:
How to use
CancellationToken
to stop long-running tasksHow to link multiple cancellation sources (user input + internal timeout)
A practical demo with multiple background workers
This pattern is extremely useful in real applications:
Cancelling HTTP requests when users navigate away
Stopping database queries after a timeout
Managing parallel background jobs gracefully
Cancellation makes your applications more responsive, resource-friendly, and robust.
Top comments (0)