A regular foreach visits one item at a time, on one core. Parallel.ForEach hands whole chunks of the same collection to several cores at once — and expects your loop body to behave when that happens.
The last lesson introduced Parallel.Invoke for a small, fixed handful of independent operations. But the far more common real-world shape isn't "run these three specific things at once" — it's "run this same operation, once per item, across a collection of a thousand items." Resize every image in a folder. Recalculate a score for every account. Validate every row in an imported file.
Doing that with a plain foreach uses exactly one core, one item at a time, no matter how many cores the machine has sitting idle. Parallel.ForEach is the member of the Parallel family built for exactly this shape: apply the same CPU-bound work to every item in a collection, spread across as many cores as are available. In this lesson, you'll learn its syntax, how to control it with ParallelOptions, how the runtime actually splits the collection up behind the scenes, and — most importantly — when it genuinely helps and when it quietly makes things worse.
Parallel.ForEach looks and behaves like a regular foreach loop — it runs the same body once for every item in a collection — except the runtime is free to run several of those iterations at the same instant, on different cores, instead of strictly one after another.
Parallel.ForEach<TSource> is a static method on System.Threading.Tasks.Parallel with several overloads. The two you'll reach for most:
public static ParallelLoopResult ForEach<TSource>(
IEnumerable<TSource> source,
Action<TSource> body);
public static ParallelLoopResult ForEach<TSource>(
IEnumerable<TSource> source,
ParallelOptions parallelOptions,
Action<TSource> body);Like Parallel.Invoke, this is a blocking call — it doesn't return until every item has been processed (or the loop is stopped/cancelled). It returns a ParallelLoopResult you can inspect to see whether the loop ran to completion.
Parallel.ForEach(images, image =>
{
Resize(image, targetWidth: 800);
});A plain foreach loop is fundamentally sequential: item 1 finishes completely before item 2 even begins. If each item takes 50 milliseconds of genuine CPU work and there are 10,000 items, that's 500 seconds — no matter how many cores the machine has, because only one core is ever doing anything at any given moment.
What's needed is a loop construct that keeps the familiar "do this for every item" shape, but is free to process several items genuinely at once, spread across however many cores the machine has to offer — without requiring you to manually partition the collection or manage worker threads yourself.
Parallel.ForEach takes over exactly that coordination. Internally, it splits the source collection into chunks (more on the strategy below), hands each chunk to a worker task on the ThreadPool, and lets those workers run concurrently across as many cores as are available — all while you write a loop body that reads almost exactly like an ordinary foreach.
You don't control the exact chunk boundaries directly, and the precise sizes aren't something to hard-code assumptions around — the internal partitioner adapts based on the size and shape of the source collection. The important mental model is simply this: Parallel.ForEach doesn't hand out one item per worker at a time; it groups items into chunks first, to keep coordination overhead low relative to the actual work being done. That's also exactly why it's a poor fit for extremely cheap, fast per-item work — see "When Should I Use It?" below.
A CPU-heavy per-item calculation — computing a hash-like checksum for every file, say — parallelized across a collection of file paths:
string[] filePaths = Directory.GetFiles(folder, "*.dat");
var checksums = new ConcurrentDictionary<string, long>();
Parallel.ForEach(filePaths, path =>
{
long checksum = ComputeExpensiveChecksum(path); // genuinely CPU-heavy
checksums[path] = checksum; // ConcurrentDictionary — safe under concurrent writes
});
Console.WriteLine($"Computed {checksums.Count} checksums.");Walking through it:
ComputeExpensiveChecksum is genuinely CPU-bound — no network or disk waiting, just computation — which is exactly what makes this a good fit for Parallel.ForEach rather than async/await.ConcurrentDictionary<TKey,TValue>, not a plain Dictionary<TKey,TValue> — this is the thread-safety requirement below, and the concurrent collections lesson elsewhere in this Part covers exactly why that distinction matters.ParallelOptions lets you tune how the loop runs. The property you'll use by far the most is MaxDegreeOfParallelism:
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 4, // never run more than 4 iterations at the same instant
CancellationToken = cancellationToken
};
Parallel.ForEach(filePaths, options, path =>
{
long checksum = ComputeExpensiveChecksum(path);
checksums[path] = checksum;
});By default, MaxDegreeOfParallelism is -1, meaning "let the runtime decide" — effectively scaling up to as many cores as it judges appropriate. Deliberately capping it lower is a genuinely common, deliberate choice, for reasons that have nothing to do with the loop being "too slow":
ParallelOptions also accepts a CancellationToken — pass one in and the loop stops promptly (throwing an OperationCanceledException) once it's signaled, honoring the same cancellation model used throughout this course rather than requiring a separate, bespoke stop mechanism.
An image-processing service that needs to generate thumbnails for a batch of uploaded photos, capping concurrency deliberately so the service doesn't monopolize the machine it shares with other workloads:
public ThumbnailBatchResult GenerateThumbnails(IReadOnlyList<string> imagePaths)
{
var failures = new ConcurrentBag<string>();
int successCount = 0;
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1) // leave one core free
};
Parallel.ForEach(imagePaths, options, path =>
{
try
{
GenerateThumbnail(path, width: 200, height: 200); // genuinely CPU-heavy image resizing
Interlocked.Increment(ref successCount);
}
catch (Exception)
{
failures.Add(path); // ConcurrentBag — safe for concurrent adds from many workers
}
});
return new ThumbnailBatchResult(successCount, failures.ToArray());
}Two thread-safety details matter here, both previewed for real depth in later lessons in this Part: successCount is updated with Interlocked.Increment rather than successCount++ (a plain increment is not safe when multiple threads touch the same variable concurrently), and failed paths go into a ConcurrentBag<string> rather than a plain List<string>, because many worker threads may call .Add at the same instant.
A regular foreach is one person sorting a giant bin of mail, one envelope at a time, start to finish. Parallel.ForEach is handing stacks of that same mail to several sorters working at separate tables at the same time — each sorter takes a stack (a "chunk"), works through it, and grabs another stack when done.
If two sorters both need to write into the same shared logbook at the same moment — say, tallying a running total of processed letters — they'll collide unless they coordinate (that's the thread-safety requirement). And if the mail itself is trivially light — postcards that take one second each to glance at — the overhead of organizing sorters and stacks can cost more time than just letting one person handle the whole bin. That's exactly the "small, fast per-item work" pitfall covered below.
Parallel.ForEach is built on the ThreadPool — the same shared worker-thread pool that underlies Task.Run and everything else in this Part, whose scheduling internals get their own dedicated lesson elsewhere. What Parallel.ForEach adds on top is a partitioner — logic that decides how to slice the source collection into chunks so that ThreadPool workers can each take a batch of items to process without constant back-and-forth coordination for every single item. For an IList<T> or array, this can take advantage of the fact that the size and random-access indices are known up front; for a plain IEnumerable<T> that has to be enumerated item by item, the partitioner instead pulls small batches from the enumerator as workers ask for more.
Exactly how large those chunks are, and exactly how many workers get spun up, are runtime decisions — deliberately not something the API surface commits to as a guaranteed number, because the right answer depends on the machine, the collection size, and current system load. What's stable and worth remembering is the shape of the strategy: batch items into chunks, hand chunks to workers, let workers ask for more as they finish — not "assign item N to worker N% (core count)."
Parallel.ForEach guarantees your loop body can run concurrently for different items — it makes no guarantee about what happens if that body touches shared, mutable state without protecting it. If two iterations write to the same plain List<T>, increment the same plain int, or mutate the same field on a shared object at the same instant, you have a race condition — full stop. Making the loop body thread-safe (through concurrent collections, locks, or atomic operations) is entirely on you. The tools for that — lock, SemaphoreSlim, Interlocked, and the concurrent collections used above — get full, dedicated coverage elsewhere in this Part; this lesson only flags that the requirement exists.
A regular foreach always processes item 1, then item 2, then item 3, in that exact order. Parallel.ForEach makes no such guarantee — item 500 might genuinely finish before item 3, depending on which worker happens to pick up which chunk and how long each item's work takes. If your logic depends on processing order, Parallel.ForEach is the wrong tool entirely — reach for a regular sequential loop instead.
Swapping foreach for Parallel.ForEach is not a free, drop-in speed boost. It changes the execution model entirely: the body may run concurrently across threads, exceptions behave differently (captured into an AggregateException, same as Parallel.Invoke), break/continue don't work the same way (there's a separate ParallelLoopState mechanism for early termination), and — most importantly — any shared state the body touches now needs to be thread-safe. Treat the conversion as a real design decision, not a mechanical find-and-replace.
Wrong — a plain List<T> and a plain int being mutated by many concurrent iterations:
var results = new List<int>();
int total = 0;
Parallel.ForEach(numbers, n =>
{
int squared = ExpensiveCompute(n);
results.Add(squared); // NOT thread-safe — can corrupt List<T>'s internal state
total += squared; // NOT thread-safe — lost updates under concurrent access
});Correct — use a concurrent collection and an atomic operation instead:
var results = new ConcurrentBag<int>();
int total = 0;
Parallel.ForEach(numbers, n =>
{
int squared = ExpensiveCompute(n);
results.Add(squared); // safe — ConcurrentBag is built for this
Interlocked.Add(ref total, squared); // safe — atomic add
}); Looping over a list of URLs with Parallel.ForEach and making a blocking HTTP call inside the body.
// WRONG — blocks a pool thread per URL, waiting on the network
Parallel.ForEach(urls, url =>
{
string content = httpClient.GetStringAsync(url).GetAwaiter().GetResult(); // blocking!
Process(content);
}); This is exactly the thread-pool-starvation problem covered elsewhere in this Part: each iteration parks a pool thread while it waits on the network, instead of freeing it. For I/O-bound work, use async/Task.WhenAll (optionally combined with a SemaphoreSlim to cap concurrency) — not Parallel.ForEach.
// CORRECT — I/O-bound fan-out belongs to async/Task.WhenAll
var tasks = urls.Select(async url =>
{
string content = await httpClient.GetStringAsync(url);
Process(content);
});
await Task.WhenAll(tasks); Wrapping a loop that adds two numbers or checks a string's length in Parallel.ForEach, expecting it to be faster.
Partitioning the collection, scheduling chunks onto worker threads, and coordinating completion all cost real time — for work that's already extremely cheap per item, that coordination overhead can easily exceed whatever time was saved, making the parallel version slower than a plain foreach. Parallel.ForEach earns its keep when each item's work is substantial enough (a rough rule of thumb: at least on the order of microseconds to milliseconds of real CPU work, not nanoseconds) that spreading it across cores meaningfully outweighs the coordination cost.
Parallel.ForEach: (1) Is each item's work genuinely CPU-bound, with no network/disk/database waiting inside it? (2) Is each item's work substantial enough that spreading it across cores is worth the coordination overhead? If both are "yes," it's a strong fit. If either is "no," reach for async/Task.WhenAll (I/O-bound) or a plain foreach (too small to bother) instead.
MaxDegreeOfParallelism caps concurrency deliberately — for headroom, or to respect an external resource's own limits.Parallel.ForEach applies the same CPU-bound operation to every item in a collection, spreading the work across multiple cores by partitioning the source into chunks and scheduling them onto the ThreadPool.ParallelOptions.MaxDegreeOfParallelism deliberately caps concurrency — for headroom on a shared machine, or to respect a downstream resource's own concurrency limit.Task.WhenAll instead.You've seen Parallel.ForEach's syntax, its options, and — critically — its rules. Let's check that "thread-safe body, CPU-bound work, no ordering guarantee" has really landed.
1. Inside a Parallel.ForEach loop body, multiple iterations increment a plain int total using total++. What's the most accurate description of the risk?
Correct: B
Why B is correct: total++ is not an atomic operation — it reads, increments, and writes back in separate steps. When multiple threads do this concurrently on the same shared variable, updates can be lost, producing a final total lower than the correct sum. This is exactly why Interlocked.Increment (or Interlocked.Add) exists for this case.
Why A is incorrect: Parallel.ForEach provides no automatic thread safety for anything the loop body touches — that responsibility is entirely on the code you write.
Why C is incorrect: Being a value type has nothing to do with thread safety of a shared, mutable variable — the field itself is still shared memory that multiple threads can race on.
Why D is incorrect: Capturing a local variable in a Parallel.ForEach lambda compiles and runs fine — the problem is a runtime race condition, not a compile-time error.
Reinforcement: Parallel.ForEach parallelizes execution; it does nothing to protect shared state your loop body touches.
2. A team wants to parallelize a loop that calls a third-party API with a documented limit of 5 concurrent requests. Which change correctly respects that limit while still using Parallel.ForEach?
Correct: B
Why B is correct: MaxDegreeOfParallelism exists precisely for this scenario — capping how many iterations run at the same instant so an external resource's own concurrency limit isn't exceeded.
Why A is incorrect: Parallel.ForEach has no awareness of external rate limits — it will happily schedule as many concurrent iterations as the runtime allows unless you explicitly cap it.
Why C is incorrect: Parallel.ForEach can absolutely be limited via ParallelOptions — dropping parallelism entirely isn't necessary.
Why D is incorrect: This describes making a call to an inherently I/O-bound API from inside Parallel.ForEach at all, which is itself the wrong tool (see Mistake 2) — and this option doesn't address the rate-limit problem regardless.
Reinforcement: MaxDegreeOfParallelism is the correct, direct way to respect an external resource's own concurrency ceiling.
3. A developer loops over 200,000 file paths with Parallel.ForEach, and each iteration makes a blocking, synchronous HTTP call to upload the file. What's the most accurate assessment?
Correct: B
Why B is correct: Each iteration parks a ThreadPool worker thread for the entire duration of a blocking network call. That's precisely the thread-pool-starvation scenario covered elsewhere in this Part — a large number of concurrent blocking waits can exhaust available pool threads. I/O-bound fan-out belongs to async/Task.WhenAll, which frees the thread instead of blocking it.
Why A is incorrect: Parallel.ForEach is built for CPU-bound work — using it to block on I/O defeats the purpose of asynchronous I/O entirely.
Why C is incorrect: The degree-of-parallelism setting doesn't change the fundamental problem — it's still tying up pool threads for I/O waiting, just at whatever concurrency level is configured.
Why D is incorrect: Parallel.ForEach has no such automatic conversion — a blocking call inside its body stays blocking.
Reinforcement: CPU-bound work → Parallel.ForEach. I/O-bound work → async/Task.WhenAll. Mixing them up causes real problems, not just style issues.
4. Why might Parallel.ForEach actually be slower than a plain foreach for a collection of one million items, where each item's work is just "add 1 to a number"?
Correct: B
Why B is correct: Parallel.ForEach's coordination — partitioning the source, scheduling chunks onto workers, tracking completion — has real cost. When each item's actual work is trivially cheap, that coordination overhead can dwarf the time saved by spreading the work across cores, making the parallel version net slower.
Why A is incorrect: Parallel.ForEach is genuinely faster for substantial, CPU-bound per-item work — the earlier checksum and thumbnail examples are real wins. The issue here is specifically that the work is too small.
Why C is incorrect: Parallel.ForEach has no such item-count ceiling; it handles collections of any size.
Why D is incorrect: There's no restriction on what operations can appear in the loop body — the problem is purely one of cost-versus-overhead, not a technical limitation.
Reinforcement: Parallelization has real overhead — it pays off only when per-item work is substantial enough to outweigh that cost.
5. Which statement correctly describes ordering guarantees in Parallel.ForEach?
Correct: B
Why B is correct: Because different chunks run on different workers at different speeds, there's no guarantee about which iteration finishes first — an item near the end of the collection can genuinely complete before one near the beginning.
Why A is incorrect: That describes a plain foreach's guarantee, which Parallel.ForEach explicitly does not preserve — that's exactly why order-dependent logic shouldn't use it.
Why C is incorrect: There's no reverse-order default — completion order is effectively unpredictable, not reversed.
Why D is incorrect: While setting MaxDegreeOfParallelism to 1 would incidentally serialize the work (removing genuine parallelism), it's not the documented guarantee to rely on for ordering — and doing so would defeat the entire purpose of using Parallel.ForEach in the first place.
Reinforcement: If your logic depends on processing order, Parallel.ForEach is the wrong tool — reach for a sequential loop instead.
You can now parallelize real CPU-bound work over a collection safely and deliberately. Next up: Channels — the async-native producer/consumer queue you already used in the Background Processing Service project, now with the full theory behind it.
dotnetmadeeasy.com — Learn C# and .NET, the right way.