Parallel programming in .NET lets a single process put every CPU core to work instead of leaving them idle while one thread grinds through a loop. The Task Parallel Library (TPL), PLINQ and System.Threading.Channels are three different tools for that job, and picking the wrong one is a common source of both wasted performance and subtle bugs. This guide is for C# developers building batch jobs, data pipelines or backend services who need to split work across cores correctly: Parallel.For, Parallel.ForEach and Parallel.ForEachAsync, PLINQ, degree-of-parallelism and partitioning, thread-safety with Interlocked and the .NET 9 System.Threading.Lock type, channel-based producer-consumer pipelines and TPL Dataflow, plus a clear rule for choosing between them.

What Is Parallel Programming? Concurrency vs. Parallelism#

Concurrency and parallelism solve different problems, and conflating them is the root cause of most bad decisions in this area. Concurrency means structuring a program so multiple operations can be in progress over the same period, even if they never execute at literally the same instant. async/await gives you concurrency: while a database call is in flight, the thread that started it is free to do other work, and no extra CPU core is involved. Parallelism means executing multiple computations at the same instant, which requires multiple CPU cores. The TPL and PLINQ give you parallelism: they split a workload into pieces and run those pieces on separate threads simultaneously.

The practical rule follows directly from the definitions. If the work spends most of its time waiting on the network, a disk or a database, you want concurrency, and adding threads only means more threads waiting; reach for async/await, covered in depth in Async/Await in C#: A Deep Dive. If the work spends its time actually computing, such as transforming a large in-memory collection, resizing images or running a numerical model, you want parallelism, and that is where the TPL and PLINQ earn their keep. Channels sit in between: they coordinate producers and consumers concurrently, and that coordination becomes parallel the moment you run more than one consumer task at a time.

Almost everything in this guide ultimately schedules work onto the shared .NET thread pool, so understanding it explains a lot of the behavior you will see in practice, which the next section covers.

How the Thread Pool Schedules Parallel Work#

The thread pool maintains a small number of worker threads that are reused across every Task, Parallel.For iteration and PLINQ partition in the process; it does not spin up a new OS thread per unit of work. Each worker thread has a local work-stealing queue, and idle threads steal items from busy threads' queues when their own queue empties, which keeps cores balanced without a central lock becoming a bottleneck. When demand briefly exceeds the current thread count, the pool injects new threads using a hill-climbing heuristic that adds capacity gradually rather than all at once, which is why a sudden burst of blocking calls can cause a short, visible delay before throughput catches up.

Parallel.For and Parallel.ForEach build on this pool by partitioning the source into chunks and handing each chunk to a worker as a Task. By default, the runtime targets roughly Environment.ProcessorCount concurrent workers, though it can use fewer if the pool decides that is more efficient, and more only up to that cap unless you raise it explicitly. This is why a Parallel.ForEach over a purely CPU-bound delegate scales with core count, while the same construct wrapped around blocking I/O just starves the pool instead of going faster.

Getting Started: Parallel.For and Parallel.ForEach#

Parallel.For and Parallel.ForEach are the simplest entry points: give them a range or a sequence and a delegate, and the runtime partitions the work across cores automatically.

C#
// CPU-bound: apply a filter to every image in memory
Parallel.For(0, images.Length, i =>
{
    images[i] = ImageFilters.ApplySharpen(images[i]);
});

Production code almost always needs more control than the two-argument overload gives you: a cap on parallelism, a cancellation token, and a way to aggregate results without a lock on every iteration. Parallel.ForEach has an overload that supports partition-local state, so each partition accumulates into its own local variable and only merges into shared state once, at the end.

C#
ParallelOptions options = new()
{
    MaxDegreeOfParallelism = Environment.ProcessorCount,
    CancellationToken = cancellationToken
};

long totalBytes = 0;

Parallel.ForEach(
    source: files,
    parallelOptions: options,
    localInit: () => 0L,
    body: (file, state, localSum) => localSum + file.Length,
    localFinally: localSum => Interlocked.Add(ref totalBytes, localSum));

The localInit/body/localFinally pattern avoids one Interlocked.Add per file and instead does one per partition, which matters once the collection has millions of elements.

Parallel.ForEachAsync for I/O-Bound Fan-Out#

Parallel.ForEachAsync looks like Parallel.ForEach but is built for asynchronous work: the body is an async delegate, and the runtime limits how many are in flight at once via MaxDegreeOfParallelism without blocking a thread for each one. This is the right tool when you need bounded concurrent I/O, such as calling a downstream API for thousands of records, rather than true CPU parallelism.

