Cancellation and parallelism

C# · Advanced · 14 min

Production services must stop work promptly on timeouts and shutdowns and use cores efficiently.

Cancellation in .NET is cooperative. A CancellationTokenSource creates a CancellationToken that you pass down through every async method. Library methods (HttpClient, EF Core, Task.Delay) observe it and throw OperationCanceledException when it fires; your own loops call token.ThrowIfCancellationRequested(). Set a timeout with new CancellationTokenSource(TimeSpan.FromSeconds(5)) or CancelAfter, and combine sources with CreateLinkedTokenSource. In ASP.NET Core, HttpContext.RequestAborted is canceled when the client disconnects.

Concurrency control:

  • Task.WhenAll over a thousand tasks starts them all at once. To cap concurrency, use Parallel.ForEachAsync(items, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (item, ct) => ...) or a SemaphoreSlim.
  • Task.WhenAny waits for the first finished task, useful for timeouts or hedged requests.

For CPU-bound work, use Parallel.For/Parallel.ForEach or PLINQ (.AsParallel()), which partition work across thread pool threads. Shared state then needs synchronization: lock, Interlocked.Increment, or concurrent collections.

System.Threading.Channels provides fast async producer/consumer queues: producers WriteAsync, consumers await foreach (var x in reader.ReadAllAsync(ct)), with bounded capacity giving backpressure.

Treat OperationCanceledException as a normal outcome, not an error, when your own token was canceled.

Example

using System.Threading.Channels;

using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250));
try
{
    for (int i = 0; ; i++) { await Task.Delay(100, cts.Token); Console.Write($"tick{i} "); }
}
catch (OperationCanceledException) { Console.WriteLine("canceled"); }
// tick0 tick1 canceled (exact count is timing-dependent)

int done = 0;
await Parallel.ForEachAsync(Enumerable.Range(1, 8),
    new ParallelOptions { MaxDegreeOfParallelism = 3 },
    async (n, ct) => { await Task.Delay(20, ct); Interlocked.Increment(ref done); });
Console.WriteLine(done);                       // 8

var ch = Channel.CreateBounded<int>(2);
var producer = Task.Run(async () =>
{
    for (int i = 0; i < 4; i++) await ch.Writer.WriteAsync(i);
    ch.Writer.Complete();
});
await foreach (var x in ch.Reader.ReadAllAsync()) Console.Write(x); // 0123
await producer;

Remember

  • Pass CancellationToken through every async call chain
  • Cap concurrency with Parallel.ForEachAsync or SemaphoreSlim
  • Use Interlocked, lock or concurrent collections for shared state
  • Channels give async producer/consumer queues with backpressure

Watch out for

  • Accepting a CancellationToken parameter but never passing it to the calls that do the waiting.

Check yourself

  1. How does cancellation with a CancellationToken work in .NET?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads