Parallelism is about finishing CPU-bound work faster by using multiple cores at the same time. In .NET, the main tools are Parallel.ForEachAsync, PLINQ, and custom partitioned pipelines. Effective parallel code maximizes throughput while preserving determinism, bounded resource usage, and observability.

  • Async does not automatically mean parallel CPU execution.
  • Parallelism helps when each unit does meaningful CPU work.
  • Shared mutable state is the core risk; minimize or isolate it.
  • Throughput gains are bounded by Amdahl’s law and memory bandwidth.

How It Works

The practical pipeline is: partition work, execute partitions concurrently, collate results safely.

There are two patterns that are still useful decision anchors:

  • Data parallelism: same operation over many data elements. Usually scales better and is easier to reason about.
  • Task parallelism: different operations in parallel. Useful, but often less structured and harder to maintain.

Partitioning

How work is split across cores decides whether you get linear speedup or wasted threads. Parallel.For/ForEach and PLINQ use a Partitioner under the hood:

  • Range/chunk partitioning (default for indexable sources) hands each worker a contiguous block — low overhead, but suffers if the per-item cost is uneven (one worker draws all the slow items).
  • Dynamic partitioning (Partitioner.Create(source, loadBalance: true)) hands out small chunks on demand — higher coordination cost but balances skewed workloads.

Match the partitioner to your data: uniform cost → range; skewed cost → dynamic/load-balanced.

Example

public async Task<IReadOnlyList<Result>> ComputeAsync(
    IReadOnlyList<Job> jobs,
    CancellationToken cancellationToken)
{
    var results = new ConcurrentBag<Result>();
 
    await Parallel.ForEachAsync(
        jobs,
        new ParallelOptions
        {
            MaxDegreeOfParallelism = Environment.ProcessorCount,
            CancellationToken = cancellationToken
        },
        (job, ct) =>
        {
            var value = ExpensiveTransform(job, ct);
            results.Add(value);
            return ValueTask.CompletedTask;
        });
 
    return results.ToList();
}

PLINQ example for pure transforms

public int[] ComputePrimes(int fromInclusive, int toExclusive)
{
    return Enumerable.Range(fromInclusive, toExclusive - fromInclusive)
        .AsParallel()
        .Where(n => n > 1 && Enumerable.Range(2, (int)Math.Sqrt(n) - 1)
            .All(i => n % i != 0))
        .ToArray();
}

PLINQ works best when each element has enough CPU work to amortize partitioning and merge costs.

Lock-free accumulation with thread-local state

The cleanest way to aggregate in parallel without a shared lock is the Parallel.For overload with localInit/localFinally: each worker accumulates into its own local, and only the final merge touches shared state.

long total = 0;
Parallel.For(0, data.Length,
    () => 0L,                                   // localInit: per-worker accumulator
    (i, _, local) => local + data[i],           // body: no shared write
    local => Interlocked.Add(ref total, local)); // localFinally: one merge per worker

This replaces N contended writes with one merge per worker — far better scaling than Interlocked.Add on every iteration.

Pitfalls

  • Shared mutable state causes data races: ConcurrentBag<T> and similar collections add synchronization overhead. Prefer partition-local accumulators and merge at the end — the PLINQ Aggregate overload, the Parallel.For localInit/localFinally pattern above, or a ConcurrentDictionary keyed by partition ID are common patterns.
  • False sharing kills scaling silently: when workers update adjacent fields that share a 64-byte CPU cache line (e.g. long[] counts indexed per-thread), each write invalidates the line in every other core’s cache, serializing what looks like independent work. Symptom: adding cores makes it slower. Fix with per-worker locals (above), padding ([StructLayout] / spacing array entries onto separate cache lines), or PaddedReference-style wrappers.
  • Over-parallelizing I/O-bound work wastes threads: Parallel.ForEach on I/O operations blocks thread-pool threads while waiting on I/O. Use async/await with SemaphoreSlim to cap concurrency without blocking threads.
  • Ignoring Amdahl’s Law: the serial fraction of your workload caps the maximum speedup regardless of core count. Profile first — if 20% of work is serial, you can never exceed 5× speedup no matter how many cores you add.
  • PLINQ ordering overhead: .AsParallel().AsOrdered() forces a merge step that can negate parallelism gains. Only add AsOrdered() when the consumer actually requires ordered output.
  • Unhandled exceptions in parallel bodies: exceptions from parallel workers are wrapped in AggregateException. Callers that only catch Exception will miss them. Always unwrap with .Flatten().Handle(...) or inspect InnerExceptions.

Tradeoffs

ApproachBest forCost
Parallel.ForEachAsyncCPU-bound work with async-compatible bodiesOverhead per partition; requires CancellationToken threading
PLINQPure transforms on in-memory sequencesMerge cost; ordering adds extra overhead; harder to debug
Task.WhenAll fan-outI/O-bound work (HTTP, DB)Thread-pool friendly; no CPU parallelism benefit
Manual partitioning + channelsStreaming pipelines with backpressureMost complex; best throughput for producer/consumer patterns

Decision rule: start with Parallel.ForEachAsync for CPU-bound batch work. Switch to PLINQ when the operation is a pure transform and you want terse syntax. Use Task.WhenAll for I/O. Reach for channels only when you need backpressure or streaming.

Two other parallelism axes worth knowing exist beyond multi-threading:

  • TPL Dataflow (System.Threading.Tasks.Dataflow) — composable blocks (TransformBlock, ActionBlock, BufferBlock) for multi-stage producer/consumer pipelines with per-block degree-of-parallelism and built-in backpressure.
  • SIMD / data-level parallelismVector<T>, System.Numerics, and hardware intrinsics process multiple elements per instruction on a single thread. For tight numeric loops this can beat thread-level parallelism with none of the coordination cost, and composes with it.

Questions

References