CreateLinkedTokenSource, disposal to avoid leaks, telling which source fired, and combining request, shutdown and timeout cancellation.
CancellationTokenSource.CreateLinkedTokenSource(a, b, ...) creates a new source that is cancelled when any of the given tokens is cancelled, and can also be cancelled itself (for example with CancelAfter). It is how you combine "the caller cancelled" with "my own timeout" or "the host is shutting down". You must dispose the linked source, because it registers callbacks on the parent tokens; forgetting to do that on long-lived tokens leaks memory. To report the right error, check which source fired: if the caller's token is cancelled, rethrow cancellation; if only your timeout fired, throw a TimeoutException.
"
CancellationTokenSource.CreateLinkedTokenSource(a, b, ...)creates a new source that becomes cancelled when any of its parent tokens is cancelled, and that I can also cancel myself, typically withCancelAfter(timeout). That's how I combine 'the caller cancelled', 'the host is shutting down' and 'my own deadline' into the single token I pass downstream. There are three rules. First, link, don't replace: if I swap the caller's token for my own timeout token, the caller can no longer cancel. Second, always dispose the linked source, because it registers a callback on each parent; with a long-lived parent like the host's stopping token, undisposed links accumulate and leak memory. Third, say which deadline fired: if the caller's token is cancelled, I letOperationCanceledExceptionpropagate, but if only my timeout fired, I translate it to aTimeoutException, so callers and dashboards can tell 'I gave up' from 'the dependency was slow'."
public async Task<Price> GetPriceAsync(string sku, CancellationToken ct)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); // 1. link
linked.CancelAfter(_options.PricingTimeout); // 2. own deadline
try
{
return await _http.GetFromJsonAsync<Price>($"prices/{sku}", linked.Token) // 3. pass linked token
?? throw new InvalidOperationException("Empty response");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested) // 4. only OUR timeout fired
{
throw new TimeoutException($"Pricing for {sku} exceeded {_options.PricingTimeout}");
}
} // 5. dispose: unregister from ct
ct (caller) cancelled? |
linked cancelled? |
Meaning | Surface as |
|---|---|---|---|
| Yes | Yes | Caller cancelled (or both) | OperationCanceledException (caller's intent wins) |
| No | Yes | Our timeout | TimeoutException |
| No | No | Real failure | The original exception |
Check the caller's token first: if both fired, the caller's intent wins.
| Parent token lifetime | Undisposed linked source per operation | Result |
|---|---|---|
| Short (request token) | Registration dies with the request | Mostly harmless, still sloppy |
| Long (host stopping token, service-wide token) | Registration stays until the parent is cancelled or disposed | Memory leak: callback nodes plus captured state accumulate |
Timer from CancelAfter |
Timer stays scheduled until it fires | Extra timers |
| Combine | Purpose |
|---|---|
| Request token + per-call timeout | Stop slow dependencies and abandoned requests |
stoppingToken + per-message timeout |
Workers that bound each item's processing time |
| Several user actions (Cancel button, navigation, window close) | Cancel when any happens |
| Overall budget + per-attempt timeout (retries) | Total deadline across retries, plus a deadline per attempt |
| Tool | Behaviour |
|---|---|
task.WaitAsync(timeout, ct) (.NET 6+) |
Stops waiting; the operation continues |
HttpClient.Timeout |
Per-request overall timeout (throws TaskCanceledException with an inner TimeoutException) |
Microsoft.Extensions.Http.Resilience / Polly v8 timeout strategies |
Manage linked tokens, per-attempt and total timeouts for you |
TimeProvider (.NET 8+) |
Testable time: new CancellationTokenSource(timeout, timeProvider) + FakeTimeProvider |
Our [queue worker] created a linked source from the host's stoppingToken for every message, to give each message a [30-second] processing timeout, but never disposed it. Over about [three days] memory grew by [hundreds of MB] until the pod was OOM-killed. A dotnet-gcdump showed millions of CancellationTokenSource.CallbackNode objects rooted by the stopping token, one registration per processed message. Wrapping each linked source in using removed the registration after each message and fixed the leak. We also translated per-message timeouts into TimeoutException so they appeared as "slow dependency" in dashboards instead of being mixed with shutdown cancellations, and added a soak test plus an analyser rule requiring disposal of CancellationTokenSource.
Checks careful cancellation composition.
Green flags
CreateLinkedTokenSource with CancelAfter.WaitAsync does not cancel work.Red flags
CreateLinkedTokenSource(a, b):
linked = new CancellationTokenSource()
regA = a.UnsafeRegister(s => ((CTS)s).Cancel(), linked)
regB = b.UnsafeRegister(...)
(if any parent is already cancelled -> linked is cancelled immediately)
linked.Dispose():
regA.Dispose(); regB.Dispose() // removes callback nodes from a and b
dispose own timer (if CancelAfter was used)
Each link is one callback registration per parent. The parent holds a reference to the linked source until the registration is disposed, which is the leak mechanism.
ASP.NET Core request (RequestAborted)
└─ endpoint: total budget 2 s (linked: RequestAborted + 2 s)
└─ pricing call: 800 ms (linked: endpoint token + 800 ms)
└─ stock call: 800 ms (linked: endpoint token + 800 ms)
Each layer links to its parent's token, never to a fresh one, so cancellation still flows from the top. Inner timeouts should be shorter than outer budgets, otherwise the inner timeout never fires and the outer one masks which dependency was slow.
using var total = CancellationTokenSource.CreateLinkedTokenSource(ct);
total.CancelAfter(TimeSpan.FromSeconds(5));
for (int attempt = 1; attempt <= 3; attempt++)
{
using var perAttempt = CancellationTokenSource.CreateLinkedTokenSource(total.Token);
perAttempt.CancelAfter(TimeSpan.FromSeconds(1.5));
try { return await CallAsync(perAttempt.Token); }
catch (OperationCanceledException) when (!total.IsCancellationRequested) { /* attempt timed out; retry */ }
catch (HttpRequestException) when (attempt < 3) { await Task.Delay(200 * attempt, total.Token); }
}
In production I'd use the standard resilience handler instead of hand-writing this, but it shows the structure.
var time = new FakeTimeProvider();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5), time);
var task = service.DoAsync(cts.Token);
time.Advance(TimeSpan.FromSeconds(6)); // no real waiting
await Assert.ThrowsAsync<TimeoutException>(() => task);
The code under test must also use TimeProvider (e.g. Task.Delay(delay, timeProvider, ct)) for fully deterministic tests.
dotnet-counters: GC heap growing steadily.dotnet-gcdump / dumpheap -stat: many CallbackNode / CancellationTokenSource instances.gcroot on one: path through the long-lived parent token's callback list.| Version | Change |
|---|---|
| .NET Framework 4.0 | CreateLinkedTokenSource |
| .NET Framework 4.5 | CancelAfter |
| .NET Core 3.0 | UnsafeRegister (used internally for cheaper links) |
| .NET 6 | TryReset, WaitAsync |
| .NET 8 | TimeProvider, CTS constructor with TimeProvider, CancelAsync |
| .NET 10 | Same |
TimeoutException), second is cancelled by the caller.using and create 1,000,000 linked sources from one long-lived token; watch memory with dotnet-counters.CallbackNode objects.FakeTimeProvider so it runs instantly.WaitAsync(timeout) with a linked token by checking whether the underlying operation keeps running.using var callerCts = new CancellationTokenSource();
try
{
await PricingClient.GetPriceAsync("SKU-1", TimeSpan.FromMilliseconds(100), callerCts.Token);
}
catch (TimeoutException ex)
{
Console.WriteLine($"Timeout: {ex.Message}");
}
callerCts.CancelAfter(10);
try
{
await PricingClient.GetPriceAsync("SKU-1", TimeSpan.FromSeconds(5), callerCts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Caller cancelled");
}
public static class PricingClient
{
public static async Task<decimal> GetPriceAsync(string sku, TimeSpan timeout, CancellationToken ct)
{
// Dispose the linked source: it registers callbacks on ct.
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(timeout);
try
{
await Task.Delay(1000, linked.Token); // stands in for a slow HTTP call
return 42m;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Only our timeout fired: report it as a timeout, not a caller cancellation.
throw new TimeoutException($"Pricing for {sku} exceeded {timeout.TotalMilliseconds} ms");
}
}
}
What happens: var linked = CreateLinkedTokenSource(stoppingToken); per message.
Why it is wrong: registrations on the long-lived parent accumulate (memory leak).
Do instead: using var linked = ....
What happens: using var cts = new CancellationTokenSource(timeout); await CallAsync(cts.Token);.
Why it is wrong: the caller's cancellation no longer reaches the operation.
Do instead: link the caller's token with your timeout.
What happens: OperationCanceledException bubbles up for a slow dependency.
Why it is wrong: indistinguishable from a client disconnect; outages hide as "cancellations".
Do instead: translate to TimeoutException when only your timeout fired.
What happens: code checks linked.IsCancellationRequested to decide it's a timeout.
Why it is wrong: the linked token is also cancelled when the caller cancels.
Do instead: check the caller's token first: when (!ct.IsCancellationRequested).
What happens: per-call 5 s inside an endpoint budget of 2 s. Why it is wrong: the inner timeout never fires; diagnostics point to the wrong layer. Do instead: inner deadlines shorter than their parent's.
WaitAsync cancels the workWhat happens: await op.WaitAsync(TimeSpan.FromSeconds(2)) as the timeout strategy.
Why it is wrong: the operation keeps running and consuming resources.
Do instead: pass a linked token into the operation.
What happens: one linked source per item in a loop over millions of items.
Why it is wrong: registration and timer overhead.
Do instead: one deadline per batch, or TryReset with pooling.
What happens: CancelAfter(5000) scattered through the code.
Why it is wrong: can't tune per environment; hard to test.
Do instead: options/config plus TimeProvider.
What happens: catch (OperationCanceledException) { return default; } after a timeout.
Why it is wrong: callers believe the call succeeded with no data.
Do instead: throw TimeoutException or return an explicit failure result.
Thread.Sleep-based tests for timeoutsWhat happens: tests wait real seconds and are flaky on slow CI agents.
Why it is wrong: slow, nondeterministic suites.
Do instead: FakeTimeProvider.
Linked tokens are where cancellation design gets real: every layer may add its own deadline, and errors must say which deadline was hit. I keep the rule "link, dispose, and translate timeouts" and make timeouts configurable and testable with TimeProvider.
| Concern | Practice |
|---|---|
| Combining sources | CreateLinkedTokenSource |
| Lifetime | using on every linked source |
| Error semantics | TimeoutException for own timeouts |
| Testing | TimeProvider and FakeTimeProvider |
| HTTP calls | Resilience handler with attempt and total timeouts |
Cancellation in .NET is cooperative. A CancellationTokenSource owns the decision to cancel; it hands out CancellationTokens that code checks with IsCancellationRequested or ThrowIfCancellationRequested(), or passes to APIs that support it. When cancelled, the convention is to throw OperationCanceledException, which makes the task Canceled. Good APIs accept a CancellationToken as the last parameter (defaulting to default), pass it to every async call they make, and never swallow cancellation. In ASP.NET Core, a CancellationToken parameter is bound to HttpContext.RequestAborted, so client disconnects stop the work.
A timeout should cancel the work, not just stop waiting. Use a CancellationTokenSource with CancelAfter (linked to the caller's token) and pass it down. Task.WaitAsync(timeout) only stops waiting. For HTTP, HttpClient.Timeout is a single total timeout (100 seconds by default) that throws TaskCanceledException with an inner TimeoutException; in production I prefer AddStandardResilienceHandler, which sets attempt and total timeouts plus retries. In ASP.NET Core 8+, the request timeouts middleware cancels RequestAborted after a configured time. Timeouts must be layered: each inner timeout shorter than the outer one.
How to tell it (STAR)
Our [queue worker] slowly ran out of memory. A GC dump showed millions of cancellation callbacks attached to the host's stopping token. Each message created a linked source that was never disposed. Adding `using` fixed the leak, and we added a soak test to catch it in future.
Last updated 31 Oct 2026.