C# & .NET (C# 14, .NET 10 LTS) Module 16 – Threads, parallelism and synchronisation
Parallel.For, Parallel.ForEach and PLINQ
Speed up CPU-bound C# loops with Parallel.For, Parallel.ForEach and PLINQ, measure the speed-up on 18 processors, and avoid the shared-state bugs.
What you will learn
- Split a CPU-bound loop across threads with Parallel.For, Parallel.ForEach or PLINQ, keeping a total per thread instead of a shared one
- Choose AsOrdered, ForAll and a degree of parallelism for a PLINQ query, and handle its AggregateException
- Measure a speed-up and explain it with Amdahl's law and the way the work is partitioned
- Find and fix shared-state bugs, such as List<T>.Add inside a parallel loop, and recognise work too small to share
- Partition an image filter by rows so that its parallel result matches the one-thread result byte for byte
Before you start
What you’ll learn · Before you start
What you will learn
- Split a CPU-bound loop across threads with Parallel.For, Parallel.ForEach or PLINQ, keeping a total per thread instead of a shared one
- Choose AsOrdered, ForAll and a degree of parallelism for a PLINQ query, and handle its AggregateException
- Measure a speed-up and explain it with Amdahl's law and the way the work is partitioned
- Find and fix shared-state bugs, such as List<T>.Add inside a parallel loop, and recognise work too small to share
- Partition an image filter by rows so that its parallel result matches the one-thread result byte for byte
Before you start
On this page
Counting the primes below 5,000,000 needed 353 ms of processor time in a for loop on the 18-processor computer these
examples ran on, and 383 ms with Parallel.For, which spread the work over many processors and finished in 56 ms. A
parallel loop does a little more work in all, and gets it done sooner.
You will spread loops and queries over threads, measure what that buys, and keep shared state from breaking them.
Parallel.For splits a loop’s iterations across threads
Parallel.For takes a range of integers and a body, runs the body for every number on several threads, and returns when
all of them are done; Parallel.ForEach does the same for the items of any sequence. Both are data parallelism: Running the same operation on many pieces of data at the same time, each piece independent of the others, as Parallel.For, Parallel.ForEach and PLINQ do with the iterations of a loop or the items of a query.: the same
operation on many pieces of data, each independent of the others. primes.cs counts the primes below 5,000,000 three
ways, with a for loop, Parallel.For and PLINQ, keeping the fastest of five runs of each:
using System.Diagnostics;
// Count the primes below 5,000,000 three ways, five times each, and keep the fastest run of each: on a computer that
// is busy with other work, the fastest run is the one that other programs disturbed least. Each line also shows the
// processor time the run used, on all its threads together.
const int Limit = 5_000_000;
Console.WriteLine($"processors: {Environment.ProcessorCount}");
Run loop = Fastest(() =>
{
int primes = 0;
for (int n = 2; n < Limit; n++)
{
if (IsPrime(n))
{
primes++;
}
}
return primes;
});
Run parallelFor = Fastest(() =>
{
int primes = 0;
Parallel.For(
2,
Limit,
() => 0, // localInit: each thread starts its own count at 0
(n, _, local) => IsPrime(n) ? local + 1 : local, // the body updates only that thread's count
local => Interlocked.Add(ref primes, local)); // localFinally: one shared addition per thread
return primes;
});
Run plinq = Fastest(() => Enumerable.Range(2, Limit - 2).AsParallel().Count(IsPrime));
bool agree = loop.Count == parallelFor.Count && parallelFor.Count == plinq.Count;
Console.WriteLine($"primes below {Limit:N0}: {loop.Count:N0}; all three agree: {agree}");
Console.WriteLine($"{"",-28}{"wall time",10}{"processor time",16}");
Console.WriteLine($"{"for loop:",-28}{loop.WallMs,7:F0} ms{loop.CpuMs,13:F0} ms");
Console.WriteLine($"{"Parallel.For, local counts:",-28}{parallelFor.WallMs,7:F0} ms{parallelFor.CpuMs,13:F0} ms {loop.WallMs / parallelFor.WallMs:F1}x faster");
Console.WriteLine($"{"PLINQ AsParallel().Count:",-28}{plinq.WallMs,7:F0} ms{plinq.CpuMs,13:F0} ms {loop.WallMs / plinq.WallMs:F1}x faster");
static bool IsPrime(int n)
{
if (n < 2)
{
return false;
}
if (n % 2 == 0)
{
return n == 2;
}
for (int d = 3; d * d <= n; d += 2)
{
if (n % d == 0)
{
return false;
}
}
return true;
}
static Run Fastest(Func<int> work)
{
work(); // a first run that is not timed: the code is compiled and the thread pool has its threads
Run best = new(0, double.MaxValue, 0);
Process self = Process.GetCurrentProcess();
for (int i = 0; i < 5; i++)
{
self.Refresh();
TimeSpan cpuBefore = self.TotalProcessorTime;
var watch = Stopwatch.StartNew();
int count = work();
double wall = watch.Elapsed.TotalMilliseconds;
self.Refresh();
double cpu = (self.TotalProcessorTime - cpuBefore).TotalMilliseconds;
if (wall < best.WallMs)
{
best = new Run(count, wall, cpu);
}
}
return best;
}
record Run(int Count, double WallMs, double CpuMs); Output
processors: 18
primes below 5,000,000: 348,513; all three agree: True
wall time processor time
for loop: 1032 ms 353 ms
Parallel.For, local counts: 56 ms 383 ms 18.4x faster
PLINQ AsParallel().Count: 116 ms 447 ms 8.9x faster
This output changes from run to run: the times change from run to run, with what else the computer is doing
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run primes.cs
All three found 348,513 primes. By the clock, Parallel.For was 18.4 times faster and PLINQ 8.9 times, but that flatters them: the loop, on one thread, had a processor for only 353 of its 1,032 ms, and other programs had it for the rest. The parallel versions used a little more processor time, 383 and 447 ms, to hand out the work and join the threads, and finished sooner by using many processors at once. Each thread counts its own primes and adds its count to the total once, at the end. Step through the stages of that call:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- One call, one range. Parallel.For gets the range 2 to 4,999,999 and a body to run for each number. The calling thread joins in, and pool threads are added as the loop runs.
- Chunks, not single numbers. The numbers are handed out in chunks, to whichever thread asks next. A thread that finishes a cheap chunk early simply asks again, so threads with slow chunks do not hold the others up.
- A count per thread. localInit runs once on each thread that takes part and gives it its own count, 0. No other thread ever sees that count.
- The body runs without a lock. For each number, the body returns its thread's count plus 1 for a prime, or the count unchanged. Nothing is shared, so there is nothing to lock and nothing to lose.
- More chunks. A thread that has finished its chunk takes the next one, carrying its count along, until no numbers are left.
- One addition per thread. localFinally runs once on each thread at the end and adds its count to the total with Interlocked.Add: a handful of shared additions instead of one per prime.
- The total. When every thread has added its count, Parallel.For returns, and the total is the same 348,513 that the for loop found.
The overload with localInit and localFinally is the pattern for any total: start a value per thread, update it in
the body without a lock, and combine the values once per thread, as Microsoft’s example of a loop with thread-local
variables does. ParallelOptions adds a MaxDegreeOfParallelism and a cancellation token.
A shared total in the body
Parallel.For(0, n, i => total += Price(i)) races exactly like value++ in the lesson on locks, and an
Interlocked call for every item makes all the threads queue for one variable. Keep a total per thread with
localInit and localFinally.
Checkpoint
Check: Parallel.For and per-thread totals
PLINQ runs a LINQ query in parallel, in whatever order it finishes
PLINQ is LINQ with one more call: AsParallel() on the source makes the rest of the query run on several threads, by
splitting the source into partitions and merging their results. order.cs finds the primes below 200,000 with LINQ,
with PLINQ and with PLINQ and AsOrdered, then throws an exception from inside a query:
// The primes below 200,000, found by LINQ, by PLINQ and by PLINQ with AsOrdered.
int[] numbers = [.. Enumerable.Range(2, 199_998)];
List<int> linq = [.. numbers.Where(IsPrime)];
List<int> plinq = [.. numbers.AsParallel().Where(IsPrime)];
List<int> ordered = [.. numbers.AsParallel().AsOrdered().Where(IsPrime)];
Console.WriteLine($"LINQ: {linq.Count:N0} primes, first five {string.Join(", ", linq.Take(5))}");
Console.WriteLine($"PLINQ: same primes: {plinq.Order().SequenceEqual(linq)}, same order: {plinq.SequenceEqual(linq)}");
Console.WriteLine($"PLINQ with AsOrdered: same primes: {ordered.Order().SequenceEqual(linq)}, same order: {ordered.SequenceEqual(linq)}");
// An exception thrown inside a PLINQ query reaches the caller inside an AggregateException.
try
{
int largest = numbers.AsParallel().Select(n => n == 150_000 ? throw new InvalidDataException($"no price for item {n}") : n).Max();
Console.WriteLine(largest);
}
catch (AggregateException e)
{
string kinds = string.Join(", ", e.InnerExceptions.Select(inner => $"{inner.GetType().Name} ({inner.Message})"));
Console.WriteLine($"{e.GetType().Name} holding: {kinds}");
}
static bool IsPrime(int n)
{
if (n % 2 == 0)
{
return n == 2;
}
for (int d = 3; d * d <= n; d += 2)
{
if (n % d == 0)
{
return false;
}
}
return true;
} Output
LINQ: 17,984 primes, first five 2, 3, 5, 7, 11 PLINQ: same primes: True, same order: False PLINQ with AsOrdered: same primes: True, same order: True AggregateException holding: InvalidDataException (no price for item 150000)
This output changes from run to run: the order in which the partitions finish changes from run to run
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run order.cs
PLINQ found the same 17,984 primes as LINQ, in another order: results come back as the partitions produce them.
AsOrdered() restored the source order, which Microsoft’s introduction to PLINQ says usually costs extra work, because
the results are buffered and sorted. The exception arrived inside an AggregateException, because several threads may
fail at once. PLINQ also decides for itself: some shapes of query run on one thread unless
WithExecutionMode(ParallelExecutionMode.ForceParallelism) says otherwise. A foreach over a query merges every
result back onto the thread that runs the loop; query.ForAll(save) skips that merge and runs save on the query’s
threads, in no order, so save must be safe to call from several threads at once. The three, side by side:
| Aspect | Parallel.For | Parallel.ForEach | PLINQ |
|---|---|---|---|
| Input | a range of integers | any sequence | any sequence, as a query |
| Result | none: the body does the work | none | a value or a sequence |
| Order of the work | none | none | none, unless AsOrdered |
| Limit the threads | MaxDegreeOfParallelism |
MaxDegreeOfParallelism |
WithDegreeOfParallelism |
Two ideas explain how far a parallel loop can go. Amdahl's law: The rule that the part of a program that cannot run in parallel limits its speed-up, however many processors are added: the serial part takes as long on many processors as on one. says how much the serial part of a program limits its speed-up. partitioning: How a parallel loop or query splits its input among its threads: in fixed ranges decided at the start, or in chunks handed to whichever thread asks next, which evens out work of uneven cost. is how a parallel loop splits its input among its threads, in fixed ranges or in chunks handed out on demand.
Waiting is not data parallelism
Fetching 200 pages with PLINQ blocks a thread for every page it waits for. For work that waits, use
Parallel.ForEachAsync or Task.WhenAll, from the lessons on throttling and on composing tasks.
Key idea
Parallel loops and PLINQ pay when each piece of work is independent and costs far more than handing it out, and only if the threads keep their own state until the end.
Checkpoint
Check: PLINQ, ordering and exceptions
Measured speed-up, Amdahl's law and partitioning Pro: opens with a Pro, Premium or Ultimate pass
A speed-up below the number of processors has causes you can measure. This part runs a job with a known serial part on 1 to 18 threads, fits Amdahl’s law to the result, and counts how unevenly fixed ranges split the work of the prime count.
In this part:
- A job with a known serial part, on 1 to 18 threads
- Amdahl’s law, fitted to the measured speed-up
- Fixed ranges and uneven work, counted
1 diagram · 1 runnable example (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Shared-state bugs reproduced, and work too small to share Pro: opens with a Pro, Premium or Ultimate pass
A parallel loop that touches shared state is wrong in a way that changes from run to run, and a parallel loop over cheap work is slower than the plain loop. This part reproduces both, with the fixes and their numbers.
- Shared lists and totals inside Parallel.For, reproduced
- Work too small to share, measured
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
A lab: a grayscale filter that must match byte for byte Pro: opens with a Pro, Premium or Ultimate pass
An image filter is the classic parallel loop: every pixel is computed on its own, and the result must be exactly what one thread computes. This lab gives you a parallel grayscale filter that loses pixels on large images, and four checks to make pass.
In this part:
- Four checks of a grayscale filter, and the two that fail
- Rows as the unit of work, and output in its own place
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The self-check covers the first two sections, and begins with the idea your answers so far found hardest:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · When does Parallel.For make a loop slower?
When each iteration does too little work. Parallel.For has a cost of its own: it splits the range into chunks, hands
them to threads, and joins the threads at the end. If the body is a few operations, that cost is larger than the work,
and the plain loop wins; Microsoft’s guide to the pitfalls says loops with few
iterations and fast bodies are unlikely to speed up, and to measure. It is also slower when the iterations share state
that needs a lock or an Interlocked call each time, because the threads then queue for it, and when the body
calls something that serialises them, such as Console.WriteLine. Parallel loops pay for independent work that costs
much more than handing it out.
-
Mid to senior
A developer replaced a for loop over 2,000 orders with Parallel.For, and the endpoint got slower while its processor use doubled. When does this happen, and how do you decide whether to parallelise a loop?
-
Senior to staff
A CPU-bound batch job runs 6 times faster on 16 cores than on 1, and adding cores barely helps. Explain what limits it, and what you would measure to raise the speed-up.
-
Fresher to mid
A nightly job collects results with results.Add(x) inside Parallel.ForEach. Its totals are off by a few hundred on some nights, and about once a week it fails with an IndexOutOfRangeException. Explain both and fix them.
Key takeaways
- Parallel.For runs a body for every number of a range on several threads, and Parallel.ForEach does the same for a sequence; both return when every iteration is done.
- Keep a value per thread with
localInitandlocalFinally, and combine the values once per thread, instead of sharing a total in the body. - PLINQ runs a query in parallel with
AsParallel(), returns results in no particular order unlessAsOrdered()is used, and reports failures in anAggregateException. - Parallel loops are for CPU-bound work; work that waits for I/O belongs to
Parallel.ForEachAsyncorTask.WhenAll.
Terms in this lesson
- Amdahl's law
- The rule that the part of a program that cannot run in parallel limits its speed-up, however many processors are added: the serial part takes as long on many processors as on one.
- data parallelism
- Running the same operation on many pieces of data at the same time, each piece independent of the others, as Parallel.For, Parallel.ForEach and PLINQ do with the iterations of a loop or the items of a query.
- partitioning
- How a parallel loop or query splits its input among its threads: in fixed ranges decided at the start, or in chunks handed to whichever thread asks next, which evens out work of uneven cost.
References
- Data parallelism (Task Parallel Library) (Microsoft Learn)
- Introduction to PLINQ (Microsoft Learn)
- Understanding speedup in PLINQ (Microsoft Learn)
- Potential pitfalls in data and task parallelism (Microsoft Learn)
- How to: Write a Parallel.For loop with thread-local variables (Microsoft Learn)
- Custom partitioners for PLINQ and TPL (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress