Most LINQ queries run on a single thread, taking each element through the pipeline one after another. PLINQ is the parallel version: it splits the data across multiple threads, runs your Where and Select on those threads at the same time, and merges the results back. This lesson covers when PLINQ helps, when it hurts, and the operators that turn parallel queries on, tune them, and switch them back off.
PLINQ lives in System.Linq.ParallelEnumerable. It defines parallel versions of the standard LINQ operators (Where, Select, OrderBy, Sum, and so on) that operate on a different sequence type called ParallelQuery<T>. Once you have a ParallelQuery<T>, every operator you chain after it dispatches to the parallel implementation instead of the sequential one.
The entry point is one extension method: .AsParallel(). Call it on any IEnumerable<T> and the result is a ParallelQuery<T>. Everything chained after that runs in parallel.
The shape of the code barely changes from a normal LINQ query. The only difference is .AsParallel(). Behind the scenes, PLINQ partitions the input list into chunks, hands each chunk to a worker thread, runs the filter and projection on each thread, and merges the partial results before returning.
The diagram shows the basic shape: split, process in parallel, merge. PLINQ chooses the partitioning strategy, the thread count, and the merge approach for you, but every one of those choices is something you can override when you need to.
PLINQ runs on the .NET thread pool. It doesn't spin up raw OS threads of its own. That means it cooperates with other parallel work in the same process and respects the pool's heuristics about how many threads to use.
PLINQ pays off when each element involves a non-trivial amount of CPU work and you have enough elements (and enough cores) to make the partitioning overhead worth it.
A good shape looks like this:
Here's a price-recommendation calculation that simulates a heavier per-product workload:
The CPU-heavy ScoreProduct call is exactly the shape PLINQ likes: each call is independent, takes real CPU time, and there are 100,000 of them. The thread pool gets to keep multiple cores busy at once.
PLINQ has fixed overhead from partitioning, scheduling, and merging. For a query that runs in under a millisecond sequentially, that overhead almost always dominates. Start with sequential LINQ. Switch to PLINQ only when measurements show the work is large enough.
PLINQ is not a free speed-up. There are several shapes where adding .AsParallel() makes the code slower, less correct, or both.
Select(x => x * 2) on a million ints is faster sequentially. The work per element is so small that thread coordination dwarfs it.async/await and parallelize the I/O without burning a thread per request.Select writes to a list or a counter, PLINQ will produce wrong results unless you use thread-safe types (ConcurrentBag<T>, Interlocked.Add).A concrete bad case:
The query runs, but it's slower than the sequential version. There are only 100 elements, and multiplying a decimal is so cheap that the partitioning cost wins. For something like this, drop the .AsParallel().
Another bad case: shared state without synchronization.
List<T>.Add is not thread-safe. Two threads adding at the same time can corrupt the internal array or simply lose writes. The fix is either to use ConcurrentBag<T>, BlockingCollection<T>, or to project values out via Select(...).ToList() and let PLINQ handle the merge itself.
PLINQ picks sensible defaults, but four configuration methods let you take over when the defaults aren't right.
WithDegreeOfParallelism(n)By default, PLINQ uses up to Environment.ProcessorCount worker threads. On a 16-core machine, that's 16. Sometimes you want fewer (because other work on the same machine needs cores too) or more (because the work is mixed CPU and I/O).
Capping at 4 is useful when the same machine is running other workloads (an ASP.NET request pipeline, a background job) and you don't want PLINQ to monopolize every core. The maximum allowed value is 512.
AsOrdered()PLINQ doesn't preserve input order by default. Three threads finishing chunks at different rates produce results in the order they finished, not the order they started. For something like a Where projection that's going into a ToList, you usually don't care. For paged results or ranking, you do.
The ordered version guarantees the original sequence. The cost is real: PLINQ has to track each element's original index and reassemble the output in order at merge time.
AsOrdered() adds bookkeeping and limits some optimizations. Use it only when downstream code needs the original order. When only the final result needs order, it's sometimes cheaper to run unordered and call OrderBy at the end.
AsSequential()Sometimes you want a parallel head and a sequential tail. AsSequential() switches back to a regular IEnumerable<T>, so any operators after it run on one thread again.
The expensive Where runs in parallel. The OrderByDescending and Take are tail operations where parallelism rarely helps, so they run sequentially. This split is a clean idiom: parallelize the heavy lifting, then drop back for the final shaping.
WithExecutionMode(ParallelExecutionMode.ForceParallelism)PLINQ has a built-in heuristic: it inspects your query and, if it predicts parallel won't beat sequential, it silently runs sequentially anyway. This is conservative, and sometimes wrong. Operators like Take, TakeWhile, ElementAt, First, and Reverse often trigger the fallback because they have order dependencies.
WithExecutionMode lets you override the heuristic.
ForceParallelism tells PLINQ "this has been measured, run parallel even if the heuristic disagrees." The other option, Default, is the conservative behavior. Use ForceParallelism only when profiling shows the heuristic is wrong for the specific query.
WithMergeOptions(...)When PLINQ consumes results, it has to merge the partial outputs from each worker thread back into a single stream. There are three strategies, and you pick with WithMergeOptions:
| Option | Behavior | Best for |
|---|---|---|
NotBuffered | Pass each result downstream as soon as it's produced. | Streaming pipelines where you want partial results fast. |
AutoBuffered (default) | Buffer small chunks per thread before merging. | Most queries; balances throughput and latency. |
FullyBuffered | Wait for all partitions to finish, then merge. | Order-sensitive operations (OrderBy, Reverse); maximum throughput. |
NotBuffered is useful when you're consuming results inside a foreach and you want the first results to appear before the whole query finishes. FullyBuffered is implicit whenever you call something like OrderBy or ToList(), because those operations need every element before they can produce output.
ForAll for Parallel Side EffectsThe standard LINQ foreach is sequential. Even if you write myParallelQuery.ToList() followed by foreach, the iteration itself runs on one thread. PLINQ provides ForAll(Action<T>) to apply a side-effect operation in parallel without that final sequential step.
ForAll skips the merge step. Each thread runs the action on its partition and returns. That makes it faster than ToList() followed by a sequential foreach, but it comes with a sharp edge: the action runs on multiple threads at once, and anything it touches must be thread-safe.
The example uses ConcurrentBag<T> for that reason. A plain List<T>.Add would race. Writing to the console works only because Console.WriteLine has its own internal lock, but the output lines will interleave from different threads.
ForAll is for side effects only. To get results back as a collection, use ToList() or ToArray(). PLINQ's merge step is more efficient than a hand-rolled "loop and lock" pattern.
When a regular LINQ query throws, you catch one exception. When a PLINQ query throws, you might have many exceptions from many threads at once. PLINQ wraps them all in a single AggregateException whose InnerExceptions collection holds the originals.
PLINQ tries to short-circuit on the first exception (it cancels other threads), but threads already in flight can still throw, so the aggregate may contain more than one.
The standard pattern is to catch AggregateException and either iterate InnerExceptions or call Handle:
Handle runs the predicate against each inner exception. Returning true marks it as handled. Any exception not handled is rethrown inside a new AggregateException after the loop completes. This is the standard way to filter known errors and let unknown ones propagate.
The most useful real-world shape is a pipeline that mixes parallel and sequential stages. Heavy filtering and projection run in parallel; ordering, paging, and result-shaping run sequentially.
The filter and the heavy RelevanceScore projection run in parallel because that's where the work is. The OrderByDescending and Take(5) run sequentially because they need a global view of all scores. The split happens at AsSequential().
Use this pattern when you have:
The parallel head does the heavy lifting. The sequential tail handles the consistency-sensitive work.
A practical note: PLINQ is for in-memory data. It operates on IEnumerable<T>, which means the data is already in your process.
Entity Framework's IQueryable<T> is a different beast. EF translates LINQ operators into SQL and ships the query to the database. The database does its own parallelism (parallel query plans, partition scans), and PLINQ has no role there. Calling .AsParallel() on a DbSet<Product> would convert it to IEnumerable<Product> first, which pulls every row into memory before PLINQ can touch it. That's almost always the wrong thing to do.
If you have a database query that returns a large result set and you want to do CPU-heavy post-processing in parallel:
The ToListAsync() materializes the query. Once the data is in memory as List<Order>, PLINQ is fair game. Don't try to mix PLINQ into the EF query expression itself.
A few traps that show up over and over in real PLINQ code.
Forgetting that order isn't preserved. A query like data.AsParallel().Where(...).Select(...).ToList() will produce a list in some order, not the original order. If your downstream code assumes order, you'll get nondeterministic bugs that pass on a small input and fail on a large one. Add AsOrdered() or apply OrderBy at the end.
Using non-thread-safe state inside operators. A Select that increments a captured counter, appends to a captured list, or writes to a shared dictionary will misbehave. Either use thread-safe types from System.Collections.Concurrent, use Interlocked for counters, or restructure to avoid shared state (project values out and let PLINQ merge).
Adding `.AsParallel()` to fast queries. A query that runs in 5 ms sequentially will probably run in 8 ms in parallel because of overhead. Measure first; parallelize when measurement shows real per-element work.
Confusing PLINQ with `async`/`await`. PLINQ schedules CPU-bound work on the thread pool. It cannot speed up I/O. For parallel I/O (database fetches, HTTP calls), use Task.WhenAll with async methods, which is the next major section.
Catching `Exception` instead of `AggregateException`. Code that worked fine with sequential LINQ will look broken after adding .AsParallel() if the catch block expects a specific exception type. PLINQ wraps everything in AggregateException. Update the catch.
10 quizzes