Async Patterns: Cancellation, ValueTask & Channels
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.
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");
}
}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 throwsTimeoutException. 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.
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}");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.
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";
}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
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
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
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}");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.
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;
}
}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
OperationCanceledExceptionand 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
CancellationTokenSourceobjects. Useusing. - Unlimited
Task.WhenAllover thousands of items. Throttle withParallel.ForEachAsyncorSemaphoreSlim. - Forgetting
Writer.Complete(). The reader never finishes. - Awaiting a
ValueTasktwice. It can return wrong data or throw; useTaskwhen in doubt.
Key points
- Combine caller cancellation and timeouts with
CreateLinkedTokenSourceplusCancelAfter; use an exception filter to tell them apart. Parallel.ForEachAsyncwithMaxDegreeOfParallelism, orSemaphoreSlim, 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. ValueTasksaves 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
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;
}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?
Linked sources follow their parent and can add their own reason, such as a timeout.
2. What happens when a bounded channel is full and the producer calls WriteAsync?
With the default full mode, WriteAsync waits, which gives back-pressure.
3. Which is a rule for ValueTask<T>?
A ValueTask may wrap reusable state, so it must be consumed exactly once.
4. Where is ConfigureAwait(false) recommended?
Libraries do not need the caller's context; UI code that updates controls does.
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
