Skip to content

Cancellation Tokens

Key Points

  • CancellationToken is the cooperative cancellation signal. It does not abort threads or kill operations — it just signals and the operation chooses to honor it.
  • Every async method that does any non-trivial work should accept a CancellationToken as the last parameter and propagate it to anything it calls.
  • token.ThrowIfCancellationRequested() is the canonical "give up here" call. Throws OperationCanceledException carrying the token.
  • CancellationTokenSource.CreateLinkedTokenSource(t1, t2, …) combines tokens. Useful for timeouts: var cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); cts.CancelAfter(timeout);.
  • token.Register(callback) runs callback when cancellation fires. Callbacks run synchronously by default — keep them short.
  • [EnumeratorCancellation] is the attribute on the CancellationToken parameter of an async iterator. The compiler links the parameter to the token threaded by WithCancellation() at the consumer.
  • Don't ignore OperationCanceledException — let it propagate. Catching Exception and swallowing cancellation is the #1 reason apps "don't shut down".

Concepts (deep dive)

The two halves: source and token

        Owner of cancellation                       Code that observes it
        ─────────────────────                       ─────────────────────
        CancellationTokenSource                     CancellationToken
        ┌─────────────────────┐                    ┌──────────────────┐
        │ Cancel()            │  ─── Token ──►     │ ThrowIfCancellationRequested() │
        │ CancelAfter(...)    │                    │ IsCancellationRequested        │
        │ Dispose()           │                    │ Register(callback)             │
        └─────────────────────┘                    └──────────────────────┘
  • CancellationTokenSource (CTS) owns the cancellation. Whoever creates it is responsible for Cancel() (or CancelAfter) and Dispose().
  • CancellationToken is a struct — a cheap value-type handle to the source. Pass these by value freely.
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));
await DoAsync(cts.Token);

The "give up" call

public async Task DoAsync(CancellationToken ct)
{
    while (true)
    {
        ct.ThrowIfCancellationRequested();   // stop here if canceled
        await StepAsync(ct);                  // propagate the token
    }
}

ThrowIfCancellationRequested throws OperationCanceledException carrying the token (so the caller can correlate which token triggered, if multiple are in play).

The cooperative contract: every level of the call stack threads the token, and at well-chosen points (between work units), each level checks. The runtime cannot interrupt you mid-CPU-work. If you're crunching a tight numeric loop with no awaits, you must check the token periodically yourself.

IsCancellationRequested — when to check vs throw

public async Task DrainAsync(CancellationToken ct)
{
    while (await _channel.Reader.WaitToReadAsync(ct))
    {
        if (ct.IsCancellationRequested) break;     // graceful exit
        var item = _channel.Reader.TryRead(out var x) ? x : default;
        if (x is not null) Process(x);
    }
}

Use ThrowIfCancellationRequested() when the caller expects "give up + propagate exception" semantics (most async code). Use IsCancellationRequested for graceful exit where you need cleanup logic that throwing wouldn't run.

Linked tokens — combining sources

public async Task<Result> CallAsync(CancellationToken externalCt)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
    cts.CancelAfter(TimeSpan.FromSeconds(30));    // 30s timeout OR external cancel — whichever first

    try
    {
        return await _client.GetAsync(_url, cts.Token);
    }
    catch (OperationCanceledException) when (externalCt.IsCancellationRequested)
    {
        // distinguish "external cancel" from "timeout"
        throw;
    }
    catch (OperationCanceledException)
    {
        return Result.Timeout();
    }
}

Key points:

  1. using is mandatory on the linked CTS or you leak a registration into the parent token.
  2. Linked tokens cancel when ANY source cancels — not all.
  3. The when (externalCt.IsCancellationRequested) filter lets you tell timeout from external cancel.

💡 Senior insight: linked tokens are not free. Each registers a callback on every source token. Creating thousands of linked tokens per second on a long-lived parent token can become a bottleneck. The pattern's fine for normal API calls; profile if it's in a tight loop.

