C# Parallel and Concurrent Programming

📖 8 min read

Async and parallel solve different problems. Async frees threads during I/O waits, so use it for HTTP calls and database queries. Parallel uses multiple threads for CPU-bound work, so use it for image processing and complex calculations. Confusing them leads to either wasted resources or blocked threads.

Async vs Parallel

Understanding when to use each approach is crucial.

Scenario Use Reason
HTTP calls, DB queries async/await I/O-bound, threads wait
Image processing Parallel CPU-bound, needs compute
File compression Parallel CPU-bound
Reading many files async/await I/O-bound
Complex calculations Parallel CPU-bound
// I/O-bound - use async
public async Task<IEnumerable<string>> DownloadAllAsync(IEnumerable<string> urls)
{
    var tasks = urls.Select(url => httpClient.GetStringAsync(url));
    return await Task.WhenAll(tasks);
}

// CPU-bound - use parallel
public IEnumerable<ProcessedImage> ProcessImages(IEnumerable<Image> images)
{
    return images.AsParallel()
        .Select(img => ProcessImage(img))
        .ToList();
}

Parallel Class

Execute loops in parallel using multiple threads.

Parallel.For

// Process indices in parallel
Parallel.For(0, 100, i =>
{
    ProcessItem(i);
});

// With parallelism control
Parallel.For(0, 100,
    new ParallelOptions { MaxDegreeOfParallelism = 4 },
    i => ProcessItem(i));

// With local state for thread-safe accumulation
long total = 0;
Parallel.For(0, 1000,
    () => 0L, // Initialize local state per thread
    (i, state, localSum) => localSum + ComputeValue(i), // Process
    localSum => Interlocked.Add(ref total, localSum)); // Combine

Parallel.ForEach

var items = GetItems();

Parallel.ForEach(items, item =>
{
    ProcessItem(item);
});

// With options
var options = new ParallelOptions
{
    MaxDegreeOfParallelism = Environment.ProcessorCount,
    CancellationToken = cancellationToken
};

Parallel.ForEach(items, options, item =>
{
    options.CancellationToken.ThrowIfCancellationRequested();
    ProcessItem(item);
});

// Partitioned for better performance with large collections
var partitioner = Partitioner.Create(items, EnumerablePartitionerOptions.NoBuffering);
Parallel.ForEach(partitioner, item => ProcessItem(item));

Breaking and Stopping

Parallel.For(0, 1000, (i, state) =>
{
    if (FoundTarget(i))
    {
        // Stop - don't start new iterations, but finish running ones
        state.Stop();
        return;
    }

    // Break - stop at this index, complete lower indices
    if (ShouldBreak(i))
    {
        state.Break();
    }

    ProcessItem(i);
});

PLINQ (Parallel LINQ)

Parallelize LINQ queries for CPU-bound operations.

Basic PLINQ

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

// Sequential
var sumSeq = numbers
    .Where(n => n % 2 == 0)
    .Select(n => n * n)
    .Sum();

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

// When order matters
var ordered = numbers
    .AsParallel()
    .AsOrdered() // Maintain source order
    .Where(n => IsPrime(n))
    .Take(100)
    .ToList();

PLINQ Options

var results = source
    .AsParallel()
    .WithDegreeOfParallelism(4)     // Limit threads
    .WithExecutionMode(ParallelExecutionMode.ForceParallelism)  // Always parallel
    .WithMergeOptions(ParallelMergeOptions.NotBuffered)  // Stream results
    .WithCancellation(cancellationToken)
    .Select(item => Process(item))
    .ToList();

When PLINQ Helps

// GOOD for PLINQ - expensive per-item operation
var processed = images
    .AsParallel()
    .Select(img => ResizeAndCompress(img))  // CPU-intensive
    .ToList();

// BAD for PLINQ - overhead exceeds benefit
var doubled = numbers
    .AsParallel()
    .Select(n => n * 2)  // Too simple
    .ToList();

