← Back to Blog

Parallel Programming: PLINQ and Task Parallel Library

If you've ever watched your application chug along while only using a fraction of your CPU, you've experienced the frustration that parallel programming was designed to solve. Modern computers ship with multiple cores-some have 8, 16, or even more-but most applications barely scratch the surface of this potential. C# provides two powerful tools-PLINQ and the Task Parallel Library-that make harnessing this power surprisingly accessible.

In my experience working with high-performance systems, the difference between single-threaded and parallel code can be dramatic. I've seen data processing jobs that took hours complete in minutes, and real-time applications that finally met their latency requirements. But I've also seen plenty of cases where parallelization made things worse-slower, more complex, and buggier.

The key is understanding not just how to write parallel code, but when to use it and how to avoid the common pitfalls. We'll explore PLINQ for data-parallel operations and the Task Parallel Library for more complex coordination scenarios. Along the way, I'll share the lessons I've learned about making parallel programming work in real applications.

The Parallel Programming Mindset

Before we dive into the code, let's talk about the fundamental shift in thinking that parallel programming requires. Traditional sequential programming is like following a recipe step by step. Parallel programming is like having multiple chefs working simultaneously in the same kitchen.

The challenge is that not all problems can be parallelized. Some tasks have inherent dependencies-you can't frost a cake before you bake it. Others involve shared resources that create conflicts. The first step in successful parallel programming is learning to recognize which problems can benefit from parallelism and which ones can't.

Parallel programming isn't about making everything faster-it's about leveraging multiple cores for problems that can be divided into independent subtasks.

When Parallel Programming Makes Sense

I've learned through experience that parallel programming works best when you have CPU-bound work that can be divided into independent chunks. Let me give you some concrete examples from my career:

In one project, we were processing large financial datasets-millions of transactions that needed validation, aggregation, and reporting. Each transaction could be processed independently, and the work was entirely CPU-bound. Parallel processing cut our batch processing time from 4 hours to 45 minutes.

But in another case, we tried to parallelize a web scraping application. The bottleneck was network I/O, not CPU processing. Adding more threads just created more network congestion and actually slowed things down. The lesson? Parallel programming helps with CPU-bound work, but for I/O-bound work, async/await is usually the better choice.

// Good candidate for parallelization - CPU-bound, independent operations
var results = transactions
    .AsParallel()
    .Select(t => ValidateTransaction(t))
    .Where(valid => valid.IsApproved)
    .ToList();

// Poor candidate - I/O-bound operations
var webResults = urls
    .AsParallel()  // This might make things worse!
    .Select(url => DownloadContent(url))
    .ToList();

The rule of thumb I use: only parallelize if you expect at least a 1.5-2x speedup. Anything less usually isn't worth the complexity.

PLINQ: Making LINQ Parallel

PLINQ is probably the easiest way to get started with parallel programming in C#. If you already know LINQ, PLINQ will feel familiar-just add .AsParallel() to your query.

What makes PLINQ special is that it handles all the complexity of parallel execution for you. It automatically partitions your data, distributes work across cores, and merges the results back together. You write declarative code that describes what you want, and PLINQ figures out the how.

var numbers = Enumerable.Range(1, 1000000);

// Sequential processing
var sequentialSquares = numbers
    .Where(n => n % 2 == 0)
    .Select(n => n * n)
    .ToList();

// Parallel processing - just add AsParallel()
var parallelSquares = numbers
    .AsParallel()
    .Where(n => n % 2 == 0)
    .Select(n => n * n)
    .ToList();

That single call to AsParallel() can dramatically improve performance on large datasets. In my testing, this simple change has provided 3-4x speedups on typical workloads.

But PLINQ isn't magic. It has overhead, so for small datasets, the sequential version might actually be faster. PLINQ works best when:

  • You have thousands or millions of elements
  • The processing per element is CPU-intensive
  • The operations are independent (no side effects)

Controlling PLINQ Behavior

While PLINQ handles most details automatically, you sometimes need to fine-tune its behavior. The library provides several options for this.

var data = GetLargeDataset();

// Limit parallelism to avoid overwhelming the system
var controlledParallel = data
    .AsParallel()
    .WithDegreeOfParallelism(4)
    .Where(item => ExpensiveValidation(item));

// Preserve order when it matters
var orderedResults = data
    .AsParallel()
    .AsOrdered()
    .Select(item => Transform(item))
    .Take(100);