Register — callback on cancel

using var cts = new CancellationTokenSource();
var registration = cts.Token.Register(() => Console.WriteLine("canceled"));
// later:
registration.Dispose();   // unsubscribe (don't leak)

Callbacks fire synchronously on the thread calling Cancel() by default. Keep them short. Throwing from a callback bubbles into the caller of Cancel() (or surfaces aggregated into an AggregateException).

For long callbacks, kick off work asynchronously:

var registration = cts.Token.Register(() =>
{
    _ = Task.Run(() => DoLongCleanup());
});

[EnumeratorCancellation] for async iterators

public async IAsyncEnumerable<Item> ReadAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    while (!ct.IsCancellationRequested)
    {
        var batch = await _store.NextAsync(ct);
        foreach (var i in batch) yield return i;
    }
}

// Consumer:
await foreach (var item in svc.ReadAsync().WithCancellation(externalCt))
{
    /* ... */
}

[EnumeratorCancellation] (System.Runtime.CompilerServices) tells the compiler: "the token I should use for WithCancellation flows into this parameter". Without the attribute, the iterator gets the default default(CancellationToken) and WithCancellation does nothing.

CancellationToken.None and default(CancellationToken)

These are equivalent: a token with no source, that never cancels. Use as a sentinel:

public Task DoAsync(CancellationToken ct = default)   // default = CancellationToken.None

Idiomatic shutdown patterns

Background service:

public class Worker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try { await DoOneCycleAsync(stoppingToken); }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
            catch (Exception ex) { _log.LogError(ex, "cycle failed; will retry"); }
        }
    }
}

HTTP request scope:

[HttpGet("/items")]
public async Task<IResult> Get(CancellationToken ct)   // bound from HttpContext.RequestAborted
{
    var items = await _repo.SearchAsync(ct);
    return TypedResults.Ok(items);
}

ASP.NET Core wires HttpContext.RequestAborted to the parameter named cancellationToken (or any CancellationToken argument). The token cancels when the client disconnects.

Cancellation in CPU-bound loops

public int Compute(int[] data, CancellationToken ct)
{
    int sum = 0;
    for (int i = 0; i < data.Length; i++)
    {
        if ((i & 0x3FF) == 0)              // every ~1024 iterations
            ct.ThrowIfCancellationRequested();
        sum += Heavy(data[i]);
    }
    return sum;
}

For tight CPU loops, checking the token every iteration is too expensive. Check periodically — every N iterations, every M ms, or at logical chunk boundaries.

⚠️ Task.Run does NOT propagate the token. If you do Task.Run(() => Compute(data)), the compute method has no token. You must thread it: Task.Run(() => Compute(data, ct), ct).


How it works under the hood

Internally, CancellationTokenSource holds an atomically-mutable state plus a list of registered callbacks. Cancel() flips the state and walks the callback list, invoking each.

The CancellationToken struct is just a reference to the source plus a few bits. IsCancellationRequested reads a single field. ThrowIfCancellationRequested checks the field and throws. Both are inlinable and effectively free.

The race condition: a callback could be registering at the same moment Cancel() is firing. The implementation guarantees that registered-before-cancel callbacks run, and registered-after-cancel callbacks run immediately on register (synchronously).

Linked tokens internally register a "fire when source fires" callback on each source. Disposing the linked CTS unregisters them. This is why you must using linked tokens — otherwise the long-lived parent token holds references to your linked tokens forever (a slow leak).

OperationCanceledException carries the token so callers can distinguish whose cancellation fired:

catch (OperationCanceledException ex) when (ex.CancellationToken == myToken)
{
    // my token canceled
}

In .NET, Task.Delay(timeout, ct) is implemented to throw OperationCanceledException (a subtype, TaskCanceledException) carrying ct when the token cancels — never aborts the timer thread.


Code: correct vs wrong

❌ Wrong: ignoring the token