// BAD - I/O bound (use async instead)
var contents = files
    .AsParallel()
    .Select(f => File.ReadAllText(f))  // I/O, not CPU
    .ToList();

Concurrent Collections

Thread-safe collections for concurrent access.

ConcurrentDictionary

var cache = new ConcurrentDictionary<string, Data>();

// Add or get
var value = cache.GetOrAdd("key", key => LoadData(key));

// Add or update
var updated = cache.AddOrUpdate(
    "key",
    key => CreateNew(key),           // Add factory
    (key, old) => UpdateExisting(old) // Update factory
);

// Thread-safe read
if (cache.TryGetValue("key", out var data))
{
    Process(data);
}

// Thread-safe remove
if (cache.TryRemove("key", out var removed))
{
    Cleanup(removed);
}

// Atomic update pattern
cache.AddOrUpdate("counter",
    _ => 1,
    (_, current) => current + 1);

ConcurrentQueue and ConcurrentStack

var queue = new ConcurrentQueue<WorkItem>();

// Producer
queue.Enqueue(new WorkItem());

// Consumer
if (queue.TryDequeue(out var item))
{
    Process(item);
}

// ConcurrentStack - LIFO
var stack = new ConcurrentStack<int>();
stack.Push(1);
stack.PushRange(new[] { 2, 3, 4 });

if (stack.TryPop(out var value)) { }
if (stack.TryPeek(out var top)) { }

ConcurrentBag

Unordered collection optimized for scenarios where the same thread produces and consumes.

var bag = new ConcurrentBag<Result>();

Parallel.ForEach(items, item =>
{
    var result = Process(item);
    bag.Add(result);  // Thread-local storage optimization
});

var allResults = bag.ToArray();

BlockingCollection

Producer-consumer pattern with blocking operations.

using var collection = new BlockingCollection<WorkItem>(boundedCapacity: 100);

// Producer
Task.Run(() =>
{
    foreach (var item in GetItems())
    {
        collection.Add(item);  // Blocks if full
    }
    collection.CompleteAdding();
});

// Consumer
Task.Run(() =>
{
    foreach (var item in collection.GetConsumingEnumerable())
    {
        Process(item);  // Blocks if empty
    }
});

// With timeout
if (collection.TryAdd(item, TimeSpan.FromSeconds(5)))
{
    // Added successfully
}

Thread Synchronization

Choosing a Synchronization Primitive

Different primitives solve different problems. Choosing the wrong one leads to either poor performance or subtle bugs.

Primitive Use When Trade-offs
lock Simple mutual exclusion Easy to use but blocks threads
SemaphoreSlim Limiting concurrent access (e.g., max 5 connections) More flexible than lock
ReaderWriterLockSlim Many reads, few writes Complexity for read-heavy scenarios
Interlocked Simple numeric operations Fastest, but limited to specific ops
Concurrent collections Shared data structures Thread-safe by design

Use lock when:

  • You need simple mutual exclusion
  • The protected code is fast (no I/O, no async)
  • Only one thread should execute the critical section at a time

Use SemaphoreSlim when:

  • You need to limit concurrency (e.g., max 10 parallel HTTP calls)
  • You need async-compatible synchronization
  • You need to coordinate across async methods

Use ReaderWriterLockSlim when:

  • Reads vastly outnumber writes
  • Read operations don’t modify shared state
  • You can tolerate the added complexity

Use Interlocked when:

  • You only need simple atomic operations (increment, compare-exchange)
  • Maximum performance is critical
  • You’re implementing lock-free algorithms

Prefer concurrent collections over manually synchronizing regular collections.

lock Statement

private readonly object lockObj = new();
private int counter;

public void Increment()
{
    lock (lockObj)
    {
        counter++;
    }
}

// Don't lock on 'this' or Type objects
// BAD: lock (this)
// BAD: lock (typeof(MyClass))

SemaphoreSlim

Limit concurrent access to a resource.

