Unresponsive operations that can't be interrupted are a common source of resource leaks, poor user experience, and cascading failures in distributed systems. CancellationToken is .NET's cooperative cancellation primitive — it doesn't kill threads, it signals intent, and your code decides how to react.
What CancellationToken Solves
Before cancellation tokens, stopping an async operation required shared bool flags, custom events, or thread-abort (which is broken by design). The problem: callers had no standard way to tell an operation "stop what you're doing."
CancellationToken establishes a contract:
- Producer (
CancellationTokenSource) decides when to cancel - Consumer (your async method) checks the token and reacts appropriately
- Framework (ASP.NET Core, EF Core,
HttpClient) does this automatically when you pass the token through
// Without cancellation — the caller has no way to stop this; the only backstop
// is HttpClient's global 100-second default Timeout
public async Task<string> FetchDataAsync(string url)
{
var response = await _httpClient.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
// With cancellation — respects caller's intent
public async Task<string> FetchDataAsync(string url, CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(url, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}The key insight: cancellation is cooperative. The token cannot force your code to stop. You must observe it.
Every Claim Here Is Asserted, Not Narrated
Cooperative cancellation has a useful property: every claim about it is checkable as a
hard fact — did the loop stop, what state did the task end in, which exception type came
out, whose token is on it.
samples/cancellationtoken-csharp
turns this article's claims into assertions, with no timing races: every token is
cancelled before the work it should stop, or the work waits infinitely so only
cancellation can end it.
| Demo | Asserted |
|---|---|
| Cooperative | a loop that never reads the token processes 5/5 items after Cancel(); the observing version processes 0/5 and its task ends Canceled, not Faulted |
| Graceful drain | a while (!token.IsCancellationRequested) loop falls through — the caller sees RanToCompletion, no exception |
| Linked tokens | the when (callerToken.IsCancellationRequested) filter separates "my timeout" from "caller cancelled" in both directions |
Register | the callback fires exactly once across two Cancel() calls; a disposed registration never fires; registering on an already-cancelled token runs the callback synchronously |
Task.Run | with a pre-cancelled token the delegate never executes; await throws TaskCanceledException carrying the caller's token |
HttpClient | Timeout → TaskCanceledException with inner TimeoutException; a caller token → no inner TimeoutException, and the exception carries the caller's token |
| Disposal | Cancel() after Dispose() throws ObjectDisposedException |

The HttpClient demo deserves one note: to prove timeout behaviour without network
flakiness, the sample starts a TcpListener on a loopback port that accepts the
connection and then never responds — a deterministic way to make an HTTP request hang.
CancellationTokenSource — Creating Tokens
CancellationTokenSource (CTS) is the controller. It creates the token and holds the ability to cancel it.
// Basic manual cancellation
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
// Pass token to your work
var workTask = DoWorkAsync(token);
// Cancel from another thread / user action
cts.Cancel(); // signals cancellation; registered callbacks run on THIS thread, now
// cts.CancelAsync(); // .NET 8+ — returns a Task instead of running callbacks inline
await workTask; // throws OperationCanceledExceptionLifetime and Disposal
// CancellationTokenSource implements IDisposable
// Always dispose when you own the source
using var cts = new CancellationTokenSource();
// Or in a try/finally if not using 'using'
var cts = new CancellationTokenSource();
try
{
await DoWorkAsync(cts.Token);
}
finally
{
cts.Dispose(); // releases internal WaitHandle if allocated
}A correction from an earlier version of this article: it showed
await using var cts = new CancellationTokenSource() with a claim that CTS gained
IAsyncDisposable in .NET 6. It did not — that line does not compile, which I found
out the direct way when porting the article's snippets into the sample project.
CancellationTokenSource is plain IDisposable; use using.
Calling Cancel() after Dispose() throws ObjectDisposedException — the sample's demo 7 asserts exactly this. If multiple components share a CTS, coordinate ownership carefully.
Passing Tokens Through the Call Chain
This is the single most important practice. Every async method that does I/O, waits, or loops should accept and forward a CancellationToken.
// Repository layer
public async Task<Order> GetOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
return await _dbContext.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
}
// Service layer — passes token down
public async Task<OrderDto> ProcessOrderAsync(int orderId, CancellationToken cancellationToken = default)
{
var order = await _orderRepo.GetOrderAsync(orderId, cancellationToken);
// Check before expensive operation
cancellationToken.ThrowIfCancellationRequested();
var enriched = await _enrichmentService.EnrichAsync(order, cancellationToken);
return _mapper.Map<OrderDto>(enriched);
}
// ASP.NET Core controller — framework passes cancellationToken automatically
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken cancellationToken)
{
var order = await _orderService.ProcessOrderAsync(id, cancellationToken);
return order is null ? NotFound() : Ok(order);
}The = default default parameter allows callers that don't care about cancellation to omit it — CancellationToken.None is the default, which never fires.
The = default Convention
// These are equivalent — CancellationToken.None never fires
await DoWorkAsync();
await DoWorkAsync(CancellationToken.None);
await DoWorkAsync(default);
await DoWorkAsync(default(CancellationToken));Timeout Tokens — CancelAfter
The most common use case: cancel an operation if it takes too long.
// CancelAfter arms a timer on the existing source (available since .NET Framework 4.5)
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var result = await FetchDataAsync(cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
// Distinguishes timeout from user cancellation
throw new TimeoutException("Operation exceeded 30s");
}// Constructor overload — sets timeout at creation
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
await ProcessAsync(cts.Token);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Operation timed out after 30 seconds");
throw;
}Adding a Timeout to an Existing Token
// CreateLinkedTokenSource + CancelAfter: cancels when the upstream token fires
// OR the timeout elapses, whichever comes first
using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));(An earlier version of this article called this a ".NET 8 TimeoutToken" — there is no
such API. Both pieces here are old: CreateLinkedTokenSource shipped with the TPL in
.NET Framework 4, CancelAfter in 4.5.)
Prefer CancelAfter over creating a new CTS with a timeout in the constructor when you already have a CTS — it reuses the existing instance instead of allocating a new one.
IsCancellationRequested vs ThrowIfCancellationRequested
Two ways to check for cancellation — each has its place.
public async Task ProcessItemsAsync(IEnumerable<Item> items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
// Option 1: ThrowIfCancellationRequested — throws OperationCanceledException
// Use inside loops or between stages where you want to abort immediately
cancellationToken.ThrowIfCancellationRequested();
await ProcessItemAsync(item, cancellationToken);
}
}public async Task ProcessWithCleanupAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
// Option 2: IsCancellationRequested — bool check, no exception
// Use when you want to do cleanup before returning
var batch = await ReadBatchAsync(cancellationToken);
if (batch.Count == 0)
break;
await ProcessBatchAsync(batch, cancellationToken);
}
// Falls through naturally — caller sees completed task, not canceled
// Appropriate for graceful shutdown scenarios
}The difference between the two is visible in Task.Status, and the sample asserts both
sides: the ThrowIfCancellationRequested version ends in Canceled (the async state
machine special-cases OperationCanceledException — the task is not Faulted), while
the bool-check drain ends in RanToCompletion as if nothing happened. Pick based on
which of those two stories you want the caller to see.
When to Use Which
| Scenario | Recommended |
|---|---|
| Loop body between iterations | ThrowIfCancellationRequested() |
| Worker loop condition | !IsCancellationRequested |
| Before expensive CPU work | ThrowIfCancellationRequested() |
| Graceful drain / cleanup | IsCancellationRequested |
| Passing to awaitable APIs | Pass token directly |
| After awaitable completes | ThrowIfCancellationRequested() optional |
// Practical: check before starting expensive work, pass through awaits
public async Task<byte[]> CompressAndUploadAsync(
Stream data,
CancellationToken cancellationToken)
{
// Check before CPU-intensive work
cancellationToken.ThrowIfCancellationRequested();
var compressed = await CompressAsync(data, cancellationToken);
// No need to check again — CompressAsync already did it internally
// But if there's a gap between awaits with no internal checking:
cancellationToken.ThrowIfCancellationRequested();
return await _storage.UploadAsync(compressed, cancellationToken);
}Linked Tokens — CreateLinkedTokenSource
Real systems often have multiple cancellation sources: request timeout, user abort, server shutdown. CreateLinkedTokenSource combines them into a single token.
public async Task<SearchResult> SearchAsync(
string query,
CancellationToken requestCancellationToken) // from HTTP request
{
// Impose an additional per-operation timeout
// The linked token fires if EITHER source fires first
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
requestCancellationToken,
timeoutCts.Token);
try
{
return await _searchEngine.SearchAsync(query, linkedCts.Token);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
// Request wasn't cancelled — must be our timeout
throw new TimeoutException($"Search exceeded 5s for query: {query}");
}
// If requestCancellationToken fired, OperationCanceledException propagates naturally
}This classification is easy to get subtly backwards, so the sample's demo 3 asserts both
branches: with a clean caller token and a fired timeout it classifies "timeout", and with
a cancelled caller it classifies "caller" — same catch filters as above.
Multiple Upstream Sources
// Combine three sources: request + user abort + global shutdown
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
httpContext.RequestAborted, // HTTP request cancelled
userCancellationToken, // explicit user action
_appLifetime.ApplicationStopping); // server shutting down
await DoLongOperationAsync(linkedCts.Token);CreateLinkedTokenSource allocates a new CancellationTokenSource. Always dispose it, especially in high-throughput paths like hot API endpoints.
Register() — Cleanup Callbacks
Register() attaches a callback that fires when cancellation is requested. Useful for canceling non-cancellable operations or releasing resources.
public async Task<string> PollWithCallbackAsync(
Func<Task<string?>> pollFunc,
CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<string>(
TaskCreationOptions.RunContinuationsAsynchronously);
// Register cleanup: cancel the TaskCompletionSource when token fires
using var registration = cancellationToken.Register(() =>
{
tcs.TrySetCanceled(cancellationToken);
});
// Start polling in background
_ = Task.Run(async () =>
{
while (!tcs.Task.IsCompleted)
{
var result = await pollFunc();
if (result is not null)
{
tcs.TrySetResult(result);
return;
}
await Task.Delay(500); // poll interval
}
});
return await tcs.Task;
}// Bridging legacy callback-based API to cancellation
public Task WaitForEventAsync(LegacyEventSource source, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnEvent(object? sender, EventArgs e) => tcs.TrySetResult();
source.EventOccurred += OnEvent;
// Clean up subscription if cancelled
cancellationToken.Register(() =>
{
source.EventOccurred -= OnEvent;
tcs.TrySetCanceled(cancellationToken);
});
return tcs.Task;
}Register() Returns a Disposable
// The CancellationTokenRegistration should be disposed when no longer needed
// to avoid holding callbacks alive longer than necessary
using var registration = cancellationToken.Register(() => DoCleanup());
// Without 'using', the callback lives until the SOURCE is disposed
// This is usually fine for short-lived operations, but can leak for long-lived onesThree Register behaviours worth knowing cold, all asserted by the sample's demo 4: the
callback fires exactly once even if Cancel() is called twice; a disposed registration
never fires; and registering on an already-cancelled token runs the callback
synchronously, on your current thread, inside the Register call itself — which
means a callback that takes a lock can deadlock right there.
Graceful Shutdown in ASP.NET Core
ASP.NET Core exposes application lifetime events through IHostApplicationLifetime. These are pre-wired CancellationToken instances.
// Program.cs — configure shutdown timeout
builder.Services.Configure<HostOptions>(options =>
{
// Give background services 30s to finish before force-killing
options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});// Background service — proper graceful shutdown
public class OrderProcessingService : BackgroundService
{
private readonly ILogger<OrderProcessingService> _logger;
private readonly IOrderQueue _queue;
public OrderProcessingService(ILogger<OrderProcessingService> logger, IOrderQueue queue)
{
_logger = logger;
_queue = queue;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Order processing started");
// stoppingToken is provided by the host — fires on SIGTERM/Ctrl+C
await foreach (var order in _queue.ReadAllAsync(stoppingToken))
{
try
{
await ProcessOrderAsync(order, stoppingToken);
}
catch (OperationCanceledException)
{
// Don't log as error — this is expected on shutdown
_logger.LogInformation("Shutdown requested, stopping order processing");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order {OrderId}", order.Id);
// Continue processing next order
}
}
_logger.LogInformation("Order processing stopped");
}
private async Task ProcessOrderAsync(Order order, CancellationToken cancellationToken)
{
// All downstream calls receive the stoppingToken
await _orderService.ValidateAsync(order, cancellationToken);
await _orderService.FulfillAsync(order, cancellationToken);
await _notificationService.SendConfirmationAsync(order, cancellationToken);
}
}IHostApplicationLifetime for Non-BackgroundService Code
public class DataSyncService
{
private readonly IHostApplicationLifetime _lifetime;
public DataSyncService(IHostApplicationLifetime lifetime)
{
_lifetime = lifetime;
}
public async Task SyncAsync(CancellationToken userCancellationToken)
{
// Combine user request cancellation with application shutdown
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userCancellationToken,
_lifetime.ApplicationStopping);
await PerformSyncAsync(linkedCts.Token);
}
}Request Cancellation in Controllers
// ASP.NET Core automatically binds CancellationToken to HttpContext.RequestAborted
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
[HttpGet("{id}/generate")]
public async Task<IActionResult> GenerateReport(
int id,
CancellationToken cancellationToken) // auto-bound from HttpContext.RequestAborted
{
try
{
var report = await _reportService.GenerateAsync(id, cancellationToken);
return Ok(report);
}
catch (OperationCanceledException)
{
// Client disconnected — nothing will ever read this response.
// 499 is an nginx convention, NOT something ASP.NET Core produces or
// handles for you; returning it here only labels the request in your
// own logs and metrics instead of a misleading 200 or 500.
return StatusCode(499);
}
}
}Cancelling HttpClient Requests
HttpClient accepts CancellationToken in all request methods. Pass it every time.
public class WeatherService
{
private readonly HttpClient _httpClient;
public WeatherService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<WeatherData> GetWeatherAsync(
string city,
CancellationToken cancellationToken)
{
// Cancel if caller cancels OR if request takes > 10s
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
timeoutCts.Token);
try
{
var response = await _httpClient.GetAsync(
$"/weather/{city}",
linkedCts.Token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<WeatherData>(linkedCts.Token)
?? throw new InvalidOperationException("Empty response");
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
throw new TimeoutException($"Weather request for {city} timed out");
}
}
}HttpClient Global Timeout vs Per-Request Cancellation
// HttpClient.Timeout — applies to ALL requests through that client instance
// CancellationToken — per-request
// BOTH surface as TaskCanceledException (which derives from OperationCanceledException).
// Since .NET 5 the timeout carries an inner TimeoutException — that is the check
// Microsoft added precisely because the two cases were indistinguishable before:
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
// HttpClient.Timeout fired
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled
}The sample's demo 6 asserts both branches against a local server that accepts the
connection and never responds: the Timeout case has the inner TimeoutException, the
caller-token case does not — and in the caller case ex.CancellationToken equals the
caller's token (I verified this on .NET 10; it also holds back to .NET 5, where the token
started being propagated onto the exception).
EF Core Query Cancellation
Entity Framework Core passes CancellationToken to the database driver. The query gets cancelled at the database level — no wasted DB resources.
public class ProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
// Pass token to all async EF Core methods
public async Task<List<Product>> GetActiveProductsAsync(
string category,
CancellationToken cancellationToken = default)
{
return await _context.Products
.Where(p => p.Category == category && p.IsActive)
.OrderBy(p => p.Name)
.AsNoTracking()
.ToListAsync(cancellationToken); // cancels the DB query
}
public async Task<int> BulkUpdatePricesAsync(
string category,
decimal multiplier,
CancellationToken cancellationToken = default)
{
// ExecuteUpdateAsync cancels if token fires mid-operation
return await _context.Products
.Where(p => p.Category == category)
.ExecuteUpdateAsync(
setters => setters.SetProperty(p => p.Price, p => p.Price * multiplier),
cancellationToken);
}
public async Task<Product?> FindWithRetryAsync(
int id,
CancellationToken cancellationToken = default)
{
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
return await _context.Products.FindAsync(
new object[] { id },
cancellationToken);
}
catch (OperationCanceledException)
{
throw; // never swallow cancellation
}
catch (Exception ex) when (attempt < 2)
{
_logger.LogWarning(ex, "Attempt {Attempt} failed, retrying", attempt + 1);
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken);
}
}
return null;
}
}Combining Operation Timeout + Request Cancellation
A complete pattern for production use: per-operation timeout combined with the upstream request's cancellation.
public class ProductSearchService
{
private readonly ISearchIndex _searchIndex;
private readonly ILogger<ProductSearchService> _logger;
// Configurable timeout per operation type
private static readonly TimeSpan SearchTimeout = TimeSpan.FromSeconds(3);
private static readonly TimeSpan SuggestTimeout = TimeSpan.FromMilliseconds(500);
public ProductSearchService(ISearchIndex searchIndex, ILogger<ProductSearchService> logger)
{
_searchIndex = searchIndex;
_logger = logger;
}
public async Task<SearchResult> SearchAsync(
SearchRequest request,
CancellationToken requestCancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken);
cts.CancelAfter(SearchTimeout);
try
{
return await _searchIndex.SearchAsync(request, cts.Token);
}
catch (OperationCanceledException) when (requestCancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Search cancelled by request for query: {Query}", request.Query);
throw; // re-throw as-is, propagate upstream
}
catch (OperationCanceledException)
{
// Our timeout fired, not the request cancellation
_logger.LogWarning("Search timed out after {Timeout}s for query: {Query}",
SearchTimeout.TotalSeconds, request.Query);
throw new SearchTimeoutException(request.Query, SearchTimeout);
}
}
public async Task<IReadOnlyList<string>> GetSuggestionsAsync(
string prefix,
CancellationToken requestCancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken);
cts.CancelAfter(SuggestTimeout);
try
{
return await _searchIndex.GetSuggestionsAsync(prefix, cts.Token);
}
catch (OperationCanceledException)
{
// Suggestions are non-critical — return empty on timeout or cancellation
_logger.LogDebug("Suggestions timed out or cancelled for prefix: {Prefix}", prefix);
return Array.Empty<string>();
}
}
}Common Mistakes
Never Swallow OperationCanceledException
// WRONG — hides cancellation, caller thinks work completed normally
try
{
await DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Swallowed! Caller has no idea cancellation happened
}
// CORRECT — log if needed, but always re-throw
catch (OperationCanceledException ex)
{
_logger.LogInformation("Work was cancelled");
throw; // preserve original exception and stack trace
}Don't Catch and Wrap Unnecessarily
// WRONG — loses cancellation semantics
catch (OperationCanceledException ex)
{
throw new ApplicationException("Operation failed", ex); // bad!
}
// CORRECT — only wrap if you're adding real context, and use a type
// that still communicates cancellation or use the original exception
catch (OperationCanceledException)
{
throw; // or let it propagate naturally
}Avoid Task.Run Without Forwarding the Token
// WRONG — fire-and-forget ignores cancellation
var task = Task.Run(() => HeavyComputation()); // no token!
// CORRECT — token passed to Task.Run AND the work
var task = Task.Run(() => HeavyComputation(cancellationToken), cancellationToken);
// The outer token cancels task scheduling; the inner one cancels the work itselfThe outer token's effect is observable: the sample's demo 5 passes an already-cancelled
token to Task.Run and asserts the delegate never executes — the task goes straight
to Canceled and await throws TaskCanceledException carrying that token.
Don't Use CancellationToken for Flow Control
// WRONG — CancellationToken is for cancellation, not branching
if (cancellationToken.IsCancellationRequested)
{
return GetCachedResult(); // using cancellation as "fast path"!
}
// CORRECT — check cancellation, then throw or return cleanly
cancellationToken.ThrowIfCancellationRequested();
return await FetchFreshResult(cancellationToken);Decision Reference
Which Pattern to Use
| Situation | Pattern |
|---|---|
| Simple timeout on an operation | new CancellationTokenSource(timeout) |
| Add timeout to existing token | CreateLinkedTokenSource + CancelAfter |
| HTTP request cancelled by client | HttpContext.RequestAborted passed through |
| Background service shutdown | BackgroundService.stoppingToken |
| Multiple cancellation sources | CreateLinkedTokenSource(token1, token2, ...) |
| Cleanup on cancellation | cancellationToken.Register(callback) |
| Check in tight loop | ThrowIfCancellationRequested() |
| Graceful drain without exception | IsCancellationRequested as loop condition |
Exception Handling Decision
| Scenario | Action |
|---|---|
| Cancellation is expected (client disconnect) | Catch, log debug/info, return 499 or empty |
| Timeout is exceptional for this operation | Catch, wrap in domain exception, log warning |
| Background worker cancelled on shutdown | Catch, log info, exit loop cleanly |
| Propagating through middleware/pipeline | Re-throw (throw;) without wrapping |
| Retry loop | Catch other exceptions, re-throw cancellation |
Summary
CancellationToken works well when you treat it consistently: accept it in every async method, forward it to every awaitable call, check between stages with ThrowIfCancellationRequested, and never swallow OperationCanceledException. The linked token pattern handles the real-world need to combine multiple cancellation sources — request lifetime, per-operation timeout, and application shutdown — into a single token that any downstream library can use without knowing the origin.
The difference between a resilient service and one that leaks resources under load often comes down to whether cancellation is wired end-to-end.
Every behavioural claim above is asserted by
samples/cancellationtoken-csharp
— clone it and run dotnet run if you want to see any of them fail on a future runtime.
Cancellation overlaps with two neighbouring topics worth reading next.
OperationCanceledException is an exception like any other, so the rules in
async exception handling — particularly around
Task.WhenAll and fire-and-forget work — apply directly to cancelled operations. And if
you are streaming results rather than returning a list,
IAsyncEnumerable<T> has its own cancellation mechanism
via [EnumeratorCancellation] that does not behave quite like a plain async method.