public async Task DoAsync(CancellationToken ct)
{
    var data = await _http.GetStringAsync(_url);   // ❌ token not propagated
    return Process(data);
}

✅ Correct: propagate every level

public async Task<string> DoAsync(CancellationToken ct)
{
    var data = await _http.GetStringAsync(_url, ct);
    return Process(data);
}

❌ Wrong: catching Exception and swallowing cancellation

try { await DoAsync(ct); }
catch (Exception ex) { _log.LogError(ex); }   // ❌ swallows OperationCanceledException

✅ Correct: let cancellation propagate

try { await DoAsync(ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex) { _log.LogError(ex); throw; }

❌ Wrong: leaking linked CTS

public async Task FooAsync(CancellationToken ct)
{
    var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
    linked.CancelAfter(timeout);
    await BarAsync(linked.Token);
    // ❌ no Dispose; registration on parent token persists
}

✅ Correct: using

public async Task FooAsync(CancellationToken ct)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
    linked.CancelAfter(timeout);
    await BarAsync(linked.Token);
}

❌ Wrong: async iterator with token but no [EnumeratorCancellation]

public async IAsyncEnumerable<int> ReadAsync(CancellationToken ct)
{
    /* ... */
}

// Consumer:
await foreach (var x in svc.ReadAsync().WithCancellation(externalCt))
{ /* externalCt is ignored — no link to ct parameter */ }

✅ Correct: annotate the parameter

public async IAsyncEnumerable<int> ReadAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    /* ... */
}

❌ Wrong: Task.Run without passing token

await Task.Run(() => Compute(data));   // ❌ if external token cancels, the running compute keeps going

✅ Correct: pass the token to Task.Run AND to the function

await Task.Run(() => Compute(data, ct), ct);

The first ct lets the compute method see it. The second ct to Task.Run cancels the scheduling if the token is already canceled before Run starts.


Design patterns for this topic

Pattern 1 — "Token last, optional with default"

  • Intent: consistent API shape; callers can omit when they have no token.
  • Code sketch: Task<T> DoAsync(arg1, arg2, CancellationToken ct = default);
  • Trade-offs: none — this is the convention.

Pattern 2 — "Linked token for timeouts"

  • Intent: combine an external cancel signal with a timeout.
  • Code sketch:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
cts.CancelAfter(timeout);
await operation(cts.Token);
  • Trade-offs: using is essential. Tracking which side fired requires the inner-when filter pattern.

Pattern 3 — "Periodic check in CPU loops"

  • Intent: allow cancellation in a tight non-async compute.
  • Code sketch: see "CPU-bound loops" above.

Pattern 4 — "Graceful drain in background services"

  • Intent: finish in-flight work, then exit on shutdown.
  • Code sketch:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        try { await ProcessOneAsync(stoppingToken); }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
    }
    // Optional: finish any pending work with a generous timeout
    using var drain = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    await DrainPendingAsync(drain.Token);
}

Pattern 5 — "Token registration with deferred cleanup"

  • Intent: trigger cleanup on cancel, regardless of where the operation is.
  • Code sketch:
var registration = ct.Register(() => _heartbeat.Stop());
try
{
    await operation(ct);
}
finally
{
    registration.Dispose();   // unsubscribe
}

Pros & cons / trade-offs

Approach Pros Cons
Cooperative cancellation Predictable; graceful cleanup Requires plumbing through every method
ThrowIfCancellationRequested Idiomatic; bubbles cleanly Throws on cancel — slight overhead in cancel path
IsCancellationRequested check Explicit; allows graceful return Caller must remember to check
Linked tokens Compose cancellation Must using; small overhead
Register callback Reactive cleanup Sync by default; can deadlock

When to use / when to avoid

  • Always accept CancellationToken in async methods that do real work.
  • Always propagate the token to every call.
  • Use ThrowIfCancellationRequested by default; reach for IsCancellationRequested only when graceful-shutdown logic requires it.
  • Avoid CancellationTokenSource per call in very hot paths — the allocation adds up. Pass through existing tokens.
  • Avoid swallowing OperationCanceledException — let it propagate or filter it explicitly.