C#
ParallelOptions options = new()
{
    MaxDegreeOfParallelism = 16,   // bound concurrent HTTP calls, not CPU cores
    CancellationToken = cancellationToken
};

await Parallel.ForEachAsync(orderIds, options, async (orderId, ct) =>
{
    OrderStatus status = await shippingClient.GetStatusAsync(orderId, ct);
    await repository.UpdateStatusAsync(orderId, status, ct);
});

Never use plain Parallel.ForEach with an async lambda: the delegate signature expects a synchronous body, so an async void-shaped lambda fires and forgets, the loop reports completion before the work finishes, and exceptions vanish. Parallel.ForEachAsync exists precisely to close that gap.

PLINQ: Declarative Parallel Queries#

PLINQ parallelizes standard LINQ query syntax by calling AsParallel(), which hands the query to a parallel query engine instead of the sequential one. The engine partitions the source, runs the query operators across multiple threads, and merges the results back together, all without you writing any explicit partitioning or aggregation code.

C#
decimal totalRevenue = orders
    .AsParallel()
    .WithDegreeOfParallelism(Environment.ProcessorCount)
    .Where(o => o.Status == OrderStatus.Completed)
    .Sum(o => o.Total);

// Preserve source order when it matters, at some cost to throughput
IEnumerable<Order> sortedHighValue = orders
    .AsParallel()
    .AsOrdered()
    .Where(o => o.Total > 1000)
    .OrderByDescending(o => o.Total);

PLINQ decides at run time whether parallelizing is actually worthwhile; for a small source or a cheap delegate, it may run sequentially because the overhead of partitioning and merging would outweigh the benefit. WithExecutionMode(ParallelExecutionMode.ForceParallelism) overrides that heuristic, which is useful when benchmarking or when you know better than the default heuristic for your workload. When a query only performs a side effect per element rather than producing a result to enumerate, ForAll is faster than a foreach loop over the query, because foreach has to buffer and re-merge results in order while ForAll invokes the delegate directly on each worker thread as results become available.

Degree of Parallelism and Partitioning#

MaxDegreeOfParallelism caps how many partitions run concurrently; it does not guarantee that many threads run, only that no more than that many will. The default, -1, lets the runtime decide, which is usually correct, but you should cap it explicitly when the parallel work shares a limited downstream resource, such as a connection pool, or when it runs alongside other important work, such as inside a web server process where you do not want a background job to starve request-handling threads.

By default, Parallel.For and Parallel.ForEach use range or chunk partitioning that assumes each element costs roughly the same amount of work. When item cost varies widely, a custom partitioner keeps cores balanced instead of leaving fast workers idle while one worker churns through a batch of expensive items.

C#
using System.Collections.Concurrent;

// Loads balance items with wildly different processing costs across workers
Partitioner<WorkItem> partitioner =
    Partitioner.Create(workItems, EnumerablePartitionerOptions.NoBuffering);

Parallel.ForEach(partitioner, options, item => Process(item));

EnumerablePartitionerOptions.NoBuffering makes each worker pull one item at a time instead of pre-fetching a chunk, which trades a little throughput for much better load balancing on uneven workloads.

Thread-Safety: Locks, Interlocked and System.Threading.Lock#

Every parallel construct in this guide shares the same hazard: two threads mutating the same field at once produce a race condition, and the bug is usually invisible in testing and expensive in production. Three tools cover almost every case. Interlocked performs a single atomic operation, such as an increment or a compare-and-swap, without taking a lock at all, and it is the right choice for simple counters and lock-free algorithms.

C#
private long _processedCount;
private long _maxLatencyMs;

void RecordCompletion(long latencyMs)
{
    Interlocked.Increment(ref _processedCount);

    long observed = Volatile.Read(ref _maxLatencyMs);
    while (latencyMs > observed)
    {
        long previous = Interlocked.CompareExchange(ref _maxLatencyMs, latencyMs, observed);
        if (previous == observed)
        {
            break;
        }
        observed = previous;
    }
}

For anything larger than a single atomic operation, you need a lock. .NET 9 introduced System.Threading.Lock, a dedicated synchronization type that is faster than the historical Monitor-based approach and cannot be confused with locking on an arbitrary object. In C# 13 and later, the lock statement recognizes when its target is a Lock instance and emits calls to Lock.EnterScope, which returns a ref struct scope disposed at the end of the block, instead of the older Monitor.Enter/Exit pair.

C#
private readonly Lock _gate = new();
private readonly Dictionary<string, decimal> _balances = new();

