Parallel.For() is designed for efficiently processing ranges of numbers. Parallel.ForEach(), found in the same System.Threading.Tasks namespace, is designed for collections: anything that implements IEnumerable<T>.
The method takes an IEnumerable<T> instance as its first parameter and executes an Action<T> delegate for each item in the enumeration. Instead of receiving a numeric index, your delegate receives the item itself, which makes it the natural choice for iterating over file paths, database query results, customer objects, or any other set of data.
If the source is an array or a List<T>, the framework uses direct indexing to create partitions efficiently. For any other kind of enumeration, a Partitioner instance decides how the items are handed out to the worker threads. Either way, every item in the collection is processed exactly once, but the items are processed in parallel to maximize performance.
Two behaviors are important to remember from the start:
Parallel.ForEach() gives no guarantee about the order of execution. The items may be processed in any order, and that order changes from run to run.- The call is blocking: the thread that calls
Parallel.ForEach() waits until every item has been processed (or until the loop is stopped or cancelled).
This article follows an easy-to-harder path with six examples:
- Step 1: the simplest loop over a
List<string>. - Step 2: the richer delegate signatures –
ParallelLoopState and the item index – and stopping the loop early. - Step 3: limiting how many items may be processed together with
ParallelOptions.MaxDegreeOfParallelism. - Step 4: stopping the loop from the outside with a
CancellationToken. - Step 5: looping over a lazy
IEnumerable and over an explicitly created Partitioner. - Step 6: a normal
foreach loop and a Parallel.ForEach loop doing the same CPU-heavy work, with their run times compared.
Features and Design
The most basic syntax involves passing an IEnumerable<T> instance as the first parameter and an Action<T> delegate as the second parameter:
Parallel.ForEach(IEnumerable<TSource> source, Action<TSource> body)
source – the collection whose items must be processed.body – the delegate that is called exactly once for every item.
The delegate can simply receive the element to process, or it can receive more parameters. These are the possible signatures for the Action delegate:
(T) – the variable contains the current element being processed.(T, ParallelLoopState) – the element and a ParallelLoopState instance, used to safely stop the loop.(T, ParallelLoopState, long) – the element, a ParallelLoopState, and a state parameter that defaults to the index of the current item in the enumeration.
The method returns a ParallelLoopResult structure. Its IsCompleted property is true when every item was processed and the loop was not stopped early.
A ParallelOptions parameter can be passed to provide cancellation support, maximum degree of parallelism control, and a custom task scheduler, exactly the same as with Parallel.For():
MaxDegreeOfParallelism – the maximum number of items allowed to be processed 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 foreach 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.
Finally, remember that the Parallel class synchronizes its own internal bookkeeping but 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 such as ConcurrentBag<T>.
Key Differences from Parallel.For
The two parallel loops are similar in many respects, but there are some key differences:
| Aspect | Parallel.For() | Parallel.ForEach() |
|---|
| Input Type | A numeric range (for example, 0..100) | A collection (for example, Array, List, IEnumerable) |
| Loop Variable | The numeric index (for example, int) | Individual collection item |
| Use Case | Numeric data processing | Collection or data structure processing |
Parallel.For() iterates over a numeric range, such as a specified "from" and "to" range of integers, and provides the current index as the loop variable. This is particularly useful for numeric processing tasks: operations on arrays or matrices where calculations depend on the index, or transformations of elements in a computational grid.
Parallel.ForEach() processes collections such as List, Array, or any IEnumerable, and provides the current element as the loop variable. This makes it ideal for non-numeric data or large sets of objects: iterating over file paths, applying operations to database query results, or transforming objects in a collection.
Step 1: Code Example – The Simplest Parallel.ForEach Loop
public static void RunBasics()
{
Console.WriteLine("=== Step 1: The simplest Parallel.ForEach loop ===");
// Any IEnumerable<T> can be used as the source. A List<T> is used here because
// it is indexable, which lets .NET split the items into chunks very cheaply.
List<string> cityNames = new List<string> { "Rome", "Oslo", "Lima", "Cairo", "Tokyo", "Paris" };
// Parallel.ForEach(source, body)
// source: the IEnumerable<T> whose items must be processed.
// body: an Action<T> delegate that is called exactly once for every item.
// 6 items were chosen only to keep the console output short and readable.
ParallelLoopResult result = Parallel.ForEach(cityNames, cityName =>
{
// Printing directly from the loop body is done on purpose here:
// it makes the most important rule of Parallel.ForEach visible, namely that
// the items are NOT processed in the order they appear in the collection.
// Environment.CurrentManagedThreadId shows which thread handled the item.
Console.WriteLine($"{cityName} was handled by thread {Environment.CurrentManagedThreadId}");
});
// Parallel.ForEach blocks the calling thread until every item has been processed,
// so this line is printed only after the whole collection is done.
// ParallelLoopResult.IsCompleted is true when all items ran and the loop was not
// stopped early (see Step 2).
Console.WriteLine($"Loop completed all items: {result.IsCompleted}");
Console.WriteLine();
}
How It Works
- A
List<string> with six city names is the source collection. Because a list is indexable, .NET can split it into chunks by simple index arithmetic, without any partitioner machinery. - The delegate has the simplest possible shape,
Action<string>: it receives one city name and does something with it. Each name is passed to the delegate exactly once. - Printing happens inside the loop body on purpose, so the mixed order is visible. The names do not come out in the order Rome, Oslo, Lima, Cairo, Tokyo, Paris, and the order is different on every run.
Environment.CurrentManagedThreadId reveals which thread processed each item. Several different ids appear, and the calling thread (usually id 1) also takes part, because it helps with the work instead of just waiting.- The last line runs only after every item is done, because
Parallel.ForEach() blocks. result.IsCompleted is True since nothing stopped the loop early.
Sample Output
=== Step 1: The simplest Parallel.ForEach loop ===
Rome was handled by thread 1
Tokyo was handled by thread 1
Paris was handled by thread 1
Cairo was handled by thread 12
Oslo was handled by thread 6
Lima was handled by thread 11
Loop completed all items: True
Both the order of the names and the thread ids change on every run and on every machine. Notice that one thread often handles several items: it received a whole chunk of the list.
Step 2: Code Example – ParallelLoopState and the Item Index
public static void RunWithLoopStateAndIndex()
{
Console.WriteLine("=== Step 2: ParallelLoopState and the item index ===");
// A small "search" scenario: find one file name in a list and stop working.
List<string> fileNames = new List<string>
{
"notes.txt", "photo.png", "budget.xlsx", "report.pdf", "music.mp3", "backup.zip"
};
const string wantedFile = "report.pdf";
// The three possible delegate shapes are:
// - Action<T> : only the item (used in Step 1).
// - Action<T, ParallelLoopState> : the item plus a way to stop the loop.
// - Action<T, ParallelLoopState, long> : the item, the loop state and the index
// of the item inside the source collection.
// The third shape is used below because the index is printed as well.
ParallelLoopResult result = Parallel.ForEach(fileNames, (fileName, loopState, index) =>
{
// A tiny delay makes the example behave like real work and gives the other
// iterations a chance to notice that the loop was stopped.
Thread.Sleep(20);
if (fileName == wantedFile)
{
Console.WriteLine($"Found '{fileName}' at index {index}. Asking the loop to stop.");
// Stop() asks the loop to finish as soon as possible. Items that were
// already started still finish, but no new item is picked up.
// Alternative: Break() also stops the loop, but it guarantees that every
// item BEFORE the current index is still processed. Use Break() when the
// order matters (for example "process everything up to the first error"),
// and Stop() when you only need one result and want to quit immediately.
loopState.Stop();
return;
}
// IsStopped is true after another iteration called Stop(). Checking it lets a
// long-running body give up early instead of finishing useless work.
if (loopState.IsStopped)
{
return;
}
Console.WriteLine($"Checked '{fileName}' at index {index}");
});
// IsCompleted is false here, because the loop was ended by Stop() before it had
// processed every item. This is the normal way to detect an early exit.
Console.WriteLine($"Loop completed all items: {result.IsCompleted}");
Console.WriteLine();
}
How It Works
- The delegate uses the third signature,
(T, ParallelLoopState, long). The index parameter tells you where the item sits in the source collection, which the item itself cannot tell you. - When the wanted file is found,
loopState.Stop() is called. This asks the loop to end as soon as possible: items that are already running still finish, but no new item is started. Break() is the alternative. It also ends the loop, but it promises that every item positioned before the current index is still processed. Stop() makes no such promise, which is why it is the right choice for a "find the first match" search.- Other iterations check
loopState.IsStopped and return immediately, so no time is wasted on work whose result nobody needs. result.IsCompleted is False. That is the standard way to detect that a parallel loop exited early instead of processing the whole collection.
Sample Output
=== Step 2: ParallelLoopState and the item index ===
Checked 'photo.png' at index 1
Found 'report.pdf' at index 3. Asking the loop to stop.
Loop completed all items: False
How many items are checked before the match is found is unpredictable: it depends on which chunks the threads received and how fast they ran. The one guaranteed line is the "Found" message.
Step 3: Code Example – Limiting Parallelism with ParallelOptions
public static void RunWithMaxDegreeOfParallelism()
{
Console.WriteLine("=== Step 3: Limiting parallelism with ParallelOptions ===");
// Six imaginary orders that must be sent to a slow external service.
List<int> orderNumbers = new List<int> { 101, 102, 103, 104, 105, 106 };
// ParallelOptions works exactly the same for Parallel.For and Parallel.ForEach.
// - MaxDegreeOfParallelism: the highest number of items allowed to be processed together.
// -1 (the default) means "no limit, let .NET decide".
// 1 makes the loop behave like a normal sequential foreach loop.
// A small number protects a limited resource, for example a database or a
// web service that must not receive too many requests at once.
// - CancellationToken: lets you stop the loop from outside (see Step 4).
// - 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 items are
// "inside" the loop body at the same time.
MaxDegreeOfParallelism = 2
};
// How many items are being processed right now, and the highest value ever seen.
// Both values are shared by all iterations, so every change uses Interlocked.
int runningCount = 0;
int highestRunningCount = 0;
// 6 items with a limit of 2 means the work is done in about 3 waves.
Parallel.ForEach(orderNumbers, options, orderNumber =>
{
// 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);
InterlockedMax(ref highestRunningCount, current);
// 100 ms of fake work makes the overlap easy to observe; without a delay an
// item would be finished before the next one even starts.
Thread.Sleep(100);
Interlocked.Decrement(ref runningCount);
});
Console.WriteLine($"Items in the collection: {orderNumbers.Count}");
Console.WriteLine($"MaxDegreeOfParallelism was set to: {options.MaxDegreeOfParallelism}");
Console.WriteLine($"Highest number of items 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 as the second parameter, so at most two items may be inside the delegate at any moment. - Each item 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 item would be finished before the next one started and the overlap could not be measured.
- The reported maximum is
2, which proves the limit was respected. This is exactly how you protect an external service that cannot handle unlimited concurrent calls.
Sample Output
=== Step 3: Limiting parallelism with ParallelOptions ===
Items in the collection: 6
MaxDegreeOfParallelism was set to: 2
Highest number of items seen running together: 2
Step 4: Code Example – Cancelling a Parallel.ForEach Loop
public static void RunWithCancellation()
{
Console.WriteLine("=== Step 4: Cancelling a Parallel.ForEach loop ===");
// 100 imaginary e-mails that each take 100 ms to send.
List<string> mailAddresses = Enumerable.Range(1, 100)
.Select(number => $"user{number}@example.com")
.ToList();
// 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 items are processed together here. This keeps the example easy to
// follow, and it leaves free thread pool threads for the timer that triggers
// the cancellation.
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 items 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
// items are never started.
int sentCount = 0;
try
{
Parallel.ForEach(mailAddresses, options, mailAddress =>
{
Thread.Sleep(100);
Interlocked.Increment(ref sentCount);
});
}
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 items.
Console.WriteLine($"Items processed before the cancellation: {sentCount} of {mailAddresses.Count}");
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 items once it is cancelled. - The loop is asked to process 100 items, each sleeping 100 ms, two at a time. That would take roughly five seconds, so the cancellation always wins and only a handful of items complete.
Parallel.ForEach() then throws OperationCanceledException. Note that it is caught directly, not through an AggregateException. Items already being processed are not killed; they finish their current work first.- This is cancellation from outside the loop.
ParallelLoopState.Stop() from Step 2 is the way to end the loop from inside it, and it does not throw.
Sample Output
=== Step 4: Cancelling a Parallel.ForEach loop ===
The loop was cancelled, so an OperationCanceledException was thrown.
Items processed before the cancellation: 4 of 100
Cancellation was requested: True
Step 5: Code Example – A Lazy IEnumerable and a Partitioner
public static void RunOverEnumerableAndPartitioner()
{
Console.WriteLine("=== Step 5: A lazy IEnumerable and a Partitioner ===");
// ReadSensorIds() is a generator method: it produces its items one by one and has
// no Count and no indexer. .NET cannot slice it, so a Partitioner is used to hand
// the items out to the worker threads while the sequence is being read.
int handledByEnumerable = 0;
Parallel.ForEach(ReadSensorIds(6), sensorId =>
{
Interlocked.Increment(ref handledByEnumerable);
Console.WriteLine($"IEnumerable source: {sensorId} on thread {Environment.CurrentManagedThreadId}");
});
Console.WriteLine($"Items processed from the lazy sequence: {handledByEnumerable}");
Console.WriteLine();
// A Partitioner can also be created by hand and passed in place of the collection.
// Partitioner.Create(source, options) returns a partitioner for any IEnumerable<T>.
// EnumerablePartitionerOptions values:
// - None (the default): the partitioner may take a small batch of items per thread.
// This is faster when the body is short, because it reduces coordination work.
// - NoBuffering: exactly one item is given to a thread at a time. This is the right
// choice when items take very different amounts of time, because a thread that
// received a batch of slow items would keep the others waiting.
OrderablePartitioner<string> partitioner =
Partitioner.Create(ReadSensorIds(6), EnumerablePartitionerOptions.NoBuffering);
int handledByPartitioner = 0;
Parallel.ForEach(partitioner, sensorId =>
{
Interlocked.Increment(ref handledByPartitioner);
Console.WriteLine($"Partitioner source: {sensorId} on thread {Environment.CurrentManagedThreadId}");
});
Console.WriteLine($"Items processed through the partitioner: {handledByPartitioner}");
Console.WriteLine();
}
// Produces sensor ids one at a time instead of returning a ready-made list.
// count: how many ids to produce.
// "yield return" makes this an IEnumerable<string> that has no Count and no indexer,
// which is exactly the kind of source that needs a Partitioner.
private static IEnumerable<string> ReadSensorIds(int count)
{
for (int number = 1; number <= count; number++)
{
yield return $"SENSOR-{number}";
}
}
How It Works
ReadSensorIds() is a generator method built with yield return. It has no Count and no indexer, so .NET cannot simply cut it into equal slices the way it does with an array or a list.- For such a source,
Parallel.ForEach() automatically uses a system-provided Partitioner. The partitioner reads the sequence and hands the items out to the worker threads as they become available. Every item is still processed exactly once. - The second loop creates the partitioner explicitly with
Partitioner.Create(source, options) and passes it in place of the collection. This is the hook you use when the default strategy is not a good fit. EnumerablePartitionerOptions.NoBuffering means one item at a time per thread. The default (None) lets a thread grab a small batch, which is faster when the body is very short but can leave other threads idle when some items are much slower than others.- For advanced scenarios you can write your own class deriving from
Partitioner<T> or OrderablePartitioner<T> to fine-tune performance, but the built-in partitioners are optimized for most general use cases.
Sample Output
=== Step 5: A lazy IEnumerable and a Partitioner ===
IEnumerable source: SENSOR-3 on thread 8
IEnumerable source: SENSOR-1 on thread 12
IEnumerable source: SENSOR-4 on thread 9
IEnumerable source: SENSOR-2 on thread 13
IEnumerable source: SENSOR-5 on thread 10
IEnumerable source: SENSOR-6 on thread 1
Items processed from the lazy sequence: 6
Partitioner source: SENSOR-1 on thread 10
Partitioner source: SENSOR-2 on thread 14
Partitioner source: SENSOR-3 on thread 7
Partitioner source: SENSOR-4 on thread 12
Partitioner source: SENSOR-5 on thread 16
Partitioner source: SENSOR-6 on thread 11
Items processed through the partitioner: 6
The counters are always 6 in both loops, which proves the promise of the method: each item is processed exactly once, no matter which partitioning strategy is used. Only the order and the thread ids differ.
Step 6: Code Example – Normal foreach Loop vs Parallel.ForEach
public static void RunSequentialVersusParallel()
{
Console.WriteLine("=== Step 6: Normal foreach loop vs Parallel.ForEach ===");
// 8 work items, each counting the prime numbers below the given limit.
// The work is CPU-heavy and every item is independent, which is the ideal shape
// for Parallel.ForEach: no shared data and slow enough that using several
// threads is worth the extra scheduling cost.
List<int> searchLimits = new List<int>
{
200_000, 200_000, 200_000, 200_000, 200_000, 200_000, 200_000, 200_000
};
// Results are collected in a thread-safe collection, because several threads add
// to it at the same time. A plain List<T> would lose items or throw here.
ConcurrentBag<int> parallelResults = new ConcurrentBag<int>();
List<int> sequentialResults = new List<int>();
// --- Normal foreach loop: one item after another on a single thread ---
Stopwatch sequentialWatch = Stopwatch.StartNew();
foreach (int limit in searchLimits)
{
sequentialResults.Add(CountPrimesBelow(limit));
}
sequentialWatch.Stop();
// --- Parallel.ForEach: the items are spread over several threads ---
Stopwatch parallelWatch = Stopwatch.StartNew();
Parallel.ForEach(searchLimits, limit => { parallelResults.Add(CountPrimesBelow(limit)); });
parallelWatch.Stop();
Console.WriteLine($"Processor count on this machine: {Environment.ProcessorCount}");
Console.WriteLine($"Normal foreach loop took: {sequentialWatch.ElapsedMilliseconds} ms");
Console.WriteLine($"Parallel.ForEach took: {parallelWatch.ElapsedMilliseconds} ms");
// The values are identical, only their order in the bag is unpredictable.
// Ordering both sequences before comparing therefore gives a deterministic result.
bool sameResults = sequentialResults.OrderBy(value => value)
.SequenceEqual(parallelResults.OrderBy(value => value));
Console.WriteLine($"Both loops produced the same results: {sameResults}");
Console.WriteLine($"Prime numbers below 200000: {sequentialResults[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 once.
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
foreach loop: the eight items are processed one after another on a single thread, and a Stopwatch measures the total time. - The second loop is a
Parallel.ForEach over the same list. .NET splits the items into chunks and runs them on several thread pool threads, so multiple processor cores work at the same time. - The parallel results go into a
ConcurrentBag<int>, not a List<int>. Several threads add at the same moment, and List<T> is not thread-safe: it would silently lose items or throw an exception. This is the reminder that the loop body must protect its own shared data. - Both sequences are sorted before they are compared, because the order inside the bag is unpredictable. The values themselves are identical: parallel execution changes the timing and the order, never the results of an independent calculation.
Sample Output
=== Step 6: Normal foreach loop vs Parallel.ForEach ===
Processor count on this machine: 10
Normal foreach loop took: 132 ms
Parallel.ForEach took: 23 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.ForEach() when:
- The data lives in a collection – an array, a
List<T>, a query result, or any other IEnumerable<T> – rather than in a numeric range. - The items are independent: processing one item does not need the result of another and the order does not matter.
- The work for each item 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 adding two numbers). Starting and coordinating threads then costs more than the loop itself, and the parallel version becomes slower.
- The items depend on each other or must be processed 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, or Parallel.ForEachAsync(), is a better fit, because those do not block threads while waiting. - Every item 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.For() for numeric ranges, Parallel.ForEachAsync() for asynchronous work per item, Parallel.Invoke() for a fixed set of different actions, and PLINQ (AsParallel()) for query-style processing.