CancellationTokenSource
application hang
C# programming
threading issues
debugging techniques

Why CancellationTokenSource hangs an application

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

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

csharp
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

csharp
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

csharp
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

csharp
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+)

csharp
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

csharp
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

csharp
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

csharp
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

csharp
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

csharp
1cts.Token.Register(() =>
2{
3    // Fire-and-forget async work
4    _ = Task.Run(async () =>
5    {
6        await CleanupResourcesAsync();
7    });
8});

Linked CancellationTokenSource

csharp
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

csharp
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

Course illustration
Course illustration

All Rights Reserved.