π Lesson 2.3: Data Parallelism: Parallel and PLINQ
When you need to apply the same operation to a large collection, the TPL and PLINQ can spread that work across every CPU core β often with a one-line change. But parallelism has real costs, so knowing when to use it matters as much as how.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain data parallelism and how it differs from task parallelism
- Use
Parallel.ForandParallel.ForEach - Aggregate results safely from parallel loops
- Query in parallel with PLINQ (
AsParallel) - Judge when parallelism helps β and when it hurts
Estimated Time: 60 minutes
Project: Parallelize a data-processing workload and measure the speedup.
In This Lesson
Data Parallelism
In Lesson 2.1 you used Task.Run for task parallelism β running different operations at once. Data parallelism is a common special case: applying the same operation to many data items, split across cores.
π Definition
Data parallelism: partitioning a collection into chunks, processing each chunk on a separate core in parallel, then combining the results. The Task Parallel Library (TPL) and PLINQ do the partitioning and coordination for you.
You did this manually in Lesson 2.1 (splitting a range into four Task.Run chunks). The tools in this lesson automate that pattern.
Parallel.For and Parallel.ForEach
Parallel.For and Parallel.ForEach look like ordinary loops but distribute their iterations across pool threads. They block until all iterations finish.
using System.Threading.Tasks;
// Like: for (int i = 0; i < 1000; i++) Process(i); β but across cores
Parallel.For(0, 1000, i =>
{
Process(i);
});
// Like foreach β over any IEnumerable
var files = Directory.GetFiles("data");
Parallel.ForEach(files, file =>
{
ProcessFile(file);
});
You can cap the parallelism with ParallelOptions β useful to leave cores free or limit resource pressure:
var options = new ParallelOptions { MaxDegreeOfParallelism = 4 };
Parallel.ForEach(items, options, item => Process(item));
β οΈ Iterations run in an unpredictable order β and concurrently
The loop body runs on multiple threads at once, in no guaranteed order. So the body must be safe to run concurrently: don't rely on iteration order, and don't touch shared mutable state without the synchronization from Lesson 2.2. A plain total += Work(i); inside a parallel loop is a race condition.
π‘ For async work: Parallel.ForEachAsync
The classic Parallel.ForEach is for CPU-bound bodies. For I/O-bound work that needs await inside, modern .NET offers Parallel.ForEachAsync, which runs async iterations with a controlled degree of concurrency β the right tool for, say, calling an API for each of many items.
Aggregating Safely
A very common need is to combine results from every iteration β a sum, a count, a list. Doing it naively creates a race. There are two clean approaches.
Approach 1: a thread-safe accumulator
Use Interlocked or a concurrent collection (Lesson 2.2):
long total = 0;
Parallel.For(0, 1_000_000, i =>
{
Interlocked.Add(ref total, Work(i)); // atomic β safe
});
Approach 2: thread-local partials (faster)
Better for hot loops: each thread accumulates into its own local total, and only combines at the end β minimizing contention. Parallel.For has an overload built exactly for this:
long total = 0;
Parallel.For(
0, 1_000_000,
() => 0L, // (1) initialize each thread's local total
(i, state, localTotal) => // (2) body: add into the LOCAL total
{
return localTotal + Work(i);
},
localTotal => // (3) combine each thread's local into the global
{
Interlocked.Add(ref total, localTotal);
});
Console.WriteLine(total);
β Why thread-local wins
The atomic Interlocked.Add in the first approach happens a million times β each with cross-core synchronization cost. The thread-local version does the expensive atomic step only once per thread (a handful of times total), doing the bulk of the work with a fast, unsynchronized local variable. Less contention, more speedup.
PLINQ
Parallel LINQ (PLINQ) parallelizes LINQ queries with a single addition: .AsParallel(). The familiar operators (Where, Select, aggregates) then run across cores.
// Sequential LINQ (Intermediate)
var primes = numbers.Where(IsPrime).ToList();
// PLINQ β same query, across cores
var primesParallel = numbers.AsParallel().Where(IsPrime).ToList();
PLINQ shines for CPU-heavy per-item work over a large source. It handles partitioning, threading, and merging for you:
double sum = Enumerable.Range(1, 10_000_000)
.AsParallel()
.Where(n => n % 3 == 0)
.Select(n => Math.Sqrt(n))
.Sum();
β οΈ Order is not preserved by default
Because chunks finish at different times, PLINQ may return results in a different order than the source. If order matters, add .AsOrdered() β but note it costs performance, since results must be re-sequenced. Only ask for ordering when you truly need it.
π‘ PLINQ vs. Parallel.ForEach
Use PLINQ when you're transforming/filtering/aggregating a sequence into a result (it composes like LINQ). Use Parallel.ForEach when you're performing an action (side effect) per item, or need finer control over the loop. Both sit on the TPL and thread pool.
When Parallelism Helps
Parallelism is not free β partitioning, scheduling across threads, and merging all add overhead. It pays off only when the parallelizable work is large enough to dwarf that overhead.
| Parallelism tends to help whenβ¦ | β¦and hurts whenβ¦ |
|---|---|
| Work per item is substantial (CPU-heavy) | Work per item is trivial (overhead dominates) |
| The collection is large | The collection is small |
| Items are independent | Items depend on each other / share state |
| The work is CPU-bound | The work is I/O-bound (use async instead) |
β οΈ Measure β don't assume
Parallelizing a cheap loop can be slower than a sequential one, because the coordination overhead outweighs the tiny per-item cost. Always benchmark with a real workload (you'll learn BenchmarkDotNet in Lesson 4.3) before concluding that parallelism helped.
π‘ Rule of thumb: Reach for data parallelism when you have a lot of independent, CPU-heavy items. For I/O-bound work, stick withasync/awaitandTask.WhenAll(orParallel.ForEachAsync) β spawning CPU threads to wait on I/O helps nothing.
Exercise & Quiz
ποΈ Exercise: Parallel Image-Brightness (Simulated)
Objective: Parallelize a CPU-heavy per-item computation and compare against sequential.
Instructions:
- Create a new project called
Parallelism. - Simulate CPU-heavy work:
double HeavyCompute(int n)that does a small math loop (e.g. sumsMath.Sqrtof many values) so each call is non-trivial. - Over
Enumerable.Range(1, 2000), compute the total sequentially with LINQ and time it withStopwatch. - Do the same with PLINQ (
.AsParallel()) and time it. - Also implement the sum with a
Parallel.Forusing the thread-local partials overload. Confirm all three totals match and compare timings.
Starter Code:
using System.Diagnostics;
var sw = Stopwatch.StartNew();
double seq = Enumerable.Range(1, 2000).Select(HeavyCompute).Sum();
sw.Stop();
Console.WriteLine($"Sequential: {seq:F1} in {sw.ElapsedMilliseconds} ms");
// TODO: PLINQ version (AsParallel) + timing
// TODO: Parallel.For with thread-local partials + timing
static double HeavyCompute(int n)
{
double acc = 0;
for (int i = 0; i < 50_000; i++) acc += Math.Sqrt(n + i);
return acc;
}
π‘ Hint
PLINQ: Enumerable.Range(1, 2000).AsParallel().Select(HeavyCompute).Sum(). Thread-local Parallel.For: init () => 0.0, body returns local + HeavyCompute(i), finalizer does lock or a double-safe combine (use a lock, since Interlocked.Add doesn't take double).
β Solution
using System.Diagnostics;
var sw = Stopwatch.StartNew();
double seq = Enumerable.Range(1, 2000).Select(HeavyCompute).Sum();
sw.Stop();
Console.WriteLine($"Sequential: {seq:F1} in {sw.ElapsedMilliseconds} ms");
sw.Restart();
double plinq = Enumerable.Range(1, 2000).AsParallel().Select(HeavyCompute).Sum();
sw.Stop();
Console.WriteLine($"PLINQ: {plinq:F1} in {sw.ElapsedMilliseconds} ms");
sw.Restart();
double total = 0;
object gate = new();
Parallel.For(
1, 2001,
() => 0.0,
(i, state, local) => local + HeavyCompute(i),
local => { lock (gate) { total += local; } });
sw.Stop();
Console.WriteLine($"Parallel.For: {total:F1} in {sw.ElapsedMilliseconds} ms");
static double HeavyCompute(int n)
{
double acc = 0;
for (int i = 0; i < 50_000; i++) acc += Math.Sqrt(n + i);
return acc;
}
All three totals match; on a multi-core machine the PLINQ and Parallel.For versions run several times faster than sequential.
π― Quick Quiz
Question 1: What is data parallelism?
Question 2: Inside a Parallel.ForEach body, doing total += Work(item); on a shared variable isβ¦
Question 3: When is parallelism likely to hurt performance?
Summary
π Key Takeaways
- Data parallelism partitions a collection across cores; the TPL and PLINQ automate the split/process/combine pattern.
Parallel.For/Parallel.ForEachrun loop bodies concurrently (unordered) and block until done; cap withMaxDegreeOfParallelism.- Aggregate safely with
Interlocked/concurrent collections, or better, the thread-local partials overload to minimize contention. - PLINQ (
.AsParallel()) parallelizes LINQ; add.AsOrdered()only if you need source order (it costs performance). - Parallelism has overhead β it helps for large, CPU-heavy, independent workloads; measure, and use async for I/O-bound work.
π Additional Resources
π What's Next?
That completes Module 2 β you can run work correctly and efficiently across cores! In Module 3, we return to asynchrony in depth. First: Lesson 3.1: Cancellation and Timeouts, giving async operations a way to stop.
π Module 2 complete!
Threads, safety, and data parallelism β you can harness every core. Next: advanced async.