WithDegreeOfParallelism() is particularly useful in production environments where you don't want your application to use all available cores. I've used this in web applications to ensure that background processing doesn't starve the request-handling threads.

AsOrdered() preserves the original order of elements. This is more expensive than unordered processing, so only use it when you actually need the results in order.

Performance Tip: PLINQ's default merge strategy buffers results to optimize for throughput. For streaming scenarios, consider WithMergeOptions() to reduce memory usage.

The Task Parallel Library: Fine-Grained Control

While PLINQ is great for data processing, the Task Parallel Library (TPL) gives you more control over parallel execution. It's lower-level than PLINQ but more flexible for complex scenarios.

The TPL is built around the concept of tasks-units of work that can run concurrently. Unlike threads, tasks are lightweight and managed by the .NET runtime, which can efficiently schedule them across available cores.

// Parallel.For - parallel version of a for loop
Parallel.For(0, 1000, i => {
    ProcessItem(i);
});

// Parallel.ForEach - parallel version of foreach
Parallel.ForEach(largeCollection, item => {
    ProcessItem(item);
});

These methods automatically divide the work among available cores. The runtime handles load balancing, so cores that finish early can help with remaining work.

One of the most powerful features of the TPL is cancellation support. This is crucial for responsive applications that need to handle user cancellation or timeouts.

var cts = new CancellationTokenSource();

// Cancel after 5 seconds
cts.CancelAfter(TimeSpan.FromSeconds(5));

try {
    Parallel.ForEach(data, new ParallelOptions {
        CancellationToken = cts.Token,
        MaxDegreeOfParallelism = Environment.ProcessorCount
    }, item => {
        cts.Token.ThrowIfCancellationRequested();
        ProcessItem(item);
    });
} catch (OperationCanceledException) {
    Console.WriteLine("Operation was cancelled");
}

Cancellation in parallel operations is more complex than in sequential code because multiple operations might be running simultaneously. The TPL handles this by propagating cancellation to all active operations.

Task Coordination Patterns

One of the most challenging aspects of parallel programming is coordinating between tasks. The TPL provides several patterns for this.

// Wait for all tasks to complete
var tasks = new[] {
    Task.Run(() => ProcessBatch(batch1)),
    Task.Run(() => ProcessBatch(batch2)),
    Task.Run(() => ProcessBatch(batch3))
};

Task.WaitAll(tasks);

// Wait for first task to complete
int completedIndex = Task.WaitAny(tasks);
Console.WriteLine($"Task {completedIndex} finished first");

Task.WaitAll() is useful when you need all results before proceeding. Task.WaitAny() is great for scenarios like racing multiple algorithms or waiting for the fastest response from multiple services.

For more complex coordination, the TPL Dataflow library provides powerful primitives like BufferBlock and ActionBlock.

var buffer = new BufferBlock();

// Producer task
var producer = Task.Run(async () => {
    for (int i = 0; i < 100; i++) {
        await buffer.SendAsync(i);
    }
    buffer.Complete();
});

// Consumer task
var consumer = Task.Run(async () => {
    while (await buffer.OutputAvailableAsync()) {
        int item = await buffer.ReceiveAsync();
        ProcessItem(item);
    }
});

await Task.WhenAll(producer, consumer);

This producer-consumer pattern is incredibly useful for streaming scenarios where data is produced and consumed at different rates. The buffer handles the coordination automatically.

Concurrent Collections: Thread-Safe Data Structures

When multiple threads need to share data, you need thread-safe collections. The System.Collections.Concurrent namespace provides several options.

var concurrentDict = new ConcurrentDictionary();
concurrentDict.TryAdd("key", 42);
int value = concurrentDict.GetOrAdd("key", k => ComputeValue(k));

// ConcurrentBag for unordered collections
var bag = new ConcurrentBag();
Parallel.ForEach(files, file => bag.Add(ReadFile(file)));

// ConcurrentQueue for FIFO operations
var queue = new ConcurrentQueue();
Parallel.ForEach(workItems, item => queue.Enqueue(ProcessItem(item)));

These collections handle synchronization internally, eliminating the need for manual locking in most cases. I've found that using concurrent collections often simplifies code significantly compared to manual synchronization.

The key insight is that concurrent collections are optimized for high-concurrency scenarios. They use fine-grained locking or lock-free algorithms to minimize contention.

