EthicsComputerCourses
Join training +91 90532 10052

Async Patterns: Cancellation, ValueTask & Channels

C# · Lesson 29 of 43 Intermediate 8 min read Updated

The async & await lesson taught the basics: Task, await, Task.WhenAll and a simple CancellationToken. Real services need more. A payment call must time out but still respect the user pressing Cancel. A job that calls an API for 5,000 customers must not fire 5,000 requests at once. A background worker needs a safe queue between the code that receives work and the code that does it.

This lesson covers the patterns you will see in professional .NET code: linked cancellation, throttling, Task.WhenEach, channels, ValueTask and ConfigureAwait.

Timeouts plus user cancellation

A method often has two reasons to stop: the caller cancelled (the user closed the page) or the operation took too long. Combine both with a linked token source: a new source that is cancelled when its parent token is cancelled or when its own timer fires.

C#
using CancellationTokenSource userCts = new();

try
{
    string report = await DownloadReportAsync("Pune", TimeSpan.FromMilliseconds(300), userCts.Token);
    Console.WriteLine(report);
}
catch (TimeoutException ex)
{
    Console.WriteLine(ex.Message);
}

async Task<string> DownloadReportAsync(string city, TimeSpan timeout, CancellationToken cancellationToken)
{
    using CancellationTokenSource timeoutCts =
        CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    timeoutCts.CancelAfter(timeout);

    try
    {
        await Task.Delay(1000, timeoutCts.Token); // pretend the server takes 1 second
        return $"{city} report";
    }
    catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
    {
        throw new TimeoutException($"{city} report took longer than {timeout.TotalMilliseconds} ms");
    }
}
Output
Pune report took longer than 300 ms

The exception filter when (!cancellationToken.IsCancellationRequested) separates the two cases. If the caller cancelled, the OperationCanceledException passes through untouched, which is what callers expect. If only the timer fired, the method reports a clear TimeoutException. Always dispose linked sources (using), because they register with the parent token.

Two more tools are worth knowing:

  • In CPU-heavy loops that do not call any async method, check the token yourself with token.ThrowIfCancellationRequested() every few iterations.
  • For an older API that takes no token, await task.WaitAsync(TimeSpan.FromSeconds(2)) (since .NET 6) stops waiting after two seconds and throws TimeoutException. The underlying work keeps running, so prefer real tokens when the API offers them.

Limiting how much runs at once

Starting every task at once with Task.WhenAll is great for three calls and dangerous for three thousand: you can exhaust connections or get blocked by the remote server. Parallel.ForEachAsync (since .NET 6) runs an async body for each item with a limit you choose.

C#
string[] cities = ["Rohtak", "Jaipur", "Pune", "Kochi", "Lucknow", "Delhi"];
Lock gate = new();
int running = 0, peak = 0, done = 0;

var options = new ParallelOptions { MaxDegreeOfParallelism = 2 };
await Parallel.ForEachAsync(cities, options, async (city, ct) =>
{
    lock (gate) { running++; peak = Math.Max(peak, running); }

    await Task.Delay(200, ct); // pretend to call a weather API for this city

    lock (gate) { running--; done++; }
});

Console.WriteLine($"Cities done: {done}");
Console.WriteLine($"Limit respected: {peak <= 2}");
Output
Cities done: 6
Limit respected: True

The body runs on several threads, so the shared counters are protected with lock on a Lock object (the dedicated lock type added in .NET 9). If you prefer Task.WhenAll, the classic alternative is a SemaphoreSlim(2): each task calls await semaphore.WaitAsync(token) before the work and semaphore.Release() in a finally block.

Handling results as they finish

Task.WhenAll waits for the slowest task. When you want to show each answer as soon as it arrives, Task.WhenEach (added in .NET 9) gives you the tasks in completion order as an async stream.

C#
Task<string>[] checks =
[
    StockAsync("Jaipur", 900),
    StockAsync("Pune", 100),
    StockAsync("Kochi", 400)
];

await foreach (Task<string> finished in Task.WhenEach(checks))
{
    Console.WriteLine(await finished);
}

async Task<string> StockAsync(string warehouse, int ms)
{
    await Task.Delay(ms);
    return $"{warehouse} warehouse replied after {ms} ms";
}
Output
Pune warehouse replied after 100 ms
Kochi warehouse replied after 400 ms
Jaipur warehouse replied after 900 ms

Awaiting finished gives its result, or rethrows its exception, so wrap the body in try/catch if one failure should not stop the others.

Walkthrough: a producer-consumer queue with channels

A channel (System.Threading.Channels, built into .NET) is an async-safe queue. One part of the program writes items, another reads them, and neither needs locks. Here an order system hands orders to a packing worker.

Step 1: create a bounded channel

C#
using System.Threading.Channels;

Channel<int> orders = Channel.CreateBounded<int>(capacity: 2);

Bounded means it holds at most two waiting items. When it is full, WriteAsync waits for space. This back-pressure stops a fast producer from filling memory faster than the consumer can work.

Step 2: write items, then complete the writer

C#
Task producer = Task.Run(async () =>
{
    foreach (int orderId in new[] { 101, 102, 103, 104 })
    {
        await orders.Writer.WriteAsync(orderId);
    }
    orders.Writer.Complete(); // no more orders are coming
});

Step 3: read until the channel is finished

C#
int packed = 0;
await foreach (int orderId in orders.Reader.ReadAllAsync())
{
    await Task.Delay(100); // packing takes time
    Console.WriteLine($"Packed order {orderId}");
    packed++;
}

