C# · .NET 10Beginner → AdvancedInteractive

CancellationToken in C#

Every long-running operation needs an off switch. In .NET that switch is the CancellationToken: a small, cheap value you pass down your call chain so work can stop early, cleanly and on purpose.

Press Cancel() and watch the signal travel to every task that was handed the token. Notice the one task that was never given a token: it keeps running.

All five tasks are running. Four of them were given cts.Token.

01

Why cancellation exists

Users close tabs, timeouts expire, servers shut down. When nobody wants the result any more, continuing to work wastes CPU, database connections, memory and money.

.NET uses cooperative cancellation. One side requests cancellation; the code doing the work notices the request and stops at a safe point. Nothing is killed from the outside. The old way, Thread.Abort, could stop a thread in the middle of anything (half-written files, held locks) and it is not supported at all on modern .NET: calling it throws PlatformNotSupportedException.

A real-world picture: the restaurant ticket

The customer
owns the order and is the only one who can say “cancel it”. That is the CancellationTokenSource.
The ticket
is clipped above every station: grill, fryer, dessert. Cooks can look at it but can’t change it. That is the CancellationToken.
Checking the ticket
a cook glances up between steps and stops before plating. That is polling IsCancellationRequested or calling ThrowIfCancellationRequested().
The buzzer
a pager that goes off when the order is cancelled, so the fryer can be switched off immediately. That is token.Register(callback).
“Order cancelled” note
the cook reports back that the dish was not finished on purpose. That is OperationCanceledException.

Why “cooperative” is a feature

  • Safe stopping points. Your code decides where it is safe to stop, so data is never left half-written.
  • Separation of powers. Code that receives a token can only listen. Only the owner of the source can cancel, so a library can’t cancel your other work.
  • One signal, many listeners. One token can be handed to 50 operations; one Cancel() reaches them all.
  • Cheap. CancellationToken is a small struct. Passing it around costs almost nothing.
  • Built into everything. HttpClient, EF Core, streams, Task.Delay, channels, SemaphoreSlim and ASP.NET Core all accept it.

The four types you will meet

CancellationTokenSource

A class you create and own. It can request cancellation (Cancel, CancelAsync, CancelAfter), hands out tokens via .Token, and must be disposed.

CancellationToken

A read-only struct you pass down. It can only observe: IsCancellationRequested, ThrowIfCancellationRequested(), Register(), WaitHandle.

CancellationTokenRegistration

A struct returned by Register(). Dispose it to stop listening, otherwise the callback (and anything it captures) stays alive as long as the source.

OperationCanceledException

The standard way to say “I stopped because I was asked to”. TaskCanceledException derives from it. Catch the base type.

02

Your first cancellable code

A console app downloads pages one by one. The user can press C to stop. Step through it line by line and watch the source, the token and the console.

using var cts = new CancellationTokenSource();
Task work = DownloadAllAsync(urls, cts.Token);

Console.WriteLine("Press C to cancel");
if (Console.ReadKey(true).Key == ConsoleKey.C)
    await cts.CancelAsync();

try
{
    await work;
    Console.WriteLine("All done");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Canceled by user");
}

static async Task DownloadAllAsync(string[] urls, CancellationToken ct)
{
    using var http = new HttpClient();
    foreach (var url in urls)
    {
        ct.ThrowIfCancellationRequested();
        string html = await http.GetStringAsync(url, ct);
        await File.WriteAllTextAsync(NameFor(url), html, ct);
    }
}

1 / 1

The four golden rules from this example

  1. Whoever creates the source owns it: they cancel it and they dispose it (using var).
  2. Pass the token, not the source. Methods take a CancellationToken as their last parameter.
  3. Forward the token to every async call that accepts one. That is what makes the stop fast.
  4. Catch OperationCanceledException at the top, where you decide what “canceled” means for the user. Don’t treat it as an error.
03

Lifecycle lab: play with a real source

This is a faithful model of how a CancellationTokenSource behaves. Try odd sequences: register after cancel, reset after cancel, cancel after dispose.

Call a member

// press a button

State

Registered callbacks

What to notice A source can be canceled once; it never goes back. Register on an already-canceled token runs the callback immediately. TryReset only works if cancellation never happened, and it drops old registrations. After Dispose, calling Cancel or CancelAfter throws ObjectDisposedException.
04

Three ways to listen for cancellation

A token is useless unless someone looks at it. There are three ways to observe it, and real code mixes them.

1 · Pass it on

Best and most common. Give the token to an API that already knows how to stop: HttpClient, EF Core, Stream.ReadAsync, Task.Delay, Channel, SemaphoreSlim.WaitAsync, Process.WaitForExitAsync…

await Task.Delay(TimeSpan.FromSeconds(5), ct);
var user = await db.Users.FindAsync([id], ct);
await stream.CopyToAsync(output, ct);

2 · Poll it

For your own loops and CPU-bound work. ThrowIfCancellationRequested() throws; IsCancellationRequested lets you clean up or return a partial result first.

foreach (var row in rows)
{
    ct.ThrowIfCancellationRequested();
    Crunch(row);
}

if (ct.IsCancellationRequested)
    return Partial(resultsSoFar);

3 · Register a callback

To bridge into APIs that don’t take a token: close a socket, kill a process, cancel a legacy operation. Always dispose the registration.

using var reg = ct.Register(() => process.Kill(entireProcessTree: true));
await legacyApi.WaitUntilDoneAsync();

// sync code that must block:
ct.WaitHandle.WaitOne(TimeSpan.FromSeconds(10));

Simulator: how often should you check?

A worker processes 30 items of CPU work, each taking about 180 ms. Start it, press Cancel part-way through, and compare how quickly each strategy actually stops.


    
done before canceldone after cancel (wasted)stopped mid-itemrunning

Rule of thumb Check often enough that the stop delay is small (tens of milliseconds for UI and requests), but not so often that the check dominates tight math loops. Checking IsCancellationRequested is a cheap field read, so once per item is usually fine.
05

Full API reference

Every public member you will use, with the version it arrived in where it matters.

CancellationTokenSource class · IDisposable

MemberWhat it does
new CancellationTokenSource()A source that cancels only when you tell it to.
new CancellationTokenSource(TimeSpan) / (int ms)Cancels itself after the delay. Same as calling CancelAfter right away.
new CancellationTokenSource(TimeSpan, TimeProvider) .NET 8Delay measured by a TimeProvider, so tests can fake time.
TokenThe CancellationToken to hand out. Every read returns an equal token.
IsCancellationRequestedtrue once cancellation has been requested. Never goes back to false.
Cancel()Requests cancellation and runs all registered callbacks synchronously on the calling thread. Callback exceptions are collected into an AggregateException.
Cancel(bool throwOnFirstException)With true, the first callback exception propagates immediately and remaining callbacks are not run.
CancelAsync() .NET 8The token flips to canceled immediately, but callbacks run asynchronously; the returned Task completes when they have finished. Prefer it in async code (analyzer CA1849 suggests it).
CancelAfter(TimeSpan) / (int ms)Schedules cancellation on a timer. Calling it again reschedules. No effect if already canceled.
CreateLinkedTokenSource(t1, t2, …)New source that is canceled when any input token is canceled (or when you cancel it yourself). Overloads take one, two, an array, or a ReadOnlySpan<CancellationToken> in newer versions.
TryReset() .NET 6Reuse a source that was not canceled: returns false if it was. On success, pending CancelAfter timers are cleared and old registrations are no longer notified. For the sole owner only; not thread-safe against a concurrent cancel.
Dispose()Releases the timer and wait handle and unlinks from parents (for linked sources). Does not cancel. Using Cancel/CancelAfter/Token afterwards throws ObjectDisposedException.

CancellationToken readonly struct

MemberWhat it does
CancellationToken.None / defaultA token that can never be canceled. They are equal. CanBeCanceled is false, and Register does nothing.
new CancellationToken(bool canceled)A token fixed in one state. new CancellationToken(true) is handy in unit tests.
IsCancellationRequestedPoll the state. Very cheap.
CanBeCanceledfalse for None. Libraries use it to skip registration work.
ThrowIfCancellationRequested()Throws OperationCanceledException (carrying this token) if canceled; otherwise does nothing.
Register(Action)Runs the callback when canceled (immediately if already canceled). Returns a CancellationTokenRegistration. Captures the ExecutionContext.
Register(Action<object?>, object? state)Pass state instead of capturing variables, which avoids a closure allocation.
Register(Action<object?, CancellationToken>, object?) .NET 6The callback also receives the token that was canceled.
Register(…, bool useSynchronizationContext)Runs the callback on the captured SynchronizationContext (e.g. the UI thread).
UnsafeRegister(…)Like Register but does not capture/flow ExecutionContext. For low-level libraries.
WaitHandleA WaitHandle that is signaled on cancellation, for blocking or legacy APIs (WaitHandle.WaitAny). Allocated lazily.
Equals, ==, !=Two tokens are equal when they come from the same source.

