Parallel LINQ partitioning, degree of parallelism, ordering, merge options, cancellation, exceptions, and why it is for CPU-bound work only.
PLINQ (Parallel LINQ) runs LINQ to Objects queries on multiple cores: source.AsParallel().Where(...).Select(...) partitions the input and processes partitions on ThreadPool threads, then merges results. It helps for CPU-bound work on large in-memory collections where each item is expensive. It makes things worse for small inputs, cheap per-item work, I/O-bound work (threads block), lambdas with shared mutable state, and server code where all cores are already busy with requests. Results are unordered unless you add AsOrdered(), and exceptions arrive as an AggregateException.
"PLINQ, Parallel LINQ, runs LINQ to Objects queries on multiple cores.
source.AsParallel()turns the sequence into aParallelQuery<T>. The input is partitioned, partitions are processed on ThreadPool threads (by default up to the processor count), and the results are merged back. It helps when the work is CPU-bound, the collection is large and in memory, and each item is expensive and pure: hashing, parsing, simulations, scoring. It makes things worse when the input is small or the per-item work is cheap (partitioning and merging cost more than they save), when the lambda does I/O (each partition blocks a pool thread), when lambdas share mutable state (races), and in ASP.NET Core request paths, where the cores are already busy serving other requests. Results are unordered unless you addAsOrdered(), exceptions arrive as anAggregateException, and you control it withWithDegreeOfParallelismandWithCancellation. I always benchmark before and after."
int[] (5M items)
โ AsParallel()
โผ
partitioning: range (arrays/lists) or chunk (other IEnumerable)
โ
[p1] [p2] [p3] [p4] ... on pool threads (+ the calling thread)
โ
merge: buffered (default) / not buffered / fully buffered; ordered only if AsOrdered()
โผ
consumer (Count, ToArray, foreach, ForAll)
| Operator | Effect |
|---|---|
WithDegreeOfParallelism(n) |
Max concurrent tasks (leave headroom: ProcessorCount - 1) |
AsOrdered() / AsUnordered() |
Preserve input order (buffering cost) / drop the requirement |
WithCancellation(ct) |
Stop early (throws OperationCanceledException) |
WithMergeOptions(...) |
NotBuffered streams results sooner; FullyBuffered maximises throughput |
WithExecutionMode(ForceParallelism) |
Override PLINQ's decision to run some query shapes sequentially |
ForAll(action) |
Process results in parallel without merging back (side effects must be thread-safe) |
| Helps | Hurts |
|---|---|
| Large in-memory arrays/lists | Small collections |
| Expensive pure per-item functions (ยตsโms each) | Cheap per-item work (ns) |
| Batch jobs, CLI tools, desktop apps with idle cores | ASP.NET Core request handling (cores already busy) |
| Pure functions, results returned from the query | Lambdas mutating shared collections/fields |
| CPU-bound | I/O-bound (HTTP, DB, files) |
IEnumerable in memory |
EF Core IQueryable: AsParallel() executes the query and pulls all rows into memory first |
| Situation | Behaviour |
|---|---|
| One partition throws | Query stops as soon as practical; other partitions stop |
| Several throw | All collected in an AggregateException |
WithCancellation cancelled |
OperationCanceledException |
| Workload | Tool |
|---|---|
| CPU-bound query over in-memory data | PLINQ |
| CPU-bound loop with side effects / per-thread state | Parallel.For / Parallel.ForEach |
| Many I/O operations with a concurrency limit | Parallel.ForEachAsync, or Task.WhenAll + SemaphoreSlim |
| Numeric kernels | SIMD: Vector<T>, TensorPrimitives |
| Multi-stage pipelines | Channels / TPL Dataflow |
Our [risk platform] computed scores for [5 million] in-memory positions with a pure function taking about [20 ยตs] each: [~100 s] sequentially in a nightly job. Adding AsParallel().WithDegreeOfParallelism(Environment.ProcessorCount - 1) brought it to [about 16 s] on 8 cores. But the scoring code was shared, and the same change landed on an API endpoint that scored positions on demand: under load, API p99 doubled, because the cores and pool threads were already busy with requests and PLINQ just added contention. I kept PLINQ in the batch job, removed it from the request path, and changed the API to read precomputed scores. We documented "no parallel loops in request paths" and added a load test to catch regressions.
Checks data-parallelism judgement.
Green flags
AggregateException.Red flags
AsParallel() to EF queries or HTTP calls.speed-up โค 1 / ((1 - p) + p / n) Amdahl: p = parallel fraction, n = cores
real speed-up = Amdahl โ partitioning cost โ merge cost โ contention โ GC
For tiny per-item work, partitioning and merging dominate and PLINQ is slower than sequential LINQ. Rule of thumb: if the whole sequential query takes only a few milliseconds, stay sequential. Benchmark with BenchmarkDotNet on production-like hardware.
| Source | Default partitioning | Notes |
|---|---|---|
Array / IList<T> |
Range (static, contiguous ranges) | Low overhead; uneven work per item can leave cores idle |
Other IEnumerable<T> |
Chunk (dynamic, growing chunk sizes, with locking) | Load-balances, more overhead |
| Custom | Partitioner.Create(...) |
E.g. load-balanced partitioning for arrays with uneven work |
AsOrdered() makes each partition keep indices and the merge reorders. Operators like Take(10) with AsOrdered() return the first ten in source order; without it, any ten. Use AsUnordered() after the order-sensitive part to regain throughput.
// WRONG: shared List<T> mutated from many threads
var results = new List<int>();
numbers.AsParallel().ForAll(n => { if (IsPrime(n)) results.Add(n); }); // lost items / exceptions
// RIGHT: let the query produce results
int[] primes = numbers.AsParallel().Where(IsPrime).ToArray();
// RIGHT: per-partition aggregation
long sum = numbers.AsParallel().Aggregate(
seed: 0L,
updateAccumulatorFunc: (acc, n) => acc + n,
combineAccumulatorsFunc: (a, b) => a + b,
resultSelector: total => total);
Under load, every core already runs request work. PLINQ in one request:
Result: one request gets faster in isolation, and everyone's p99 gets worse under load. Move heavy CPU work to background jobs or precompute.
| Version | Change |
|---|---|
| .NET Framework 4.0 | PLINQ, Parallel class |
| .NET 6 | Parallel.ForEachAsync (async alternative for I/O) |
| .NET 8 | TensorPrimitives for vectorised numeric work |
| .NET 10 | PLINQ API unchanged |
ForAll with a shared List<int> and observe exceptions or missing items; fix with ToArray().AsOrdered().Take(10) with unordered Take(10).Thread.Sleep(1) (simulated I/O) inside the lambda and watch thread count climb with dotnet-counters.Partitioner.Create(array, true)) for uneven work.using System.Diagnostics;
int[] numbers = Enumerable.Range(1, 3_000_000).ToArray();
var sw = Stopwatch.StartNew();
int sequential = numbers.Count(IsPrime);
Console.WriteLine($"Sequential: {sequential} primes in {sw.ElapsedMilliseconds} ms");
using var cts = new CancellationTokenSource();
sw.Restart();
int parallel = numbers
.AsParallel()
.WithDegreeOfParallelism(Math.Max(1, Environment.ProcessorCount - 1))
.WithCancellation(cts.Token)
.Count(IsPrime);
Console.WriteLine($"PLINQ: {parallel} primes in {sw.ElapsedMilliseconds} ms");
// Ordered output when order matters.
var firstTen = numbers.AsParallel().AsOrdered().Where(IsPrime).Take(10).ToArray();
Console.WriteLine(string.Join(", ", firstTen));
// Exceptions arrive as AggregateException.
try
{
_ = numbers.AsParallel().Select(n => n == 1_234_567 ? throw new InvalidDataException("bad") : n).ToArray();
}
catch (AggregateException ex)
{
Console.WriteLine($"Failed: {ex.InnerExceptions[0].Message}");
}
static bool IsPrime(int n)
{
if (n < 2) return false;
for (int d = 2; d * d <= n; d++)
if (n % d == 0) return false;
return true;
}
What happens: urls.AsParallel().Select(u => http.GetStringAsync(u).Result).
Why it is wrong: each partition blocks a pool thread while waiting โ starvation.
Do instead: Parallel.ForEachAsync with MaxDegreeOfParallelism, or throttled Task.WhenAll.
What happens: list.Add or dict[key] = ... inside parallel lambdas.
Why it is wrong: not thread-safe โ lost items, corrupted state, exceptions.
Do instead: return results from the query, use Aggregate with per-partition seeds, or concurrent collections.
What happens: AsParallel() on 100 items with trivial lambdas.
Why it is wrong: overhead exceeds benefit.
Do instead: benchmark; stay sequential for small total work.
What happens: a controller parallelises scoring per request. Why it is wrong: competes with other requests; tail latency worsens under load. Do instead: sequential per request; heavy CPU work in background jobs.
AsParallel() on EF Core queriesWhat happens: db.Orders.AsParallel().Where(...).
Why it is wrong: executes the full query and pulls all rows into memory first.
Do instead: filter in the database; parallelise only in-memory CPU work.
What happens: code relies on output order matching input.
Why it is wrong: PLINQ is unordered by default.
Do instead: AsOrdered() where order matters.
What happens: catch (InvalidDataException) around a PLINQ query.
Why it is wrong: exceptions arrive wrapped in AggregateException.
Do instead: catch AggregateException and inspect InnerExceptions.
What happens: default degree of parallelism on a box running other services.
Why it is wrong: starves co-located work.
Do instead: WithDegreeOfParallelism to leave headroom.
What happens: lambdas use a shared Random, StringBuilder or cache.
Why it is wrong: races and corrupted results.
Do instead: Random.Shared, thread-local helpers, or pure functions.
What happens: AsParallel() added on intuition.
Why it is wrong: often slower; sometimes faster only on dev machines.
Do instead: BenchmarkDotNet on production-like hardware, plus a load test for server code.
PLINQ is a batch-processing tool. I reach for it in jobs and desktop apps for expensive pure computations, I limit the degree of parallelism to leave room for other work, and I benchmark every use.
| Question | My check |
|---|---|
| CPU-bound? | If not, do not use PLINQ |
| Big enough? | Total sequential time well above milliseconds |
| Pure lambdas? | No shared mutable state |
| Server request path? | Avoid |
| Ordering needed? | Add AsOrdered() deliberately |
Parallel.ForEachAsync (.NET 6+) runs an async body for each item in a sequence (IEnumerable<T> or IAsyncEnumerable<T>) with a bounded number of concurrent operations, set by ParallelOptions.MaxDegreeOfParallelism (default: processor count). The body receives a CancellationToken that is cancelled if any iteration throws, so it stops early on failure. It is the simplest way to say "process these 10,000 items, at most 20 at a time". Compared with Task.WhenAll it limits concurrency; compared with SemaphoreSlim it needs less code. It does not return results, so write to a concurrent collection or a preallocated array.
The ThreadPool is a process-wide set of reusable background threads that run queued work items: Task.Run, async continuations, timer callbacks and ASP.NET Core requests. Work goes into a global queue or, when queued from a pool thread, into that thread's local queue, and idle threads steal from others. The pool starts up to minimum threads (default: processor count) immediately on demand; above that it adds threads gradually, using a hill-climbing algorithm that measures throughput plus a starvation detector that injects threads when queued work is not progressing. That gradual injection is why blocking pool threads causes latency spikes.
How to tell it (STAR)
Adding PLINQ to our [risk calculation] made the batch job six times faster but doubled API p99 latency. The API's cores were already busy, so parallelism only added contention. I kept PLINQ for the batch job, removed it from the request path and precomputed scores instead.
Last updated 31 Oct 2026.