- Choose the right tool for I/O-bound and CPU-bound work
- Write an
IAsyncEnumerable<T>, read it withawait foreachand stop work with aCancellationToken - Parallelise computations with
Paralleland PLINQ and avoid blocking traps
In the earlier module you learned to do work that involves waiting with async/await and Task.WhenAll. Now it is time to separate two different problems. I/O-bound work (network, files, databases) spends most of its time waiting — it needs async/await. CPU-bound work (calculations, image processing) keeps the processor busy — Parallel and PLINQ split it across cores. In this lesson you will learn both, plus asynchronous streams and cancellation.
| Kind of work | Example | Tool |
|---|---|---|
| I/O: waiting | an HTTP request, a file, SQL | async/await, Task.WhenAll |
| Data arriving piece by piece | a paged API, a large file | IAsyncEnumerable<T>, await foreach |
| CPU: computation | primes, reports, images | Parallel.For, AsParallel() |
| Long or no longer needed work | the user pressed “Cancel” | CancellationToken |
Asynchronous streams: IAsyncEnumerable
A Task<List<T>> gives a result only when the whole list is ready. When data arrives piece by piece (a paged API, a large file), it is better to process each element as soon as it arrives. An asynchronous stream — IAsyncEnumerable<T> — combines yield return with await, and the consumer reads it with await foreach. In .NET 10 the LINQ methods (Where, Select, ToListAsync) are available for asynchronous streams too, without an extra package.
await foreach (int score in LoadScoresAsync())
Console.WriteLine($"Received {score}");
List<int> high = await LoadScoresAsync().Where(s => s >= 80).ToListAsync();
Console.WriteLine($"80 and above: {string.Join(", ", high)}");
async IAsyncEnumerable<int> LoadScoresAsync()
{
int[] fromServer = [95, 78, 88];
foreach (int score in fromServer)
{
await Task.Delay(200); // each item arrives from the network
yield return score;
}
}Received 95 Received 78 Received 88 80 and above: 95, 88
Cancellation: CancellationToken
The user closed the page, but the server is still preparing the report? Long work must be stoppable. A CancellationTokenSource creates the cancellation signal, and its Token is passed along the chain to every asynchronous method. CancelAfter sets a timer; .NET methods such as Task.Delay, HttpClient and EF Core accept the token and throw OperationCanceledException when it is cancelled. In your own computation loop, call token.ThrowIfCancellationRequested().
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(450));
try
{
await DownloadAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancelled: it took too long");
}
async Task DownloadAsync(CancellationToken token)
{
for (int part = 1; part <= 5; part++)
{
await Task.Delay(300, token); // throws once the token is cancelled
Console.WriteLine($"Part {part} downloaded");
}
}Part 1 downloaded Cancelled: it took too long
For a simple time limit you do not have to change the task itself: WaitAsync(TimeSpan) (since .NET 6) throws TimeoutException if the result does not arrive in time. Note that the real work keeps running in the background — to truly stop it, you must pass a token.
Task<string> answer = FetchAsync("exam results", TimeSpan.FromSeconds(2));
try
{
Console.WriteLine(await answer.WaitAsync(TimeSpan.FromMilliseconds(500)));
}
catch (TimeoutException)
{
Console.WriteLine("Timeout: showing yesterday's cached results");
}
async Task<string> FetchAsync(string what, TimeSpan delay)
{
await Task.Delay(delay);
return $"Fresh {what}";
}Timeout: showing yesterday's cached results
Parallel computation: Parallel and PLINQ
Parallel.For and Parallel.ForEach split a loop's iterations among the threads of the pool, and AsParallel() turns an ordinary LINQ query into PLINQ — parallel LINQ. Below, four heavy calculations — counting primes up to different limits — run at the same time. The main rule: each thread either writes to its own cell (results[i]) or changes a shared variable atomically with Interlocked.
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
int[] limits = [1_000_000, 2_000_000, 3_000_000, 4_000_000];
int[] results = new int[limits.Length];
Parallel.For(0, limits.Length, i => results[i] = CountPrimes(limits[i])); // each task writes its own cell
for (int i = 0; i < limits.Length; i++)
Console.WriteLine($"Primes below {limits[i]:N0}: {results[i]:N0}");
long total = 0;
Parallel.ForEach(Enumerable.Range(1, 1000), n => Interlocked.Add(ref total, n));
Console.WriteLine($"Sum 1..1000 = {total}");
int evenSquares = Enumerable.Range(1, 1000).AsParallel().Count(n => n * n % 2 == 0);
Console.WriteLine($"PLINQ, even squares: {evenSquares}");
static int CountPrimes(int limit)
{
int count = 0;
for (int n = 2; n < limit; n++)
{
bool prime = true;
for (int d = 2; d * d <= n; d++)
if (n % d == 0) { prime = false; break; }
if (prime) count++;
}
return count;
}Primes below 1,000,000: 78,498 Primes below 2,000,000: 148,933 Primes below 3,000,000: 216,816 Primes below 4,000,000: 283,146 Sum 1..1000 = 500500 PLINQ, even squares: 500
long total = 0;
Parallel.ForEach(Enumerable.Range(1, 1000), n =>
{
total += n; // read + add + write from many threads: a race
});
// the result can be less than 500500long total = 0;
Parallel.ForEach(Enumerable.Range(1, 1000), n =>
{
Interlocked.Add(ref total, n); // one atomic operation
});
// always 500500 (or simply: Enumerable.Range(1, 1000).AsParallel().Sum())So how does CPU work fit into asynchronous code? In a graphical interface, send a heavy calculation to a pool thread with await Task.Run(() => CountPrimes(4_000_000)): the window does not freeze, and the result arrives when it is ready. On a server (ASP.NET Core), however, Task.Run usually gains nothing — the work just moves from one pool thread to another. Task.Run is for moving one job to the background; Parallel is for splitting one big job into parts.
ConfigureAwait and blocking traps
Apps with a graphical interface (WPF, WinForms, MAUI) have a synchronization context: after an await, code returns to the UI thread so that it can safely change buttons and text. Library code, however, does not touch the UI, so there you write ConfigureAwait(false) — “run the continuation on any thread”. It is slightly faster and prevents certain deadlocks. ASP.NET Core and console apps have no such context, so application code usually does not need ConfigureAwait.
// library code: it does not need to come back to the caller's UI thread
public static async Task<int> CountLinesAsync(string path, CancellationToken token = default)
{
string[] lines = await File.ReadAllLinesAsync(path, token).ConfigureAwait(false);
return lines.Count(line => line.Length > 0);
}ConfigureAwait(false)// blocks the thread while waiting;
// in a UI app (WPF, WinForms) this can freeze or deadlock
string report = LoadReportAsync().Result;// the thread is free while waiting
string report = await LoadReportAsync();Key points
async/awaitfor I/O-bound work,Paralleland PLINQ for CPU-bound work.IAsyncEnumerable<T>+await foreachprocess items as they arrive; .NET 10 has LINQ for them.- A
CancellationTokenis passed down the chain; cancellation throwsOperationCanceledException, andWaitAsyncgives a time limit. - In parallel code each thread writes to its own cell or uses
Interlocked. - “Async all the way”: no
.Resultor.Wait();ConfigureAwait(false)in library code.
Check yourself
10 questions. Every correct answer earns XP.