CancellationTokenRegistration struct · IDisposable · IAsyncDisposable

MemberWhat it does
Dispose()Unregisters. If the callback is running right now on another thread, it waits for it to finish.
DisposeAsync()Same, but waits asynchronously.
Unregister()Unregisters without waiting; returns true if the callback was removed before it ran.
TokenThe token this registration belongs to.

Related types & helpers

APIWhy it matters
OperationCanceledExceptionThe base exception for cancellation. Its CancellationToken property tells you which token fired.
TaskCanceledExceptionDerives from OperationCanceledException; thrown by Task.Delay, HttpClient and awaiting canceled tasks.
Task.FromCanceled(ct)Returns an already-canceled task (token must be canceled). Also ValueTask.FromCanceled.
Task.WaitAsync(ct) / (TimeSpan) .NET 6Stops waiting for a task. Does not stop the task itself.
ParallelOptions.CancellationTokenCancels Parallel.For/ForEach/ForEachAsync.
[EnumeratorCancellation]Connects WithCancellation(ct) to your async iterator’s token parameter.
IHostApplicationLifetimeApplicationStarted, ApplicationStopping, ApplicationStopped tokens for the whole app.
HttpContext.RequestAbortedCanceled when the client disconnects (or a request timeout fires).
Analyzer CA2016Flags calls where you forgot to forward your CancellationToken, and offers a fix.
06

Linked tokens: many reasons to stop

Real operations have several “off switches” at once: the user, a deadline, and the app shutting down. CreateLinkedTokenSource merges them into one token.

requestAbortedClient disconnects
timeoutCts.TokenDeadline 5.0 s
ApplicationStoppingServer shutting down
linked.TokenCanceled if any parent is canceled
ExportAsync(linked.Token)Idle

Start the export, then fire any of the three parents, or just wait for the deadline.

app.MapGet("/export", async (ExportService exporter, IHostApplicationLifetime lifetime,
                             CancellationToken requestAborted) =>
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        requestAborted, timeoutCts.Token, lifetime.ApplicationStopping);

    try
    {
        var csv = await exporter.RunAsync(linked.Token);
        return Results.File(csv, "text/csv");
    }
    catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
    {
        return Results.StatusCode(StatusCodes.Status504GatewayTimeout);
    }
    catch (OperationCanceledException) when (lifetime.ApplicationStopping.IsCancellationRequested)
    {
        return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
    }
    // requestAborted: let it bubble up - nobody is listening for a response
});
Which parent fired? The linked token only says “canceled”. Check each parent with when (parent.IsCancellationRequested) filters to decide the outcome: retry, 504, 503 or silence.
Always dispose a linked source It registers a callback on every parent. If a parent lives long (like ApplicationStopping or a service’s stoppingToken), forgetting Dispose leaks one registration per call, forever.
07

Timeouts & time

A timeout is just cancellation triggered by a clock. There are several ways to set one, and one important trap.

Ways to create a deadline

// 1. Constructor: starts the clock now
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

// 2. CancelAfter: start or move the deadline later
cts.CancelAfter(TimeSpan.FromSeconds(30));

// 3. Combine with a caller's token
using var linked = CancellationTokenSource.CreateLinkedTokenSource(callerToken);
linked.CancelAfter(TimeSpan.FromSeconds(10));

// 4. Testable time (.NET 8+)
using var testable = new CancellationTokenSource(TimeSpan.FromSeconds(10), timeProvider);

Testing timeouts without waiting

Inject TimeProvider and use FakeTimeProvider (package Microsoft.Extensions.TimeProvider.Testing) to jump the clock forward instantly.

var time = new FakeTimeProvider();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30), time);

Assert.False(cts.IsCancellationRequested);
time.Advance(TimeSpan.FromSeconds(31));   // no real waiting
Assert.True(cts.IsCancellationRequested);

Task.Delay(TimeSpan, TimeProvider, CancellationToken) and PeriodicTimer accept a TimeProvider too.