await producer;
Console.WriteLine($"All orders packed: {packed}");
Output
Packed order 101
Packed order 102
Packed order 103
Packed order 104
All orders packed: 4

ReadAllAsync waits when the channel is empty and ends the loop only after Complete() has been called and every item has been read. Forgetting Complete() is the classic bug: the consumer waits forever. Channels are the backbone of many background workers, and you will use one idea from here in Project: CSV Import Worker Service.

ValueTask: avoiding allocations on a fast path

Every Task is an object on the heap. For a method that usually finishes without waiting, for example because the answer is cached, ValueTask<T> can return the result without allocating.

C#
var rates = new RateCache();
for (int i = 0; i < 3; i++)
{
    Console.WriteLine($"USD: {await rates.GetRateAsync("USD")}");
}
Console.WriteLine($"Downloads: {rates.Downloads}");

public sealed class RateCache
{
    private readonly Dictionary<string, decimal> _cache = new();
    public int Downloads { get; private set; }

    public ValueTask<decimal> GetRateAsync(string currency)
    {
        if (_cache.TryGetValue(currency, out decimal rate))
        {
            return ValueTask.FromResult(rate); // cached: no Task object needed
        }
        return new ValueTask<decimal>(DownloadAsync(currency));
    }

    private async Task<decimal> DownloadAsync(string currency)
    {
        await Task.Delay(200);
        Downloads++;
        decimal rate = 88.50m; // a made-up rate for the demo
        _cache[currency] = rate;
        return rate;
    }
}
Output
USD: 88.50
USD: 88.50
USD: 88.50
Downloads: 1

ValueTask has strict rules: await it exactly once, never await it from two places, and do not read .Result before it has completed. If you need any of that, call .AsTask() first. Use Task by default and switch to ValueTask only for hot paths that a measurement shows are worth it.

ConfigureAwait and synchronization contexts

After an await, where does the rest of the method run? Desktop apps (WPF, Windows Forms, .NET MAUI) have a synchronization context that sends the continuation back to the UI thread, so you can update controls safely. ASP.NET Core and console apps have none, so the continuation runs on any thread-pool thread.

await something.ConfigureAwait(false) says "I do not need to come back to the original context". The guidance from the .NET team is simple:

  • Library code that might be used by a UI app: use ConfigureAwait(false) on its awaits. It avoids needless hops to the UI thread and protects against deadlocks when a caller blocks.
  • App code (UI event handlers, ASP.NET Core endpoints, console apps): leave it out. UI code needs the context; the others have none, so it makes no difference.

Common mistakes

  • Catching OperationCanceledException and carrying on. Let cancellation flow up unless you are at the top level that decides what to tell the user.
  • Not disposing linked or timed CancellationTokenSource objects. Use using.
  • Unlimited Task.WhenAll over thousands of items. Throttle with Parallel.ForEachAsync or SemaphoreSlim.
  • Forgetting Writer.Complete(). The reader never finishes.
  • Awaiting a ValueTask twice. It can return wrong data or throw; use Task when in doubt.

Key points

  • Combine caller cancellation and timeouts with CreateLinkedTokenSource plus CancelAfter; use an exception filter to tell them apart.
  • Parallel.ForEachAsync with MaxDegreeOfParallelism, or SemaphoreSlim, limits concurrent work.
  • Task.WhenEach (.NET 9) streams tasks in the order they complete.
  • Channels give an async producer-consumer queue; bounded channels add back-pressure, and Complete() ends reading.
  • ValueTask saves allocations on synchronous fast paths but must be awaited exactly once.
  • Use ConfigureAwait(false) in library code, not in app code.

Exercise

Write async Task<int> SendSmsBatchAsync(IEnumerable<string> phones, CancellationToken token) for a school in Kochi that sends exam reminders. Each send is simulated with await Task.Delay(50, token). Allow at most 3 sends at once, count successful sends, and make the whole batch stop if it runs longer than 2 seconds. Call it with 10 numbers and print the count.

Show solution
C#
string[] phones = Enumerable.Range(1, 10).Select(i => $"98470000{i:D2}").ToArray();
int sent = await SendSmsBatchAsync(phones, CancellationToken.None);
Console.WriteLine($"Reminders sent: {sent}");

async Task<int> SendSmsBatchAsync(IEnumerable<string> phones, CancellationToken token)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
    cts.CancelAfter(TimeSpan.FromSeconds(2));

    int count = 0;
    var options = new ParallelOptions { MaxDegreeOfParallelism = 3, CancellationToken = cts.Token };
    await Parallel.ForEachAsync(phones, options, async (phone, ct) =>
    {
        await Task.Delay(50, ct); // pretend to call the SMS gateway
        Interlocked.Increment(ref count);
    });
    return count;
}
Output
Reminders sent: 10

Ten sends of 50 ms in groups of up to three finish in roughly 200 ms, well under the 2-second limit. Interlocked.Increment updates the counter safely from several threads. If the limit were hit, Parallel.ForEachAsync would throw OperationCanceledException, which the caller can catch.

Quick Quiz

1. What does CancellationTokenSource.CreateLinkedTokenSource(token) give you?

2. What happens when a bounded channel is full and the producer calls WriteAsync?

3. Which is a rule for ValueTask<T>?

4. Where is ConfigureAwait(false) recommended?

Learn C# with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call