Using Parallel.For
Overview
A normal for loop runs its iterations one after another on a single thread. If the loop body is slow and each iteration is independent of the others, the machine is only using one of its processor cores while the rest sit idle.
Parallel.For(), found in the System.Threading.Tasks namespace, solves this problem. It takes a range of integer values and runs the loop body for every value in that range, spreading the work over several threads. The range includes the starting value and excludes the ending value, which is exactly how array and list indexes work, so Parallel.For(0, items.Length, ...) visits every element exactly once.
The method splits the range into chunks and hands each chunk to a different thread, so the iterations do not run in order. The call itself is blocking: the thread that calls Parallel.For() waits until every iteration has finished (or until the loop is cancelled).
This article follows an easy-to-harder path with five examples:
- Step 1: the simplest loop over a range of indexes.
- Step 2: limiting how many iterations may run together with
ParallelOptions.MaxDegreeOfParallelism. - Step 3: stopping the loop from the outside with a
CancellationToken. - Step 4: a bad example with a shared
+= counter and a good example with Interlocked.Add, showing that the loop body is not synchronized for you. - Step 5: a normal
for loop and a Parallel.For loop doing the same CPU-heavy work, with their run times compared.
Features and Design
The simplest overload of the method looks like this:
Parallel.For(int fromInclusive, int toExclusive, Action<int> body)
fromInclusive – the first index of the range; this value is used.toExclusive – the loop stops before this value; this value is not used.body – an Action<int> delegate that is called exactly once for every index in the range.
The method returns a ParallelLoopResult structure. Its IsCompleted property is true when every iteration ran and the loop was not stopped early. The delegate can also take a second, optional parameter of type ParallelLoopState, which allows the loop to exit early and to detect unhandled exceptions.
An overload that takes a ParallelOptions instance lets you change the default behavior. The class exposes three properties:
MaxDegreeOfParallelism – the maximum number of iterations allowed to run at the same time. The default is -1, which means "no upper limit, let .NET decide". Setting it to 1 makes the loop behave like a sequential for loop.CancellationToken – a token that can stop the loop from the outside. The default is CancellationToken.None, which means the loop is never cancelled.TaskScheduler – a custom scheduler, or null to use the default scheduler.
Two behaviors are important to remember:
- When the token is cancelled,
Parallel.For() throws an OperationCanceledException directly; it is not wrapped in an AggregateException. Exceptions thrown by the loop body, on the other hand, are collected into an AggregateException. - The
Parallel class synchronizes its own internal bookkeeping, but it provides no synchronization at all for the data your delegate touches. Any shared variable, collection or file accessed from the loop body still needs a lock, an Interlocked method, or a thread-safe collection.
When Parallel.For Helps
Parallel.For() is designed for ranges of independent, CPU-bound work. Typical use cases are data transformation over large sets, matrix computations, image processing, simulations, data aggregation and analysis, rendering, and large-scale mathematical computations such as searching for prime numbers.
Step 1: Code Example – The Simplest Parallel.For Loop
public static void RunBasics()
{
Console.WriteLine("=== Step 1: The simplest Parallel.For loop ===");
// Every index writes into its OWN slot of this array.
// Because no two iterations ever touch the same slot, no locking is needed.
string[] labels = new string[5];
// Parallel.For(fromInclusive, toExclusive, body)
// fromInclusive: 0 -> the first index the loop uses.
// toExclusive: 5 -> the loop stops BEFORE this value, so the indexes are 0,1,2,3,4.
// This "end is excluded" rule is exactly how array indexes work,
// so labels.Length can be passed directly as the end value.
// body: an Action<int> delegate that is called once for every index.
// 5 iterations were chosen only to keep the console output short and readable.
ParallelLoopResult result = Parallel.For(0, labels.Length, index =>
{
// Environment.CurrentManagedThreadId shows which thread ran this index.
// You will usually see a few different ids, and the ids can change on every run.
labels[index] = $"Index {index} was handled by thread {Environment.CurrentManagedThreadId}";
// Printing here would come out in a mixed order, because the iterations
// run at the same time. We store the text instead and print it below in
// index order, so the output is deterministic and easy to compare.
});
// Parallel.For blocks the calling thread until every iteration is finished,
// so at this point the array is completely filled.
foreach (string label in labels)
{
Console.WriteLine(label);
}
// ParallelLoopResult.IsCompleted is true when the loop ran all iterations
// and was not stopped early (for example by ParallelLoopState.Break()).
Console.WriteLine($"Loop completed all iterations: {result.IsCompleted}");
Console.WriteLine();
}
How It Works
- An array of five strings is created. Index
0 writes to slot 0, index 1 to slot 1, and so on, so two iterations never touch the same memory. This is why no lock is needed. Parallel.For(0, labels.Length, ...) produces the indexes 0, 1, 2, 3 and 4. The end value 5 is never passed to the delegate.- Each iteration records the id of the thread that ran it. Several different ids appear, and the main thread (usually id 1) also takes part, because the calling thread helps with the work instead of just waiting.
- The array is printed after the loop. Since
Parallel.For() blocks until all iterations are done, every slot is already filled. result.IsCompleted reports True because nothing stopped the loop early.
Sample Output
=== Step 1: The simplest Parallel.For loop ===
Index 0 was handled by thread 1
Index 1 was handled by thread 6
Index 2 was handled by thread 10
Index 3 was handled by thread 8
Index 4 was handled by thread 4
Loop completed all iterations: True
The thread ids change on every run and on every machine; the index order in the printed list is always 0 to 4, because the printing happens after the loop.
Step 2: Code Example – Limiting Parallelism with ParallelOptions
public static void RunWithMaxDegreeOfParallelism()
{
Console.WriteLine("=== Step 2: Limiting parallelism with ParallelOptions ===");
// ParallelOptions lets you change how the loop behaves. It has three properties:
// - MaxDegreeOfParallelism: the highest number of iterations allowed to run together.
// -1 (the default) means "no limit, let .NET decide".
// 1 makes the loop behave like a normal sequential for loop.
// A small number is useful when the work uses a limited resource,
// for example a database or an external service that must not be flooded.
// - CancellationToken: lets you stop the loop from outside (see Step 3).
// - TaskScheduler: a custom scheduler, or null to use the default one.
ParallelOptions options = new ParallelOptions
{
// 2 was chosen so the limit is easy to see: never more than two
// iterations are "inside" the loop body at the same time.
MaxDegreeOfParallelism = 2
};
// Counts how many iterations are running right now, and the highest value seen.
// Both are shared by all iterations, so every change uses Interlocked (see Step 4).
int runningCount = 0;
int highestRunningCount = 0;
// 6 iterations with a limit of 2 means the work is done in about 3 waves.
Parallel.For(0, 6, options, index =>
{
// Interlocked.Increment adds 1 in a single, uninterruptible step and
// returns the new value, so two threads can never lose an update.
int current = Interlocked.Increment(ref runningCount);
// Remember the highest number of iterations that ever ran together.
// A simple "if (current > highest) highest = current;" would not be safe here.
InterlockedMax(ref highestRunningCount, current);
// 100 ms of fake work makes the overlap easy to observe; without a delay
// an iteration would finish before the next one even starts.
Thread.Sleep(100);
Interlocked.Decrement(ref runningCount);
});
Console.WriteLine($"MaxDegreeOfParallelism was set to: {options.MaxDegreeOfParallelism}");
Console.WriteLine($"Highest number of iterations seen running together: {highestRunningCount}");
Console.WriteLine();
}
// Stores newValue into target only while it is bigger than the value already there.
// target: the shared field that keeps the maximum.
// newValue: the candidate value produced by one iteration.
// Interlocked.CompareExchange writes only if nobody changed the field in the meantime,
// which is why the loop repeats until the write succeeds.
private static void InterlockedMax(ref int target, int newValue)
{
int currentValue = target;
while (newValue > currentValue)
{
int seenValue = Interlocked.CompareExchange(ref target, newValue, currentValue);
if (seenValue == currentValue)
{
return;
}
currentValue = seenValue;
}
}
How It Works
- A
ParallelOptions instance with MaxDegreeOfParallelism = 2 is passed to the loop, so at most two iterations may be inside the delegate at any moment. - Each iteration increases a shared
runningCount when it starts and decreases it when it ends. Interlocked.Increment and Interlocked.Decrement change the value in one uninterruptible step, so no update is lost. InterlockedMax remembers the largest value runningCount ever reached. It uses Interlocked.CompareExchange, which writes the new value only if the field still holds the value the method last read; otherwise it tries again with the fresh value.- The 100 ms sleep represents work. Without it, an iteration would finish before the next one started and the overlap could not be measured.
- The reported maximum is
2, which proves the limit was respected. Removing the options object would let .NET use as many threads as it wants, and the value would be higher.
Sample Output
=== Step 2: Limiting parallelism with ParallelOptions ===
MaxDegreeOfParallelism was set to: 2
Highest number of iterations seen running together: 2
Step 3: Code Example – Cancelling a Parallel.For Loop
public static void RunWithCancellation()
{
Console.WriteLine("=== Step 3: Cancelling a Parallel.For loop ===");
// A CancellationTokenSource creates the token and decides when it is cancelled.
using CancellationTokenSource cancellationSource = new CancellationTokenSource();
ParallelOptions options = new ParallelOptions
{
CancellationToken = cancellationSource.Token,
// Only 2 iterations run together here. This keeps the example easy to
// follow, and it also leaves free thread pool threads for the timer that
// triggers the cancellation. If every thread were blocked inside the loop
// body, the cancellation itself would be noticed much later.
MaxDegreeOfParallelism = 2
};
// CancelAfter(150) cancels automatically after 150 milliseconds.
// Alternatives: call Cancel() yourself (for example when a user presses a
// button), or pass CancellationToken.None to say "this loop is never cancelled".
cancellationSource.CancelAfter(150);
// 100 iterations that each sleep 100 ms and run two at a time would need about
// 5 seconds, so the 150 ms cancellation is guaranteed to arrive first and most
// iterations are never started.
const int iterationCount = 100;
int finishedIterationCount = 0;
try
{
Parallel.For(0, iterationCount, options, index =>
{
Thread.Sleep(100);
Interlocked.Increment(ref finishedIterationCount);
});
}
catch (OperationCanceledException)
{
// Important: cancellation throws OperationCanceledException directly.
// It is NOT wrapped in an AggregateException, unlike normal exceptions
// thrown by the loop body.
Console.WriteLine("The loop was cancelled, so an OperationCanceledException was thrown.");
}
// The exact number changes on every run and on every machine, but it is
// always much smaller than the total number of iterations.
Console.WriteLine($"Iterations finished before the cancellation: {finishedIterationCount} of {iterationCount}");
Console.WriteLine($"Cancellation was requested: {cancellationSource.Token.IsCancellationRequested}");
Console.WriteLine();
}
How It Works
- A
CancellationTokenSource creates the token and decides when the cancellation happens. CancelAfter(150) starts a timer that cancels the token after 150 milliseconds. - The token is placed into
ParallelOptions.CancellationToken. From that moment the loop checks the token while it works and refuses to start further iterations once it is cancelled. - The loop is asked to run 100 iterations, each sleeping 100 ms, two at a time. That would take roughly five seconds, so the cancellation always wins and only a handful of iterations complete.
Parallel.For() then throws OperationCanceledException. Note that it is caught directly, not through an AggregateException. Iterations already running are not killed; they finish their current work first.- The counter shows how many iterations completed before the loop stopped. This number changes from run to run, which is normal for concurrent code.
Sample Output
=== Step 3: Cancelling a Parallel.For loop ===
The loop was cancelled, so an OperationCanceledException was thrown.
Iterations finished before the cancellation: 4 of 100
Cancellation was requested: True
Step 4: Code Example – Shared Counter, Plain += (bad) vs Interlocked (good)
public static void RunUnsafeCounterVersusInterlocked()
{
Console.WriteLine("=== Step 4: Shared counter, plain += (bad) vs Interlocked (good) ===");
// 100000 iterations that each add 1 give an expected total of 100000.
// A large number is needed so the threads really collide; with only a few
// iterations the problem would often stay invisible.
const int iterationCount = 100_000;
// --- BAD EXAMPLE: "+=" is not a single operation ---
// "total += 1" is really three steps: read the field, add one, write it back.
// Two threads can read the same old value and both write the same new value,
// so one of the updates is simply lost.
int unsafeTotal = 0;
Parallel.For(0, iterationCount, index =>
{
unsafeTotal += 1; // not safe: updates get lost
});
Console.WriteLine($"Plain += result: {unsafeTotal} (expected {iterationCount})");
// --- GOOD EXAMPLE: Interlocked.Add is atomic ---
// Interlocked.Add(ref target, value) performs the read-add-write as one
// uninterruptible operation, so no update can ever be lost.
int safeTotal = 0;
Parallel.For(0, iterationCount, index =>
{
Interlocked.Add(ref safeTotal, 1); // safe from any number of threads
});
// This line always prints the expected value, no matter how often you run it.
Console.WriteLine($"Interlocked.Add result: {safeTotal} (expected {iterationCount})");
Console.WriteLine();
}
How It Works
- Both loops do exactly the same job: add
1 to a shared variable 100000 times, so the correct answer is always 100000. - The first loop uses
unsafeTotal += 1. This single line is really three machine steps: read the current value, add one, write the result back. Two threads can read the same value before either of them writes, and one of the two updates disappears. - The printed result is therefore far below 100000, and it is different on every run. This is a classic race condition, and it happens even though
Parallel.For() itself is perfectly correct: the class synchronizes its own bookkeeping, never your data. - The second loop uses
Interlocked.Add(ref safeTotal, 1). The processor performs the read-add-write as one uninterruptible operation, so no update can be lost and the total is always exact. - A
lock statement would also give the correct answer, but for a simple counter Interlocked is faster because it does not block any thread.
Sample Output
=== Step 4: Shared counter, plain += (bad) vs Interlocked (good) ===
Plain += result: 51419 (expected 100000)
Interlocked.Add result: 100000 (expected 100000)
The first number is different every time you run the program, and it may occasionally even be correct on a slow machine. That unpredictability is exactly what makes race conditions dangerous.
Step 5: Code Example – Normal for Loop vs Parallel.For
public static void RunSequentialVersusParallel()
{
Console.WriteLine("=== Step 5: Normal for loop vs Parallel.For ===");
// 8 work items, each counting the prime numbers below 200000.
// The work is CPU-heavy and every item writes to its own array slot,
// which is the ideal shape for Parallel.For: independent and slow enough
// that the cost of starting threads is worth paying.
const int workItemCount = 8;
const int primeSearchLimit = 200_000;
int[] sequentialResults = new int[workItemCount];
int[] parallelResults = new int[workItemCount];
// --- Normal for loop: one item after another on a single thread ---
Stopwatch sequentialWatch = Stopwatch.StartNew();
for (int index = 0; index < workItemCount; index++)
{
sequentialResults[index] = CountPrimesBelow(primeSearchLimit);
}
sequentialWatch.Stop();
// --- Parallel.For: the range is split into chunks over several threads ---
Stopwatch parallelWatch = Stopwatch.StartNew();
Parallel.For(0, workItemCount, index =>
{
parallelResults[index] = CountPrimesBelow(primeSearchLimit);
});
parallelWatch.Stop();
Console.WriteLine($"Processor count on this machine: {Environment.ProcessorCount}");
Console.WriteLine($"Normal for loop took: {sequentialWatch.ElapsedMilliseconds} ms");
Console.WriteLine($"Parallel.For took: {parallelWatch.ElapsedMilliseconds} ms");
// The results are identical: parallel execution changes the timing and the
// order of the work, never the calculated values.
Console.WriteLine($"Both loops produced the same results: {sequentialResults.SequenceEqual(parallelResults)}");
Console.WriteLine($"Prime numbers below {primeSearchLimit}: {parallelResults[0]}");
Console.WriteLine();
}
// Counts how many prime numbers are smaller than the given limit.
// limit: the exclusive upper bound of the search.
// The method is deliberately simple (and a bit slow) so that it represents
// real CPU work; it uses only local variables, so it is safe to call from
// many threads at the same time.
private static int CountPrimesBelow(int limit)
{
int primeCount = 0;
for (int candidate = 2; candidate < limit; candidate++)
{
bool isPrime = true;
// Testing divisors up to the square root is enough: a bigger divisor
// would always have a matching smaller one that we already tested.
for (int divisor = 2; divisor * divisor <= candidate; divisor++)
{
if (candidate % divisor == 0)
{
isPrime = false;
break;
}
}
if (isPrime)
{
primeCount++;
}
}
return primeCount;
}
How It Works
- The unit of work is
CountPrimesBelow(200000), a purely CPU-bound calculation that uses only local variables, so it is safe to run on many threads at the same time. - The first loop is an ordinary
for loop: the eight work items run one after another on a single thread, and a Stopwatch measures the total time. - The second loop is a
Parallel.For over the same range. .NET splits the eight indexes into chunks and runs them on several thread pool threads, so multiple processor cores work at the same time. - Each iteration writes only to its own array slot, so no synchronization is needed. This is the ideal shape for
Parallel.For(): independent iterations and no shared state. SequenceEqual confirms both loops produced identical values. Parallel execution changes the timing and the order of the work, never the results of an independent calculation.
Sample Output
=== Step 5: Normal for loop vs Parallel.For ===
Processor count on this machine: 10
Normal for loop took: 134 ms
Parallel.For took: 21 ms
Both loops produced the same results: True
Prime numbers below 200000: 17984
The measured times depend on the machine. The speed-up is limited by the number of processor cores, and it can never be larger than that number, because there are only so many cores to share the work.
When to Use
Use Parallel.For() when:
- The loop iterates over a numeric range, most often the indexes of an array or a list.
- The iterations are independent: one iteration does not need the result of another and the order does not matter.
- The work in each iteration is CPU-bound and heavy enough that splitting it is worth the extra cost of scheduling threads.
Avoid it when:
- The loop body is very short (for example a simple addition). Starting and coordinating threads then costs more than the loop itself, and the parallel version becomes slower.
- The iterations depend on each other or must run in a fixed order.
- The work is I/O-bound, such as calling web services or reading files. In that case
async/await with Task.WhenAll is a better fit, because it does not block threads while waiting. - Every iteration hits the same shared object anyway. If almost all of the work happens inside a lock, the threads simply wait for each other and nothing is gained.
Related members are Parallel.ForEach() for collections that are not indexed by numbers, Parallel.Invoke() for a fixed set of different actions, and PLINQ (AsParallel()) for query-style processing.