Introduction
A CancellationTokenSource can hang an application when a cancellation callback registered via CancellationToken.Register() blocks, when cancellation is triggered on the UI thread causing a deadlock, or when Dispose() is called while callbacks are still executing. The most common pattern is a synchronous callback that tries to access a resource held by the thread calling Cancel(), creating a deadlock. Understanding that Cancel() executes registered callbacks synchronously on the calling thread is the key to avoiding these hangs.
How Cancel() Executes Callbacks
1var cts = new CancellationTokenSource();
2
3// Register a callback
4cts.Token.Register(() =>
5{
6 Console.WriteLine("Callback executing on: " + Thread.CurrentThread.ManagedThreadId);
7 // This runs SYNCHRONOUSLY on the thread that calls Cancel()
8});
9
10Console.WriteLine("Cancel called on: " + Thread.CurrentThread.ManagedThreadId);
11cts.Cancel(); // Blocks until ALL registered callbacks complete
12Console.WriteLine("Cancel returned");
Cancel() does not return until every registered callback finishes. If any callback blocks, Cancel() blocks, and the calling code hangs.
Deadlock Scenario 1: Callback Waits on the Cancelling Thread
1private readonly object _lock = new object();
2private CancellationTokenSource _cts = new CancellationTokenSource();
3
4void Setup()
5{
6 _cts.Token.Register(() =>
7 {
8 // DEADLOCK: trying to acquire _lock, but the thread
9 // calling Cancel() already holds _lock
10 lock (_lock)
11 {
12 Console.WriteLine("Cleanup in callback");
13 }
14 });
15}
16
17void StopProcessing()
18{
19 lock (_lock) // Acquires _lock
20 {
21 _cts.Cancel(); // Executes callback synchronously
22 // Callback tries to acquire _lock → deadlock!
23 }
24}
The callback runs on the same thread that called Cancel(), which already holds the lock. The callback waits for the lock, which will never be released.
Fix: Cancel Outside the Lock
1void StopProcessing()
2{
3 CancellationTokenSource localCts;
4 lock (_lock)
5 {
6 localCts = _cts;
7 _cts = null;
8 }
9 localCts?.Cancel(); // Cancel outside the lock — no deadlock
10}
Deadlock Scenario 2: UI Thread Synchronization Context
1// WPF or WinForms application
2private CancellationTokenSource _cts;
3
4async void StartButton_Click(object sender, EventArgs e)
5{
6 _cts = new CancellationTokenSource();
7
8 _cts.Token.Register(() =>
9 {
10 // This callback runs on the UI thread (because Cancel is called on UI thread)
11 // If it tries to update UI synchronously — fine
12 // If it tries to await something — potential issues
13 statusLabel.Text = "Cancelled";
14 });
15
16 try
17 {
18 await DoWorkAsync(_cts.Token);
19 }
20 catch (OperationCanceledException) { }
21}
22
23void CancelButton_Click(object sender, EventArgs e)
24{
25 _cts.Cancel(); // Runs callbacks synchronously on UI thread
26 // If a callback blocks, the UI freezes
27}
Fix: Use CancelAsync() (.NET 8+)
1// .NET 8+ provides CancelAsync() which does not block
2async void CancelButton_Click(object sender, EventArgs e)
3{
4 await _cts.CancelAsync(); // Callbacks run asynchronously
5}
Fix: Cancel on a Background Thread
1void CancelButton_Click(object sender, EventArgs e)
2{
3 Task.Run(() => _cts.Cancel()); // Cancel on thread pool — UI stays responsive
4}
Deadlock Scenario 3: Dispose During Active Callbacks
1var cts = new CancellationTokenSource();
2
3cts.Token.Register(() =>
4{
5 Thread.Sleep(5000); // Long-running callback
6});
7
8// Cancel and immediately dispose
9cts.Cancel(); // Starts executing callback (blocks for 5s)
10cts.Dispose(); // Called from another thread — may throw or hang
Dispose() waits for callbacks to complete. If called from a different thread while callbacks are running, it blocks until they finish.
Fix: Cancel and Dispose Sequentially
1try
2{
3 cts.Cancel();
4}
5catch (AggregateException ex)
6{
7 // Handle callback exceptions
8}
9finally
10{
11 cts.Dispose(); // Safe — callbacks are done
12}
Timeout-Based Cancellation Hang
1// CancellationTokenSource with timeout
2var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
3
4cts.Token.Register(() =>
5{
6 // This callback fires after 5 seconds on a TIMER thread
7 // If it blocks, the timer thread hangs
8 Thread.Sleep(10000); // Bad — blocks timer thread
9});
10
11// The timeout callback runs on a ThreadPool timer thread
12// Blocking it can starve the thread pool
Fix: Keep Callbacks Non-Blocking
1cts.Token.Register(() =>
2{
3 // Fire-and-forget async work
4 _ = Task.Run(async () =>
5 {
6 await CleanupResourcesAsync();
7 });
8});
Linked CancellationTokenSource
1var parentCts = new CancellationTokenSource();
2var childCts = CancellationTokenSource.CreateLinkedTokenSource(parentCts.Token);
3
4childCts.Token.Register(() => Console.WriteLine("Child cancelled"));
5parentCts.Token.Register(() => Console.WriteLine("Parent cancelled"));
6
7parentCts.Cancel();
8// Both callbacks execute — child is cancelled because parent was cancelled
9// If either callback blocks, Cancel() blocks
10
11// IMPORTANT: Always dispose linked CancellationTokenSources
12childCts.Dispose(); // Prevents memory leak from parent registration
Debugging a Hanging Cancel
1// Add logging to identify which callback hangs
2cts.Token.Register(() =>
3{
4 Console.WriteLine("Callback 1 starting");
5 // ... work ...
6 Console.WriteLine("Callback 1 done");
7});
8
9cts.Token.Register(() =>
10{
11 Console.WriteLine("Callback 2 starting");
12 // If you see "starting" but not "done", this callback is hanging
13});
14
15// Use CancellationTokenSource.TryReset() (.NET 6+) to reuse without recreating
16if (!cts.TryReset())
17{
18 cts.Dispose();
19 cts = new CancellationTokenSource();
20}
Common Pitfalls
Blocking in cancellation callbacks: Cancel() executes all registered callbacks synchronously on the calling thread. If a callback acquires a lock, waits on a task, or performs I/O, it blocks Cancel() and can deadlock the application. Keep callbacks lightweight and non-blocking.
Calling Cancel() while holding a lock: If any registered callback needs the same lock, you get a deadlock. Always call Cancel() outside of lock blocks by copying the CancellationTokenSource reference first.
Not disposing CancellationTokenSource: Undisposed CancellationTokenSource objects leak timer handles (if timeout-based) and linked token registrations. Always dispose, preferably in a finally block.
Not disposing linked CancellationTokenSource: CreateLinkedTokenSource registers a callback on the parent token. Without Dispose(), this registration is never removed, causing a memory leak that grows over time.
Ignoring AggregateException from Cancel(): If a registered callback throws an exception, Cancel() wraps it in an AggregateException and throws. Unhandled, this crashes the application. Always wrap Cancel() in try-catch if callbacks might throw.
Summary
Cancel() executes all registered callbacks synchronously on the calling thread — blocking callbacks hang Cancel()
Never call Cancel() while holding a lock that callbacks need — move Cancel() outside the lock
Use CancelAsync() (.NET 8+) or Task.Run(() => cts.Cancel()) to avoid blocking the UI thread
Keep cancellation callbacks lightweight — offload heavy work to Task.Run
Always dispose CancellationTokenSource, especially linked token sources, to prevent memory leaks
Wrap Cancel() in try-catch to handle exceptions from callbacks