Prefer concurrent collections over manual locking. They're safer, more performant, and less error-prone than custom synchronization code.

Synchronization Primitives

Sometimes you need more control than concurrent collections provide. That's when you reach for synchronization primitives.

// Simple mutual exclusion
private readonly object _lock = new object();
private int _counter;

public void IncrementCounter() {
    lock (_lock) {
        _counter++;
    }
}

// Atomic operations
Interlocked.Increment(ref _counter);

// Reader-writer lock for read-heavy scenarios
private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();

public int ReadData() {
    _rwLock.EnterReadLock();
    try {
        return _sharedData;
    } finally {
        _rwLock.ExitReadLock();
    }
}

The lock statement is the most basic synchronization primitive. It ensures only one thread can execute the locked code at a time.

Interlocked provides atomic operations for simple increments and exchanges. These are more efficient than locks for simple operations.

ReaderWriterLockSlim is perfect for scenarios where you have many readers and few writers. It allows multiple threads to read simultaneously but ensures exclusive access for writes.

Exception Handling in Parallel Operations

Exception handling in parallel code is more complex because multiple exceptions can occur simultaneously. The TPL aggregates these into an AggregateException.

try {
    Parallel.ForEach(data, item => {
        if (item == null) {
            throw new ArgumentNullException(nameof(item));
        }
        ProcessItem(item);
    });
} catch (AggregateException ex) {
    foreach (var innerException in ex.InnerExceptions) {
        Log.Error(innerException, "Parallel processing error");
    }
}

Always catch AggregateException when working with parallel operations. The InnerExceptions collection contains all the exceptions that occurred.

In PLINQ, exceptions are also aggregated:

try {
    var results = data
        .AsParallel()
        .Select(item => ProcessItem(item))
        .ToList();
} catch (AggregateException ex) {
    // Handle aggregated exceptions
}

Performance Optimization Strategies

Getting good performance from parallel code requires understanding how work is distributed. The key is minimizing overhead while maximizing core utilization.

// Chunking for better cache locality
Parallel.ForEach(data, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
    () => new List(), // Thread-local state
    (item, state, localList) => {
        var result = ProcessItem(item);
        localList.Add(result);
        return localList;
    }, localList => {
        // Merge results from this thread
        MergeResults(localList);
    });

This pattern uses thread-local storage to collect results locally before merging them. It reduces contention and improves cache locality.

Another important consideration is work distribution. Some tasks take longer than others, so static partitioning might not be optimal.

// Dynamic partitioning for variable work
var partitioner = Partitioner.Create(data, loadBalancing: true);
Parallel.ForEach(partitioner, item => {
    // Work amount varies by item
    ProcessVariableWorkItem(item);
});

Dynamic partitioning allows the runtime to rebalance work as threads complete their tasks. This is particularly important when processing items of varying complexity.

Real-World Example: Parallel Image Processing

Let me show you a complete example that brings together many of the concepts we've discussed. We'll build a parallel image processing pipeline.

public async Task ProcessImagesAsync(string[] imagePaths, CancellationToken token = default)
    {
        // Load images in parallel (I/O bound)
        var loadTasks = imagePaths.Select(path => Task.Run(() => LoadImage(path), token));
        var images = await Task.WhenAll(loadTasks);

        // Process images in parallel (CPU bound)
        var processTasks = images.Select(image => Task.Run(() => ProcessImage(image), token));
        var processedImages = await Task.WhenAll(processTasks);

        // Save results (I/O bound)
        var saveTasks = processedImages.Select((image, index) =>
            Task.Run(() => SaveImage(image, $"processed_{index}.jpg"), token));
        await Task.WhenAll(saveTasks);
    }

    private Bitmap ProcessImage(Bitmap image)
    {
        // Apply multiple filters in parallel
        var filters = new[] {
            Task.Run(() => ApplyBlur(image)),
            Task.Run(() => ApplySharpen(image)),
            Task.Run(() => AdjustBrightness(image))
        };
        Task.WaitAll(filters);
        return image;
    }

This example demonstrates combining different types of parallelism. We use async/await for I/O operations and parallel tasks for CPU-intensive processing. The key insight is matching the right tool to each type of work.

Debugging Parallel Code

Debugging parallel code requires different techniques than sequential code. Race conditions and timing issues can make bugs appear intermittently.