public void Adjust(string account, decimal delta)
{
    lock (_gate)   // compiles to Lock.EnterScope() on .NET 9+ with C# 13+
    {
        _balances[account] = _balances.GetValueOrDefault(account) + delta;
    }
}

Adopting it is usually a one-line change: replace private readonly object _gate = new(); with private readonly Lock _gate = new(); and leave every lock (_gate) statement untouched. The one thing Lock does not support is Monitor.Wait/Pulse signaling or being passed around as a general object; keep using Monitor or SemaphoreSlim for those cases, and reach for ConcurrentDictionary<TKey, TValue> or another concurrent collection instead of a hand-rolled lock whenever a built-in one fits.

Producer-Consumer Pipelines with System.Threading.Channels#

System.Threading.Channels models an async-first, thread-safe queue between producers and consumers. Channel.CreateBounded<T> applies backpressure: once the channel is full, writers await until a consumer catches up, which prevents an unbounded queue from growing without limit when consumers fall behind. Channel.CreateUnbounded<T> skips that limit for pipelines where memory pressure is not a concern. Since .NET 9, Channel.CreateUnboundedPrioritized orders reads by an IComparer<T> instead of strict first-in-first-out, which is useful when some queued items are more urgent than others.

C#
Channel<OrderEvent> channel = Channel.CreateBounded<OrderEvent>(new BoundedChannelOptions(1_000)
{
    FullMode = BoundedChannelFullMode.Wait,
    SingleReader = false,
    SingleWriter = true
});

async Task ProduceAsync(CancellationToken ct)
{
    await foreach (OrderEvent evt in eventSource.ReadAllAsync(ct))
    {
        await channel.Writer.WriteAsync(evt, ct);
    }
    channel.Writer.Complete();   // signals readers that no more items are coming
}

async Task ConsumeAsync(CancellationToken ct)
{
    await foreach (OrderEvent evt in channel.Reader.ReadAllAsync(ct))
    {
        await orderProjector.ApplyAsync(evt, ct);
    }
}

// Run several consumers in parallel against the same channel
await Task.WhenAll(ProduceAsync(cts.Token), ConsumeAsync(cts.Token), ConsumeAsync(cts.Token));

Channels compose naturally with IHostedService-based background workers; see Background Services and Worker Processes in .NET for hosting a pipeline like this behind the generic host. Compared with the older BlockingCollection<T>, channels are async all the way through, so no thread ever blocks waiting for space or an item, which is what makes running several consumer tasks in parallel cheap.

TPL Dataflow for Multi-Stage Pipelines#

System.Threading.Tasks.Dataflow (the System.Threading.Tasks.Dataflow NuGet package, since it does not ship in the shared framework) builds on the TPL to model a pipeline as a graph of blocks: TransformBlock<TIn, TOut> maps items, ActionBlock<T> consumes them, and BufferBlock<T>, BatchBlock<T> and JoinBlock handle buffering, batching and merging multiple inputs. Blocks are linked with LinkTo, and PropagateCompletion flows completion and faults downstream automatically, which channels leave up to you to wire by hand.

C#
var parse = new TransformBlock<string, OrderRecord>(
    line => OrderRecord.Parse(line),
    new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 4 });

var save = new ActionBlock<OrderRecord>(
    record => repository.UpsertAsync(record),
    new ExecutionDataflowBlockOptions
    {
        MaxDegreeOfParallelism = 8,
        BoundedCapacity = 200   // backpressure: pauses 'parse' once 'save' falls behind
    });

parse.LinkTo(save, new DataflowLinkOptions { PropagateCompletion = true });

foreach (string line in File.ReadLines(path))
{
    await parse.SendAsync(line);
}
parse.Complete();
await save.Completion;

Reach for Dataflow when a pipeline has multiple stages with different degrees of parallelism, branching, batching or joining, and you want completion and error propagation handled for you. For a single producer-consumer stage, a hand-rolled channel is usually simpler and has one less package to track.

TPL vs PLINQ vs Channels vs Dataflow: Which Tool When?#

SituationReach forWhy
CPU-bound loop over a collection or rangeParallel.For / Parallel.ForEachSimple, automatic partitioning across cores
CPU-bound LINQ-style query or aggregationPLINQ (AsParallel)Declarative, integrates with existing LINQ code
Many concurrent I/O calls, bounded fan-outParallel.ForEachAsyncAsync body, no thread blocked per call
Single async producer-consumer streamSystem.Threading.ChannelsAsync-first, backpressure, lightweight
Multi-stage pipeline with branching or batchingTPL DataflowBuilt-in linking, completion and fault propagation
A handful of independent async operationsTask.WhenAllNo dedicated infrastructure needed

