15 minlesson

Linked Cancellation Tokens

Linked Cancellation Tokens

Sometimes you need to cancel an operation when any of multiple conditions occur - user cancellation, timeout, or application shutdown. Linked tokens combine multiple cancellation sources.

The Problem

Consider an HTTP request that should cancel if:

  1. User clicks "Cancel"
  2. Request takes longer than 30 seconds
  3. Application is shutting down

Without linked tokens, you'd need complex logic:

csharp
1// MESSY: Manual checking of multiple sources
2async Task FetchDataAsync(CancellationToken userToken, CancellationToken appToken)
3{
4 var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
5
6 while (hasMoreData)
7 {
8 // Check all three!
9 if (userToken.IsCancellationRequested ||
10 appToken.IsCancellationRequested ||
11 timeoutCts.Token.IsCancellationRequested)
12 {
13 throw new OperationCanceledException();
14 }
15
16 await FetchChunkAsync(/* which token to pass?? */);
17 }
18}

CreateLinkedTokenSource

Combine multiple tokens into one:

csharp
1async Task FetchDataAsync(CancellationToken userToken, CancellationToken appToken)
2{
3 using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
4
5 // Create linked token that cancels if ANY source cancels
6 using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
7 userToken,
8 appToken,
9 timeoutCts.Token
10 );
11
12 CancellationToken linkedToken = linkedCts.Token;
13
14 // Now just check/pass one token
15 await FetchChunkAsync(linkedToken);
16}

User Cancellation + Timeout

The most common use case:

csharp
1public async Task<string> FetchWithTimeoutAsync(
2 string url,
3 TimeSpan timeout,
4 CancellationToken userCancellation = default)
5{
6 using var timeoutCts = new CancellationTokenSource(timeout);
7 using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
8 userCancellation,
9 timeoutCts.Token);
10
11 try
12 {
13 return await httpClient.GetStringAsync(url, linkedCts.Token);
14 }
15 catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
16 {
17 throw new TimeoutException($"Request timed out after {timeout}");
18 }
19 // User cancellation passes through as OperationCanceledException
20}

Identifying Which Token Canceled

When cancellation occurs, you might need to know which source triggered it:

csharp
1async Task ProcessAsync(CancellationToken userToken)
2{
3 using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
4 using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
5 userToken,
6 timeoutCts.Token);
7
8 try
9 {
10 await DoWorkAsync(linkedCts.Token);
11 }
12 catch (OperationCanceledException)
13 {
14 if (timeoutCts.IsCancellationRequested)
15 {
16 Console.WriteLine("Operation timed out");
17 }
18 else if (userToken.IsCancellationRequested)
19 {
20 Console.WriteLine("User canceled the operation");
21 }
22 else
23 {
24 Console.WriteLine("Unknown cancellation source");
25 }
26
27 throw;
28 }
29}

Application Shutdown Pattern

Create a global shutdown token:

csharp
1public class AppLifetime
2{
3 private readonly CancellationTokenSource _shutdownCts = new();
4
5 public CancellationToken ShutdownToken => _shutdownCts.Token;
6
7 public void SignalShutdown()
8 {
9 _shutdownCts.Cancel();
10 }
11}
12
13// In services
14public class DataService
15{
16 private readonly AppLifetime _lifetime;
17
18 public async Task ProcessAsync(CancellationToken requestToken)
19 {
20 // Cancel on request cancellation OR app shutdown
21 using var linked = CancellationTokenSource.CreateLinkedTokenSource(
22 requestToken,
23 _lifetime.ShutdownToken);
24
25 await LongRunningWorkAsync(linked.Token);
26 }
27}

Nested Operations

Each layer can add its own timeout:

csharp
1// Outer operation: 60 second total timeout
2public async Task OuterOperationAsync(CancellationToken ct)
3{
4 using var outerTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
5 using var linkedOuter = CancellationTokenSource.CreateLinkedTokenSource(
6 ct, outerTimeout.Token);
7
8 await InnerOperationAsync(linkedOuter.Token);
9 await AnotherOperationAsync(linkedOuter.Token);
10}
11
12// Inner operation: 10 second per-call timeout
13private async Task InnerOperationAsync(CancellationToken ct)
14{
15 using var innerTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
16 using var linkedInner = CancellationTokenSource.CreateLinkedTokenSource(
17 ct, innerTimeout.Token);
18
19 await httpClient.GetAsync(url, linkedInner.Token);
20}

The inner operation cancels if:

  • User cancels (original ct)
  • Outer timeout (60s) expires
  • Inner timeout (10s) expires

Dynamic Linking

Add sources after creation:

csharp
1// Note: CreateLinkedTokenSource doesn't support adding more tokens later
2// Create a new linked source if you need to add more
3
4var sources = new List<CancellationToken> { userToken };
5
6if (useTimeout)
7{
8 var timeoutCts = new CancellationTokenSource(timeout);
9 sources.Add(timeoutCts.Token);
10}
11
12if (appShutdownToken != default)
13{
14 sources.Add(appShutdownToken);
15}
16
17using var linked = CancellationTokenSource.CreateLinkedTokenSource(
18 sources.ToArray());

Memory and Disposal

Always dispose linked token sources!

csharp
1// BAD: Memory leak
2var linked = CancellationTokenSource.CreateLinkedTokenSource(token1, token2);
3await DoWorkAsync(linked.Token);
4// linked is never disposed!
5
6// GOOD: Using statement
7using var linked = CancellationTokenSource.CreateLinkedTokenSource(token1, token2);
8await DoWorkAsync(linked.Token);

Common Pattern: Request with Timeout

csharp
1public static class CancellationExtensions
2{
3 public static CancellationTokenSource WithTimeout(
4 this CancellationToken token,
5 TimeSpan timeout)
6 {
7 var timeoutCts = new CancellationTokenSource(timeout);
8 var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
9 token,
10 timeoutCts.Token);
11
12 return linkedCts;
13 }
14}
15
16// Usage
17public async Task ProcessAsync(CancellationToken ct)
18{
19 using var cts = ct.WithTimeout(TimeSpan.FromSeconds(30));
20 await DoWorkAsync(cts.Token);
21}

Key Takeaways

  • CreateLinkedTokenSource combines multiple cancellation sources
  • The linked token cancels when any source cancels
  • Check individual tokens to determine which source triggered cancellation
  • Always dispose linked token sources
  • Common pattern: user cancellation + timeout
  • Useful for layered timeouts in nested operations
  • Great for combining request cancellation with app shutdown