What are the best practices for cancellation in async operations using `CancellationToken`?
Quick Answer
`CancellationToken` provides cooperative cancellation: a `CancellationTokenSource` issues a token that methods accept and observe via `ThrowIfCancellationRequested()`, `IsCancellationRequested`, or by passing it to async APIs. Best practices: flow the token through the whole call chain, honor it promptly in loops/long work, propagate it to downstream calls, and handle the resulting `OperationCanceledException`. Cancellation is cooperative — code must check the token, it isn't forced.
Detailed Answer
CancellationToken provides a cooperative cancellation mechanism for async operations. Here are the best practices:
1. Always accept CancellationToken parameters:
// Good: Accepts cancellation token
public async Task FetchDataAsync(CancellationToken cancellationToken = default)
{
return await _httpClient.GetFromJsonAsync(url, cancellationToken);
}
// Bad: No way to cancel
public async Task FetchDataAsync()
{
return await _httpClient.GetFromJsonAsync(url);
}
2. Pass tokens through the call chain:
public async Task ProcessOrderAsync(Order order, CancellationToken cancellationToken)
{
// Pass token to all async calls
await ValidateOrderAsync(order, cancellationToken);
await SaveOrderAsync(order, cancellationToken);
await SendConfirmationAsync(order, cancellationToken);
}
private async Task ValidateOrderAsync(Order order, CancellationToken cancellationToken)
{
await _validator.ValidateAsync(order, cancellationToken);
}
3. Check for cancellation in long-running operations:
public async Task ProcessItemsAsync(List items, CancellationToken cancellationToken)
{
foreach (var item in items)
{
// Check before each iteration
cancellationToken.ThrowIfCancellationRequested();
await ProcessItemAsync(item, cancellationToken);
}
}
4. Use timeout with CancellationTokenSource:
public async Task FetchWithTimeoutAsync()
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
return await FetchDataAsync(cts.Token);
}
catch (OperationCanceledException)
{
throw new TimeoutException("Operation timed out after 30 seconds");
}
}
5. Link multiple cancellation tokens:
public async Task ProcessAsync(CancellationToken userToken)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userToken,
timeoutCts.Token
);
// Cancelled if either user cancels OR timeout occurs
await LongRunningOperationAsync(linkedCts.Token);
}
6. Handle OperationCanceledException appropriately:
public async Task TryProcessAsync(CancellationToken cancellationToken)
{
try
{
var data = await FetchDataAsync(cancellationToken);
return Result.Success(data);
}
catch (OperationCanceledException)
{
// Expected when cancelled - don't log as error
_logger.LogInformation("Operation was cancelled");
return Result.Cancelled();
}
catch (Exception ex)
{
// Unexpected exception - log as error
_logger.LogError(ex, "Operation failed");
return Result.Failed(ex.Message);
}
}
7. Register cleanup actions:
public async Task ProcessWithCleanupAsync(CancellationToken cancellationToken)
{
var resource = await AcquireResourceAsync();
// Register cleanup when cancelled
using var registration = cancellationToken.Register(() =>
{
_logger.LogInformation("Cancellation requested - cleaning up");
resource.Dispose();
});
try
{
await ProcessResourceAsync(resource, cancellationToken);
}
finally
{
resource.Dispose();
}
}
8. Use CancellationToken in ASP.NET Core:
[HttpGet]
public async Task GetData(CancellationToken cancellationToken)
{
// Automatically cancelled if client disconnects
var data = await _service.FetchDataAsync(cancellationToken);
return Ok(data);
}
9. Provide meaningful default values:
// Use default parameter for optional cancellation
public async Task LoadDataAsync(CancellationToken cancellationToken = default)
{
// Works with or without cancellation token
await Task.Delay(1000, cancellationToken);
return new Data();
}
10. Don't swallow OperationCanceledException unnecessarily:
// Bad: Hides cancellation
public async Task ProcessAsync(CancellationToken cancellationToken)
{
try
{
await DoWorkAsync(cancellationToken);
}
catch (Exception ex) // Catches OperationCanceledException too
{
_logger.LogError(ex, "Error");
// Cancellation is hidden
}
}
// Good: Let OperationCanceledException propagate
public async Task ProcessAsync(CancellationToken cancellationToken)
{
try
{
await DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Let it propagate or handle specifically
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
throw;
}
}
Complete example:
public class DataService
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
public async Task ProcessDataAsync(
string url,
CancellationToken cancellationToken = default)
{
// Add timeout protection
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromMinutes(5));
try
{
// Pass token through call chain
var data = await FetchDataAsync(url, cts.Token);
var processed = await TransformDataAsync(data, cts.Token);
await SaveDataAsync(processed, cts.Token);
return ProcessingResult.Success();
}
catch (OperationCanceledException)
{
_logger.LogInformation("Processing cancelled");
return ProcessingResult.Cancelled();
}
catch (Exception ex)
{
_logger.LogError(ex, "Processing failed");
return ProcessingResult.Failed(ex.Message);
}
}
private async Task FetchDataAsync(string url, CancellationToken cancellationToken)
{
return await _httpClient.GetFromJsonAsync(url, cancellationToken);
}
}