Simulator: WaitAsync stops waiting, not working

task.WaitAsync(TimeSpan.FromSeconds(3)) throws a TimeoutException after 3 seconds, but the underlying task keeps running. Run it, then turn on “also pass the token”.

Your await
The actual work (6 s)


  

HttpClient: timeout or cancel?

Both look like a TaskCanceledException. On .NET 5 and later, an HttpClient.Timeout (default 100 seconds) puts a TimeoutException in InnerException, so you can tell them apart:

try
{
    return await http.GetFromJsonAsync<Forecast>("/forecast", ct);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    logger.LogWarning("Weather API timed out");     // HttpClient.Timeout fired
    return Forecast.Unavailable;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    throw;                                           // our caller canceled: just stop
}
Resilience pipelines With Microsoft.Extensions.Http.Resilience (AddStandardResilienceHandler) the pipeline’s own timeouts surface as Polly’s TimeoutRejectedException, a separate exception you catch on its own. Polly passes your token through: await pipeline.ExecuteAsync(async token => await CallAsync(token), ct);
08

Exceptions & task states

Cancellation is reported with exceptions, but it is not an error. Knowing how it travels through tasks avoids noisy logs and wrong HTTP status codes.

The family tree

Exception
 └─ SystemException
     └─ OperationCanceledException      // catch THIS one
         └─ TaskCanceledException       // Task.Delay, HttpClient, awaited canceled tasks

Some APIs throw the base type, some throw the derived one. catch (TaskCanceledException) misses the base type, so always catch OperationCanceledException.

Final task states

StatusHow you get there
RanToCompletionFinished normally, including when your code saw the token and chose to return early.
CanceledAn async method let an OperationCanceledException escape, or Task.Run(…, ct) never started because ct was already canceled.
FaultedAny other exception escaped.

Patterns for catching

// ✔ Only treat it as "canceled" if OUR token asked for it
try
{
    await ProcessAsync(order, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    logger.LogInformation("Order {Id} processing canceled", order.Id);
    throw;            // usually rethrow so callers know it did not complete
}

// ✔ When you catch everything, let cancellation pass through first
try { await ProcessAsync(order, ct); }
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
    logger.LogError(ex, "Order {Id} failed", order.Id);
}

// ✔ Task.WhenAll: canceled if any task was canceled and none faulted
try { await Task.WhenAll(tasks); }
catch (OperationCanceledException) { /* all-or-nothing cancel */ }

// ✘ .Wait() / .Result wrap it: AggregateException -> TaskCanceledException
Why the when filter matters An OperationCanceledException can come from a token you don’t own, for example a library’s internal timeout. Without the filter you would report “the user canceled” when actually something timed out.

Task.Run(work, token): what the token actually does

The token passed to Task.Run only stops the task from starting if it is already canceled. Once the delegate runs, nothing stops it unless the delegate itself observes the token. It also lets the task end as Canceled (instead of Faulted) when the delegate throws an OperationCanceledException for that same token.

await Task.Run(() =>
{
    foreach (var tile in tiles)
    {
        ct.ThrowIfCancellationRequested();   // ← this is what really stops it
        Render(tile);
    }
}, ct);                                       // ← this only prevents a late start
09

Scenario: ASP.NET Core APIs

Every request gets its own token. Add a CancellationToken parameter to a controller action or a minimal API handler and ASP.NET Core binds it to HttpContext.RequestAborted, which is canceled when the client disconnects.

app.MapGet("/reports/{id:int}", async (int id, ReportService svc, CancellationToken ct) =>
{
    var report = await svc.BuildAsync(id, ct);
    return Results.Ok(report);
});

public async Task<Report> BuildAsync(int id, CancellationToken ct)
{
    var rows = await _db.Sales
        .Where(s => s.ReportId == id)
        .ToListAsync(ct);

    var rates = await _http.GetFromJsonAsync<Rates>("/fx/today", ct);

    ct.ThrowIfCancellationRequested();
    return Compose(rows, rates);
}

1 / 1

Controllers work exactly the same way

[HttpGet("{id:int}")]
public async Task<ActionResult<Report>> Get(int id, CancellationToken cancellationToken)
    => await _reports.BuildAsync(id, cancellationToken);   // bound to RequestAborted

Server-side request timeouts .NET 8+

