Skip to main content

Cancellation token pooling

CancellationTokenSourcePool reuses sources that finish without being canceled. It removes their timers and registrations before reuse, eliminating the allocations normally made by CancellationTokenSource, CancelAfter, and CancellationToken.Register.

Choose the fastest method

Use the method that matches the source's lifetime:

ScenarioRecommended methodWhy
Synchronous scopeCancellationTokenSourcePool.Shared.RentScoped()Fastest pooled method; stack-only lease guarantees return.
Scope crosses awaitCancellationTokenSourcePool.Shared.Rent()Source can cross the async boundary and returns itself when disposed.
Caller cancellation plus a timeoutCancellationTokenSourcePool.Shared.RentLinked(callerToken)Links one upstream token without allocating a BCL linked source.
Source usually cancelsnew CancellationTokenSource()A canceled source cannot be reused, so pooling adds overhead.
No timer or registration, allocation is acceptablenew CancellationTokenSource()Lowest raw latency for a trivial source.
Repeated timers or registrationsPoolReuses their internal storage and avoids steady-state allocations.

RentScoped() is the most performant pooled API. It is intended for synchronous scopes and cannot live across an await. Rent() is the correct pooled API for async work. Plain construction remains faster when measuring only creation and disposal, but allocates on every operation.

Representative BenchmarkDotNet ShortRun results on .NET 10.0.11 and an Intel Core i7-12700K:

WorkloadnewPoolnew allocationPool allocation
Create/dispose2.85 ns16.53 ns48 B0 B
Create/dispose with scoped lease2.61 ns7.23 ns48 B0 B
Schedule unfired timer50.54 ns47.19 ns144 B0 B
Register callback27.53 ns28.74 ns192 B0 B
Cancel/dispose19.76 ns26.93 ns48 B56 B

Nanosecond timings vary by machine and runtime. Compare rows by workload: pooling optimizes allocation pressure and reusable timer/registration state, not every isolated operation. Under contention, relative latency also varies with worker count; benchmark your production-shaped workload when latency is critical. See Benchmarks to reproduce the suite.

In a same-run comparison, the thread-local scoped path reduced the scoped operation from 12.80 ns to 7.23 ns on .NET 10 (0.56 ratio), and from 18.57 ns to 14.83 ns on .NET 8 (0.80 ratio). All four cases allocated 0 B.

Async use

Rent the source directly when its lifetime crosses an await:

using CancellationTokenSource source = CancellationTokenSourcePool.Shared.Rent();
source.CancelAfter(TimeSpan.FromSeconds(30));
await ProcessAsync(source.Token);

The rented source is a specialized subtype. Calling Dispose() offers it back to its originating pool; the pool retains it only when reset succeeds. Dispose each rental exactly once and only after all work using its token has completed.

Linked async use

Use RentLinked() when a source must observe caller cancellation as well as its own timeout:

using CancellationTokenSource source =
CancellationTokenSourcePool.Shared.RentLinked(callerToken);

source.CancelAfter(TimeSpan.FromSeconds(30));
await ProcessAsync(source.Token);

The pooled subtype owns one registration on callerToken. Disposing the rental unregisters that callback—and waits for an in-flight callback to finish—before the source can return to the pool. An upstream token that cannot be canceled follows the normal Rent() path.

This provides linked behavior without pooling a BCL source created by CancellationTokenSource.CreateLinkedTokenSource. As with any canceled pooled source, upstream or timeout cancellation causes TryReset() to fail, so that rental is permanently disposed rather than retained.

Synchronous use

Use a scoped lease when the source never crosses an async boundary:

using var lease = CancellationTokenSourcePool.Shared.RentScoped(
out CancellationTokenSource source);

source.CancelAfter(TimeSpan.FromSeconds(5));
RunSynchronousWork(source.Token);

The lease owns the source. Do not also dispose source. RentScoped() returns a stack-only Lease, so it also prevents the rental from escaping to the heap. Scoped rentals retain one reset source per participating thread on the pool instance, then use the bounded shared store for nested rentals. Clear() and Dispose() permanently dispose sources retained in both tiers.

What can be reused

On return, the pool calls CancellationTokenSource.TryReset():

  • true: cancellation has not occurred; timers are disarmed, registrations are removed, and the source can be retained;
  • false: cancellation occurred or reset is unsafe; the source is permanently disposed and discarded.

If the runtime does not expose TryReset(), returned sources are permanently disposed instead. This can occur when a .NET Standard 2.0 consumer runs on an older runtime.

Pooling therefore works best for timeout or speculative-cancellation sources that usually complete before cancellation. It provides little benefit when cancellation is the normal outcome.

Linked sources created directly by CancellationTokenSource.CreateLinkedTokenSource are ordinary sources. They do not come from Reservoir and must be disposed normally.

Ownership and concurrency

Before disposing a rental or its lease, ensure there are:

  • no outstanding token users;
  • no concurrent Cancel or CancelAfter calls;
  • no concurrent registration or disposal operations.

RentLinked() synchronizes its owned upstream callback during disposal, so upstream cancellation may race disposal. Caller-initiated operations on the rented source still must finish first.

TryReset() is not thread-safe with concurrent source use. Disposal transfers ownership to the pool: do not access the source, its token, a registration, or another source alias afterward. These rules apply even when the pool itself is shared safely between threads.

Shared and dedicated pools

Use CancellationTokenSourcePool.Shared for most applications. Create a dedicated pool to isolate retention or set a workload-specific limit:

using var pool = new CancellationTokenSourcePool(maxCapacity: 32);

maxCapacity limits the bounded shared tier, not simultaneous rentals. Scoped use can additionally retain one source per participating thread. Clear() permanently disposes retained sources while leaving the pool usable. Dispose() drains and closes a dedicated pool; outstanding rentals are permanently disposed when returned. Dispose dedicated pools when finished so their thread-local retention is released.

Calling CancellationTokenSourcePool.Shared.Dispose() only clears retained sources. It deliberately does not close the process-wide shared pool.