Skip to content
Educora
Advanced23 min14 / 16

Asynchronous and parallel programming

Tell waiting work from computing work: asynchronous streams, cancellation tokens, `Parallel` and PLINQ, `ConfigureAwait` and blocking traps.

Check yourself
In this lesson you will learn
  • Choose the right tool for I/O-bound and CPU-bound work
  • Write an IAsyncEnumerable<T>, read it with await foreach and stop work with a CancellationToken
  • Parallelise computations with Parallel and 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 workExampleTool
I/O: waitingan HTTP request, a file, SQLasync/await, Task.WhenAll
Data arriving piece by piecea paged API, a large fileIAsyncEnumerable<T>, await foreach
CPU: computationprimes, reports, imagesParallel.For, AsParallel()
Long or no longer needed workthe 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.

C#
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;
    }
}
Expected output
Received 95
Received 78
Received 88
80 and above: 95, 88
Each score is printed as soon as it arrives; in the second part LINQ is applied to the asynchronous stream (.NET 10)

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().

C#
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");
    }
}
Expected output
Part 1 downloaded
Cancelled: it took too long
Cancelled at 450 ms: the first part (300 ms) was downloaded, the second was cut short

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.

C#
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}";
}
Expected output
Timeout: showing yesterday's cached results
The server takes 2 seconds, but we wait only 0.5 seconds

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.

C#
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;
}
Expected output
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
Four calculations run in parallel, yet the results always print in the same order, because each has its own cell
Race condition
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 500500
Atomic addition
long 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.

C#
// 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);
}
A library method: it accepts a token and uses ConfigureAwait(false)
Wrong: blocking with `.Result`
// blocks the thread while waiting;
// in a UI app (WPF, WinForms) this can freeze or deadlock
string report = LoadReportAsync().Result;
Right: `await`
// the thread is free while waiting
string report = await LoadReportAsync();

Key points

  • async/await for I/O-bound work, Parallel and PLINQ for CPU-bound work.
  • IAsyncEnumerable<T> + await foreach process items as they arrive; .NET 10 has LINQ for them.
  • A CancellationToken is passed down the chain; cancellation throws OperationCanceledException, and WaitAsync gives a time limit.
  • In parallel code each thread writes to its own cell or uses Interlocked.
  • “Async all the way”: no .Result or .Wait(); ConfigureAwait(false) in library code.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Which tool fits counting primes on all cores?