The request timeouts middleware cancels RequestAborted when an endpoint runs too long, so the same token handles both “client left” and “we gave up”. By default it answers with 504. Timeouts are not enforced while you are debugging.

builder.Services.AddRequestTimeouts(o =>
    o.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(10) });

app.UseRequestTimeouts();

app.MapGet("/slow", async (CancellationToken ct) => { /* ... */ })
   .WithRequestTimeout(TimeSpan.FromSeconds(2));

The point of no return

Cancellation must never leave things half done. Once you have charged the card, finish the bookkeeping even if the client disconnects:

public async Task<IResult> Checkout(Cart cart, CancellationToken ct)
{
    var quote = await _pricing.QuoteAsync(cart, ct);      // safe to cancel
    await _inventory.ReserveAsync(cart, ct);              // safe: reservation expires

    var payment = await _payments.ChargeAsync(quote, CancellationToken.None);
    // ── point of no return: money moved, we MUST record it ──
    _db.Orders.Add(Order.From(cart, payment));
    await _db.SaveChangesAsync(CancellationToken.None);

    return Results.Ok(payment.Receipt);
}

More real-world cases

Search-as-you-type

Each keystroke cancels the previous request so only the latest result is shown, and the server stops working on stale queries.

private CancellationTokenSource? _searchCts;

public async Task OnQueryChangedAsync(string q)
{
    if (_searchCts is not null)
    {
        await _searchCts.CancelAsync();
        _searchCts.Dispose();
    }
    _searchCts = new CancellationTokenSource();
    var ct = _searchCts.Token;
    try
    {
        await Task.Delay(250, ct);                       // debounce
        Results = await _api.SearchAsync(q, ct);
    }
    catch (OperationCanceledException) { /* superseded */ }
}

Proxy / backend-for-frontend

Forward the request token to the downstream call so a disconnect upstream also cancels the call downstream.

app.MapGet("/api/profile", async (IHttpClientFactory f, CancellationToken ct) =>
{
    var client = f.CreateClient("users");
    using var resp = await client.GetAsync("/me", HttpCompletionOption.ResponseHeadersRead, ct);
    return Results.Stream(await resp.Content.ReadAsStreamAsync(ct), "application/json");
});

Database-heavy report

EF Core and Dapper accept the token on every async call; when it fires, the driver asks the database to cancel the running command, freeing the connection.

var total = await db.Orders
    .Where(o => o.Year == year)
    .SumAsync(o => o.Total, ct);

var rows = await conn.QueryAsync<Row>(
    new CommandDefinition(sql, args, cancellationToken: ct));

Fire-and-forget after the response

Never use the request token for work that should outlive the request: it is canceled the moment the request ends. Hand the work to a queue processed by a background service instead (see chapter 10).

app.MapPost("/signup", async (User u, IBackgroundQueue q, CancellationToken ct) =>
{
    await q.EnqueueAsync(new SendWelcomeEmail(u.Email), ct); // quick hand-off
    return Results.Accepted();
});

gRPC & SignalR

gRPC exposes the call’s token as context.CancellationToken, canceled on client cancel or when the deadline passes. SignalR streaming hub methods can take a CancellationToken parameter that fires when the client stops the stream.

public override async Task<Reply> Get(Req r, ServerCallContext context)
    => await _svc.GetAsync(r.Id, context.CancellationToken);

Logging aborted requests

A client disconnecting is normal. Many teams map it to status 499 (“client closed request”) and log at Information or Debug, keeping error dashboards clean.

catch (OperationCanceledException) when (ctx.RequestAborted.IsCancellationRequested)
{
    logger.LogDebug("Client aborted {Path}", ctx.Request.Path);
    ctx.Response.StatusCode = 499;
}
10

Scenario: Background services & graceful shutdown

A BackgroundService gets a stoppingToken that the host cancels when the app shuts down. Your job: notice it, finish the current unit of work, and exit before the host stops waiting.

public sealed class OrderConsumer(Channel<Order> queue, IServiceScopeFactory scopes,
                                  ILogger<OrderConsumer> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var order in queue.Reader.ReadAllAsync(stoppingToken))
        {
            await using var scope = scopes.CreateAsyncScope();
            var handler = scope.ServiceProvider.GetRequiredService<OrderHandler>();
            try
            {
                await handler.HandleAsync(order, stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                log.LogInformation("Shutting down; order {Id} will be retried", order.Id);
                break;
            }
            catch (Exception ex)
            {
                log.LogError(ex, "Order {Id} failed", order.Id);   // keep the service alive
            }
        }
    }
}