private readonly SemaphoreSlim semaphore = new(maxCount: 3);

public async Task ProcessAsync(Item item)
{
    await semaphore.WaitAsync();
    try
    {
        await DoWorkAsync(item);
    }
    finally
    {
        semaphore.Release();
    }
}

// Process with limited concurrency
public async Task ProcessAllAsync(IEnumerable<Item> items)
{
    var tasks = items.Select(async item =>
    {
        await semaphore.WaitAsync();
        try
        {
            return await ProcessAsync(item);
        }
        finally
        {
            semaphore.Release();
        }
    });

    await Task.WhenAll(tasks);
}

ReaderWriterLockSlim

Multiple readers or single writer.

private readonly ReaderWriterLockSlim rwLock = new();
private Dictionary<string, string> data = new();

public string Read(string key)
{
    rwLock.EnterReadLock();
    try
    {
        return data.TryGetValue(key, out var value) ? value : null;
    }
    finally
    {
        rwLock.ExitReadLock();
    }
}

public void Write(string key, string value)
{
    rwLock.EnterWriteLock();
    try
    {
        data[key] = value;
    }
    finally
    {
        rwLock.ExitWriteLock();
    }
}

Interlocked Operations

Atomic operations without locks.

private long counter;

public void Increment() => Interlocked.Increment(ref counter);
public void Decrement() => Interlocked.Decrement(ref counter);
public void Add(long value) => Interlocked.Add(ref counter, value);
public long Read() => Interlocked.Read(ref counter);

// Compare and swap
private int state;

public bool TryTransition(int from, int to)
{
    return Interlocked.CompareExchange(ref state, to, from) == from;
}

// Exchange
public int GetAndReset()
{
    return Interlocked.Exchange(ref counter, 0);
}

Task Parallel Library Patterns

Task.Run for CPU-Bound Work

// Offload CPU-bound work from UI thread
private async void Calculate_Click(object sender, EventArgs e)
{
    var result = await Task.Run(() =>
    {
        return ExpensiveCalculation();
    });

    DisplayResult(result);
}

// Don't wrap async methods in Task.Run
// BAD:
await Task.Run(async () => await httpClient.GetStringAsync(url));

// GOOD:
await httpClient.GetStringAsync(url);

Continuation Tasks

var task = GetDataAsync();

// Continue when task completes
var continuation = task.ContinueWith(t =>
{
    if (t.IsCompletedSuccessfully)
        Process(t.Result);
    else if (t.IsFaulted)
        HandleError(t.Exception);
});

// Prefer async/await over ContinueWith
var data = await GetDataAsync();
Process(data);

TaskCompletionSource

Bridge between callback-based APIs and Task-based APIs.

public Task<string> DownloadAsync(string url)
{
    var tcs = new TaskCompletionSource<string>();

    var client = new WebClient();
    client.DownloadStringCompleted += (s, e) =>
    {
        if (e.Cancelled)
            tcs.SetCanceled();
        else if (e.Error != null)
            tcs.SetException(e.Error);
        else
            tcs.SetResult(e.Result);
    };

    client.DownloadStringAsync(new Uri(url));

    return tcs.Task;
}

Channels (Modern Producer-Consumer)

using System.Threading.Channels;

// Bounded channel - blocks when full
var bounded = Channel.CreateBounded<Message>(new BoundedChannelOptions(100)
{
    FullMode = BoundedChannelFullMode.Wait,
    SingleReader = false,
    SingleWriter = false
});

// Unbounded channel - never blocks writes
var unbounded = Channel.CreateUnbounded<Message>();

// Producer
public async Task ProduceAsync(ChannelWriter<Message> writer, CancellationToken ct)
{
    try
    {
        while (!ct.IsCancellationRequested)
        {
            var message = await GetNextMessageAsync(ct);
            await writer.WriteAsync(message, ct);
        }
    }
    finally
    {
        writer.Complete();
    }
}