Best Practices#

  • Measure before parallelizing. A loop that already runs in a few milliseconds will not get faster from partitioning overhead; profile first, as covered in High-Performance .NET: Techniques That Actually Matter.
  • Never nest CPU-bound parallel constructs. A Parallel.For inside another Parallel.For oversubscribes the thread pool and usually runs slower than a single flat loop.
  • Use partition-local state for aggregation instead of an Interlocked call per iteration when the collection is large.
  • Cap MaxDegreeOfParallelism whenever parallel work shares a scarce downstream resource, such as a database connection pool.
  • Keep the critical section inside a lock as small as possible, and never perform I/O or call unknown code while holding one.
  • Pass a CancellationToken to every Parallel, PLINQ and channel API so long-running work can be stopped cleanly.
  • Prefer bounded channels in production pipelines; unbounded ones can grow without limit if a consumer stalls.

Common Pitfalls#

  • Blocking I/O inside Parallel.ForEach. Synchronous HTTP or database calls inside a parallel loop exhaust the thread pool; use Parallel.ForEachAsync or a channel-based pipeline instead.
  • Swallowing AggregateException. Exceptions thrown inside Parallel.For, Parallel.ForEach or a PLINQ query are wrapped in an AggregateException; catch and inspect InnerExceptions rather than assuming a single exception type.
  • Unsynchronized shared state. Writing to a shared List<T> or dictionary from multiple iterations without a lock or a concurrent collection corrupts data intermittently, which makes it hard to reproduce; see Deadlocks and Race Conditions Interview Questions for the underlying failure modes.
  • Parallelizing inside a request handler. Spinning up Parallel.For inside an ASP.NET Core action competes with the server's own thread-pool demand under load; prefer it in batch jobs, workers and CLI tools.
  • Blocking on async code with .Result or .Wait() anywhere near a parallel or channel-based pipeline, which is a common source of deadlocks and starvation.
  • Forgetting Writer.Complete() on a channel, which leaves ReadAllAsync awaiting forever even after the last item has been produced.

Frequently Asked Questions#

What is the difference between concurrency and parallelism in .NET?#

Concurrency is about structure: multiple operations can be in progress at once, which async/await provides without necessarily using more than one core. Parallelism is about execution: multiple computations run at literally the same instant on multiple cores, which is what the TPL and PLINQ provide. I/O-bound work needs concurrency; CPU-bound work needs parallelism.

When should I use PLINQ instead of a Parallel.ForEach loop?#

Prefer PLINQ when the work is already naturally expressed as a LINQ query, such as filtering, projecting and aggregating a collection, since AsParallel() parallelizes it with almost no code change. Prefer Parallel.ForEach when the body has side effects, needs fine control over partitioning or partition-local state, or does not map cleanly onto query operators.

Does Parallel.For create one thread per iteration?#

No. Parallel.For and Parallel.ForEach partition the source into chunks and schedule those chunks as tasks on the shared thread pool, which typically runs around Environment.ProcessorCount workers regardless of how many iterations the loop has. Millions of iterations do not mean millions of threads.

Is System.Threading.Channels a replacement for TPL Dataflow?#

For a single producer-consumer stage, yes, and channels are lighter weight with no extra package needed on modern target frameworks. For a pipeline with several stages, branching, batching or automatic completion and fault propagation across stages, TPL Dataflow still does more of that work for you out of the box.

Should I replace every lock statement with System.Threading.Lock in .NET 9?#

For new code targeting .NET 9 or later with C# 13 or later, yes: change the field's type from object to Lock and the existing lock statements keep working, now compiled against the faster API. Do not change fields that are locked from other assemblies you do not control, or objects also used for Monitor.Wait/Pulse signaling, since Lock does not support that pattern.

Summary#

  • Concurrency (async/await) and parallelism (TPL, PLINQ) solve different problems; match the tool to whether work is I/O-bound or CPU-bound.
  • Parallel.For/ForEach and PLINQ both run on the shared thread pool and partition work automatically; use Parallel.ForEachAsync for bounded concurrent I/O.
  • Cap MaxDegreeOfParallelism and use a custom partitioner when work is uneven or shares a scarce resource.
  • Prefer Interlocked for simple atomics, and System.Threading.Lock over a plain object lock on .NET 9 and later.
  • Reach for System.Threading.Channels for async producer-consumer pipelines, and TPL Dataflow when a pipeline has multiple linked stages.

Further Reading#