// Add thread information to debug output
Parallel.ForEach(data, item => {
    Debug.WriteLine($"Processing {item} on thread {Thread.CurrentThread.ManagedThreadId}");
    ProcessItem(item);
});

// Use conditional breakpoints
if (Thread.CurrentThread.ManagedThreadId == 1) {
    Debugger.Break();
}

Visual Studio's Parallel Stacks window is invaluable for understanding what's happening across threads. It shows the call stack for each thread simultaneously.

For complex issues, I often add temporary sequential processing to isolate whether the problem is in the parallel logic or the business logic itself.

Testing Parallel Code

Testing parallel code requires careful consideration. You need to test both correctness and thread safety.

[Test]
public void ParallelProcessing_ProducesCorrectResults()
{
    var data = Enumerable.Range(1, 1000);
    var expected = data.Select(x => x * 2).OrderBy(x => x);

    var result = data.AsParallel()
                    .Select(x => x * 2)
                    .OrderBy(x => x);

    Assert.Equal(expected, result);
}

[Test]
public void ParallelProcessing_IsThreadSafe()
{
    var counter = 0;
    var data = Enumerable.Range(1, 1000);

    Parallel.ForEach(data, _ => Interlocked.Increment(ref counter));

    Assert.Equal(1000, counter);
}

The first test ensures correctness-parallel processing should produce the same results as sequential processing. The second test verifies thread safety using atomic operations.

For more thorough testing, consider using the Microsoft.VisualStudio.TestTools.UnitTesting namespace's parallel testing features or libraries like xUnit's parallel test runners.

Common Pitfalls and How to Avoid Them

Over the years, I've seen the same mistakes repeated in parallel code. Here are the most common ones and how to avoid them.

Warning: Parallel code can introduce subtle bugs like race conditions and deadlocks. Always test thoroughly and consider code reviews from experienced parallel programmers.

Race conditions: These occur when multiple threads access shared data simultaneously. Use concurrent collections or proper synchronization to avoid them.

Deadlocks: Threads waiting for each other in a circular dependency. Avoid nested locks and consider using timeout-based waiting.

Over-parallelization: Creating more tasks than cores can hurt performance due to context switching overhead. Use MaxDegreeOfParallelism to control this.

Ignoring exceptions: Exceptions in parallel operations can be lost if not handled properly. Always catch AggregateException.

Advanced Patterns: Pipeline Processing

For complex workflows, consider the pipeline pattern. It divides work into stages that can execute in parallel.

public class DataProcessingPipeline
{
    private readonly BufferBlock _inputBuffer = new();
    private readonly ActionBlock _validationBlock;
    private readonly ActionBlock _processingBlock;
    private readonly ActionBlock _outputBlock;

    public DataProcessingPipeline()
    {
        _validationBlock = new ActionBlock(
            data => ValidateData(data),
            new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 2 });

        _processingBlock = new ActionBlock(
            data => ProcessData(data),
            new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 4 });

        _outputBlock = new ActionBlock(
            data => SaveData(data),
            new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 1 });

        // Link the pipeline
        _inputBuffer.LinkTo(_validationBlock);
        _validationBlock.LinkTo(_processingBlock);
        _processingBlock.LinkTo(_outputBlock);
    }

    public async Task ProcessAsync(RawData data)
    {
        await _inputBuffer.SendAsync(data);
    }

    public async Task CompleteAsync()
    {
        _inputBuffer.Complete();
        await _outputBlock.Completion;
    }
}

This pipeline processes data through stages with different levels of parallelism. Validation uses 2 threads, processing uses 4, and output uses 1 (perhaps for database constraints). The TPL Dataflow library makes this pattern much easier to implement than manual thread coordination.

Summary

Parallel programming in C# with PLINQ and the Task Parallel Library opens up significant performance opportunities for CPU-bound workloads. PLINQ provides a declarative approach to data parallelism, automatically handling partitioning, load balancing, and result merging. The Task Parallel Library offers fine-grained control over task execution and coordination, enabling complex concurrent patterns.

The key insights are: not all code benefits from parallelization-focus on CPU-intensive, independent operations; proper synchronization is crucial but complex-prefer concurrent collections over manual locking; and exception handling requires special attention due to AggregateException. What makes C#'s parallel programming compelling is how accessible it makes high-performance computing without requiring concurrency expertise.