// Consumer
public async Task ConsumeAsync(ChannelReader<Message> reader, CancellationToken ct)
{
    await foreach (var message in reader.ReadAllAsync(ct))
    {
        await ProcessMessageAsync(message);
    }
}

// Usage
var channel = Channel.CreateBounded<Message>(100);
var producerTask = ProduceAsync(channel.Writer, cts.Token);
var consumerTask = ConsumeAsync(channel.Reader, cts.Token);
await Task.WhenAll(producerTask, consumerTask);

Thread-Safe Patterns

Lazy Thread-Safe Initialization

// Lazy<T> with thread safety
private readonly Lazy<ExpensiveObject> lazy =
    new Lazy<ExpensiveObject>(() => new ExpensiveObject());

public ExpensiveObject Instance => lazy.Value;

// Double-check locking (manual pattern)
private volatile ExpensiveObject? instance;
private readonly object lockObj = new();

public ExpensiveObject Instance
{
    get
    {
        if (instance == null)
        {
            lock (lockObj)
            {
                instance ??= new ExpensiveObject();
            }
        }
        return instance;
    }
}

Immutable State Updates

// Thread-safe state updates using immutable types
private ImmutableList<Item> items = ImmutableList<Item>.Empty;
private readonly object lockObj = new();

public void AddItem(Item item)
{
    lock (lockObj)
    {
        items = items.Add(item);
    }
}

// Or using Interlocked with compare-exchange
private ImmutableList<Item> items = ImmutableList<Item>.Empty;

public void AddItemLockFree(Item item)
{
    ImmutableList<Item> initial, updated;
    do
    {
        initial = items;
        updated = initial.Add(item);
    } while (Interlocked.CompareExchange(ref items, updated, initial) != initial);
}

Common Pitfalls

Race Conditions

// BAD - race condition
private int counter;
public void Increment()
{
    counter++;  // Not atomic: read-modify-write
}

// GOOD - atomic increment
public void IncrementSafe()
{
    Interlocked.Increment(ref counter);
}

Deadlocks

// DEADLOCK potential - acquiring locks in different order
public void Method1()
{
    lock (lockA)
    {
        lock (lockB) { }  // Waits for B
    }
}

public void Method2()
{
    lock (lockB)
    {
        lock (lockA) { }  // Waits for A
    }
}

// SOLUTION - always acquire locks in same order

Closure Capture in Loops

// BAD - all tasks capture same variable
for (int i = 0; i < 10; i++)
{
    Task.Run(() => Console.WriteLine(i));  // Might print 10 ten times
}

// GOOD - capture copy
for (int i = 0; i < 10; i++)
{
    int captured = i;
    Task.Run(() => Console.WriteLine(captured));
}

// Or use foreach (captures correctly since C# 5)
foreach (var item in items)
{
    Task.Run(() => Process(item));  // OK
}

Version History

Feature Version Significance
Parallel class .NET 4.0 Parallel loops
PLINQ .NET 4.0 Parallel LINQ
Concurrent collections .NET 4.0 Thread-safe collections
Task Parallel Library .NET 4.0 Task-based parallelism
SemaphoreSlim .NET 4.0 Lightweight semaphore
Channels .NET Core 2.1 Modern producer-consumer
IAsyncEnumerable .NET Core 3.0 Async streams

Key Takeaways

Use async for I/O, parallel for CPU: Async frees threads during I/O waits. Parallel uses multiple threads for CPU work.

Limit parallelism: Don’t spawn unlimited parallel tasks. Use MaxDegreeOfParallelism or semaphores.

Prefer concurrent collections: ConcurrentDictionary and friends are optimized for concurrent access.

Lock minimally: Hold locks for the shortest time possible. Consider lock-free alternatives.

Use channels for producer-consumer: Channels provide efficient, modern producer-consumer patterns.

Test concurrent code carefully: Race conditions are timing-dependent. Use stress testing and tools like thread sanitizers.

Found this guide helpful? Share it with your team:

Share on LinkedIn