Simulator: will your cleanup make it in time?

When the host stops (Ctrl+C, SIGTERM from Docker or Kubernetes), it cancels stoppingToken and then waits up to HostOptions.ShutdownTimeout (default 30 seconds) for services to finish.

  1. SIGTERM received → IHostApplicationLifetime.ApplicationStopping is canceled.
  2. The host calls StopAsync on each hosted service → BackgroundService cancels your stoppingToken.
  3. Your loop notices, finishes the current item and returns from ExecuteAsync.
  4. Outcome

Tuning shutdown

builder.Services.Configure<HostOptions>(o =>
{
    o.ShutdownTimeout = TimeSpan.FromSeconds(25);  // keep below Kubernetes' 30 s grace period
    o.ServicesStopConcurrently = true;             // .NET 8+: stop services in parallel
});

The CancellationToken passed to StopAsync(cancellationToken) means “shutdown is no longer graceful”: it fires when the timeout runs out.

Periodic jobs with PeriodicTimer

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
    try
    {
        do
        {
            await RefreshCacheAsync(stoppingToken);
        }
        while (await timer.WaitForNextTickAsync(stoppingToken));
    }
    catch (OperationCanceledException) { /* normal shutdown */ }
}
Changed in .NET 10 All of ExecuteAsync now runs on a background thread. Before .NET 10 the code before the first await ran synchronously during startup and blocked other services from starting. If you need work to run during startup, override StartAsync (do it before calling base.StartAsync) or implement IHostedLifecycleService.

More real-world cases

Message queue consumer

Pass stoppingToken to the receive call so shutdown interrupts the wait for the next message, but finish (or explicitly abandon) the message in hand so the broker redelivers it instead of losing it.

Outbox / batch processor

Check the token between batches, not in the middle of a database transaction. A half-committed batch is worse than a 2-second delay.

Stopping the app from inside

Inject IHostApplicationLifetime and call StopApplication() on a fatal condition; every stoppingToken then fires. Register on ApplicationStopping to flush telemetry.

Unhandled exceptions

By default (since .NET 6) an exception escaping ExecuteAsync stops the whole host (BackgroundServiceExceptionBehavior.StopHost). An OperationCanceledException during shutdown is treated as a normal stop.

11

Scenario: Parallel work & async streams

When many operations run at once, cancellation has to stop the ones in flight and prevent new ones from starting.

Simulator: Parallel.ForEachAsync

18 thumbnails, processed with a limited degree of parallelism. Cancel part-way, or make one item fail: the loop cancels the token it gave every other item.

var options = new ParallelOptions
{
    MaxDegreeOfParallelism = 3,
    CancellationToken = cts.Token          // stops scheduling new items
};

await Parallel.ForEachAsync(images, options, async (image, ct) =>
{
    // ct is canceled if cts is canceled OR another item throws
    var bytes = await blob.DownloadAsync(image.Path, ct);
    await blob.UploadAsync(image.ThumbPath, Resize(bytes), ct);
});

Simulator: cancelling an IAsyncEnumerable<T>

With await foreach (… in Stream().WithCancellation(ct)) the token only reaches your iterator if its parameter has [EnumeratorCancellation]. Without it the compiler warns (CS8425) and the token is silently ignored.


    

More patterns

Fan-out: first failure cancels the rest

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var tasks = shards.Select(async s =>
{
    try { return await QueryShardAsync(s, cts.Token); }
    catch { await cts.CancelAsync(); throw; }   // stop siblings early
});
var results = await Task.WhenAll(tasks);

Retry with backoff

Pass the token to the delay too, otherwise a cancel waits for the full backoff.

for (int attempt = 1; ; attempt++)
{
    try { return await CallAsync(ct); }
    catch (HttpRequestException) when (attempt < 5)
    {
        await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
    }
}

Producer / consumer with channels

await channel.Writer.WriteAsync(item, ct);           // waits if full
await foreach (var x in channel.Reader.ReadAllAsync(ct))
    await HandleAsync(x, ct);

Streaming from an endpoint

When an endpoint returns IAsyncEnumerable<T>, take a CancellationToken and pass it into the iterator so a client that stops reading also stops the database query.