Interview Q&A

Q1. What's the difference between CancellationToken.None and default(CancellationToken)? None. CancellationToken.None is default(CancellationToken). It's a token without a source and never cancels. Used as a sentinel.

Q2. What does ThrowIfCancellationRequested actually do? Checks IsCancellationRequested. If true, throws OperationCanceledException with the token attached. If false, returns immediately. Inlinable and effectively free in the non-cancel case.

Q3. Why must you using a linked CTS? Because CreateLinkedTokenSource registers callbacks on the parent tokens. Without Dispose, the registrations live as long as the parent — a slow memory leak.

Q4. What's [EnumeratorCancellation]? Attribute on the CancellationToken parameter of an async iterator (async IAsyncEnumerable<T>). The compiler links the parameter to the token threaded by WithCancellation() at the consumer side. Without it, WithCancellation is silently a no-op.

Q5. How does HttpContext.RequestAborted relate to your controller's CancellationToken parameter? ASP.NET Core's model binder wires HttpContext.RequestAborted to a CancellationToken parameter on the action. When the client disconnects, the token cancels. You then need to honor it in your service calls.

Q6. Do Task.Delay and Task.WhenAll honor cancellation? Task.Delay(t, ct) does — pass a token, it'll throw on cancel. Task.WhenAll(tasks) — the returned task completes when all complete or one faults; if you want cancel-aware behavior, link the token through the contributing tasks.

Q7. What's the difference between OperationCanceledException and TaskCanceledException? TaskCanceledException is a subclass. Task.WaitAsync(timeout, ct) and similar throw TaskCanceledException. Token-cancellation generally throws OperationCanceledException. Catch the parent to handle both.

Q8. Can you cancel a synchronous (CPU-bound) loop? Cooperatively — by checking ct.IsCancellationRequested periodically. The runtime cannot interrupt CPU work. For uninterruptible code paths, you have to design check points in.

Q9. Why pass the token both to Task.Run and to the function inside? The first lets Task.Run cancel the scheduling if the token is already canceled. The second is what the actual work observes during execution. Without the second, the function has no way to honor cancellation.

Q10. How does CancellationTokenSource.CancelAfter work under the hood? It schedules a timer. When the timer fires, it calls Cancel() on the source. Disposing the CTS cancels the timer.

Q11. What's CreateLinkedTokenSource cost in a hot path? Each linked source registers a callback on each parent token. Both the registration and the callback firing are CAS operations on a list. Fine for "few per second" patterns; profile if you're creating thousands per second.

Q12. Why can Token.Register callbacks be dangerous? They run synchronously on the thread that called Cancel(). A long callback delays cancellation propagation to others. A throwing callback either propagates to Cancel() caller or is aggregated. Keep them short and pure.

Q13. How would you implement a timeout on top of a token-aware async API? CreateLinkedTokenSource + CancelAfter. Inside, use the linked token. Catch OperationCanceledException and check whether the original token canceled (real cancel) or the timeout fired.


Gotchas / common mistakes

  • ⚠️ Forgetting to propagate the token — a leaf method ignoring ct blocks shutdown.
  • ⚠️ Catching and swallowing OperationCanceledException — your service "won't shut down".
  • ⚠️ Not using a linked CTS — slow leak.
  • ⚠️ Task.Run without token — the running task survives external cancel.
  • ⚠️ Checking IsCancellationRequested only at the top of a loop — long-running iteration body is not interruptible.
  • ⚠️ async IAsyncEnumerable without [EnumeratorCancellation]WithCancellation is silently broken.
  • ⚠️ Heavy work in Token.Register — synchronous; blocks Cancel().
  • ⚠️ Disposing CTS while another thread is Cancel()-ing it — race; modern impl is more tolerant but don't.

Further reading