Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: Parallel.For splits a loop's iterations across threads · PLINQ runs a LINQ query in parallel, in whatever order it finishes. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

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:

primes.cs: one count, three ways C# · primes/primes.cs
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:

Interactive · stage by stage How Parallel.For counts the primes below 5,000,000 Step down the stages. Each step says which part of the call runs, and on which thread.
How Parallel.For counts the primes below 5,000,000, step 7 of 7. Showing Numbers 2 to 4,999,999, Chunks handed to threads, localInit: a count of 0 (one count of 0 per thread), The body: IsPrime(n), localFinally: one Add, The total (348,513 primes). In focus: The total.next chunkNumbers 2 to 4,999,999Parallel.For(2, Limit, …)Chunks handed to threadsto whichever thread askslocalInit: a count of 0one count of 0 per threadThe body: IsPrime(n)adds to its thread's countlocalFinally: one Addonce on each threadThe total348,513 primes

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. More chunks. A thread that has finished its chunk takes the next one, carrying its count along, until no numbers are left.
  6. 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.
  7. 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

  1. Question 1 of 2 primes.cs gives each thread its own count with localInit and joins them in localFinally. Why not call Interlocked.Increment on one shared total for each prime?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Every prime would then be an atomic operation on one shared variable; local counts need one per thread

    Keep the work of each thread on its own data, and share only the result, once per thread.

  2. Question 2 of 2 Which call runs a body once for each item of an IEnumerable<string> of file names, on several threads?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Parallel.ForEach(files, file => …)

    Parallel.For is for index ranges, Parallel.ForEach for sequences, and PLINQ for queries.

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:

order.cs: the order of PLINQ results, and its exceptions C# · order/order.cs
// 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

  1. Question 1 of 2 order.cs found the 17,984 primes below 200,000 with PLINQ, without AsOrdered. What did it print about them?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The same primes as LINQ, but not in the same order

    PLINQ hands back results in whatever order the partitions produce them, unless AsOrdered asks it to restore the source order.

  2. Question 2 of 2 One element of a PLINQ query throws InvalidDataException. What does the caller catch?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An AggregateException whose InnerExceptions hold the InvalidDataException

    Catch AggregateException around a PLINQ query, and look at InnerExceptions for what actually failed.

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.

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.

In this part:

  • 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.

  1. Question 1 of 6 What does Parallel.For(2, 5_000_000, n => …) do?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: Runs the body for every n from 2 up to 4,999,999, on several threads, and returns when all are done

    The calling thread takes part in the loop, so Parallel.For blocks it until the work is done.

  2. Question 2 of 6 A parallel loop must leave two of eight cores free for a web server on the same machine. How do you limit it?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: new ParallelOptions { MaxDegreeOfParallelism = 6 }, passed to Parallel.For

    PLINQ has the same knob as WithDegreeOfParallelism.

  3. Question 3 of 6 A parallel loop adds each order's amount to a shared decimal total with total += amount. The total is wrong by a different amount every night. What is the smallest correct fix?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: Keep a local total per thread with localInit, and add each one to the shared total under a lock in localFinally

    For a decimal, a lock in localFinally is cheap because it runs only once per thread.

  4. Question 4 of 6 What does AsOrdered() add to a PLINQ query?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: Results in the order of the source, at the cost of buffering and sorting them

    Ask for order only where the result needs it.

  5. Question 5 of 6 Each result of a PLINQ query must be saved, in any order. What is the difference between foreach over the query and query.ForAll(save)?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: foreach merges every result back onto one thread first; ForAll runs save on the query's own threads

    With ForAll the action runs in parallel, so it must be safe to call from several threads.

  6. Question 6 of 6 A teammate fetches 200 web pages with urls.AsParallel().Select(u => client.GetStringAsync(u).Result). What is wrong, and what fits instead?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: It blocks a thread for every page it waits for; use Parallel.ForEachAsync or Task.WhenAll for I/O

    Parallel loops and PLINQ are for CPU-bound work; asynchronous I/O is awaited.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

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 localInit and localFinally, 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 unless AsOrdered() is used, and reports failures in an AggregateException.
  • Parallel loops are for CPU-bound work; work that waits for I/O belongs to Parallel.ForEachAsync or Task.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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.