app.MapGet("/events", (EventStore s, CancellationToken ct) => s.ReadAllAsync(ct));

Throttling with SemaphoreSlim

await _gate.WaitAsync(ct);          // waiting in line is cancellable
try { await CallRateLimitedApiAsync(ct); }
finally { _gate.Release(); }

Synchronous Parallel.ForEach

Put the token in ParallelOptions. When that same token is canceled, the loop throws a single OperationCanceledException. Inside long iterations, also call ct.ThrowIfCancellationRequested().

12

Under the hood

A simplified model of what the runtime does. The real source is more optimized (lock-free fast paths, pooled nodes), but the behaviour is the same.

var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
CancellationToken copy = token;

CancellationTokenRegistration reg =
    token.Register(() => socket.Abort());

bool before = token.IsCancellationRequested;

cts.Cancel();

bool after = copy.IsCancellationRequested;

reg.Dispose();
cts.Dispose();

var none = CancellationToken.None;
none.Register(() => Never());

1 / 1
Thread safety All members of CancellationTokenSource and CancellationToken are thread-safe, except Dispose (call it once, when no one else is using the source) and TryReset (owner only).
ExecutionContext Register captures the ExecutionContext (so AsyncLocal values, culture and activity flow into the callback). UnsafeRegister skips that for speed.
13

Pitfalls & best-practices lab

The mistakes that show up in real code reviews. Pick one to compare the broken version with the fix.

14

Designing your own cancellable APIs

Do

  • Make CancellationToken cancellationToken = default the last parameter of every async method that does I/O or long work.
  • Accept a CancellationToken, never a CancellationTokenSource. Callers keep the power to cancel.
  • Forward it to every call that accepts one. Turn on CA2016 as a warning in .editorconfig.
  • Check the token before starting expensive work, and between steps that are safe stopping points.
  • Let OperationCanceledException propagate so your task ends Canceled.
  • Document what “canceled” means: is anything rolled back, partially written or retried?
  • For interfaces, put the token on the interface method so every implementation gets it.
  • For async iterators, mark the token with [EnumeratorCancellation].
public interface IInvoiceStore
{
    Task<Invoice?> FindAsync(Guid id, CancellationToken cancellationToken = default);
    IAsyncEnumerable<Invoice> StreamAsync(int year, CancellationToken cancellationToken = default);
}

Don’t

  • Don’t throw a different exception type for cancellation, or wrap it in your own exception.
  • Don’t return null or a half-empty result silently when canceled; callers will think it succeeded.
  • Don’t store a request’s token in a singleton field and use it later.
  • Don’t cancel after the point of no return. Use CancellationToken.None for the commit.
  • Don’t take two separate token parameters. Link them inside if you need to.
  • Don’t add a token to trivial synchronous methods that finish in microseconds.
  • Don’t do heavy work or throw inside Register callbacks.

Convenience overloads

If you already shipped DoAsync(), add DoAsync(CancellationToken) and have the old one call the new one with CancellationToken.None, or replace both with one method that has an optional parameter in a new major version.

15

Cheat sheet

I want to…Use
let a user cancelnew CancellationTokenSource() + await cts.CancelAsync()
give up after N secondsnew CancellationTokenSource(TimeSpan) or cts.CancelAfter(…)
combine caller token + timeoutCreateLinkedTokenSource(ct) then CancelAfter; dispose it
stop my own loopct.ThrowIfCancellationRequested() each iteration
clean up, then stopif (ct.IsCancellationRequested) { cleanup; ct.ThrowIfCancellationRequested(); }
cancel an API with no tokenusing var r = ct.Register(() => thing.Abort());
stop waiting on a non-cancellable taskawait task.WaitAsync(ct) (work keeps running)
know the client left (ASP.NET Core)a CancellationToken parameter = RequestAborted
know the app is stoppingstoppingToken or IHostApplicationLifetime.ApplicationStopping
cancel a parallel loopParallelOptions.CancellationToken
cancel an async stream[EnumeratorCancellation] + .WithCancellation(ct)
an operation that must not be cancelledCancellationToken.None
a canceled token in a unit testnew CancellationToken(true)
fake time in testsFakeTimeProvider + new CancellationTokenSource(delay, timeProvider)
reuse a source cheaplyif (!cts.TryReset()) { cts.Dispose(); cts = new(); }