DEV Community

Jairo Blanco
Jairo Blanco

Posted on

Concurrency in .NET: Patterns, Pitfalls, and Practical Solutions

Introduction

Concurrency is one of those topics every .NET developer eventually encounters. It starts innocently enough: a background worker, an asynchronous API call, or a hosted service. Then one day, two requests update the same record simultaneously, a cache becomes corrupted, or a scheduled job executes twice.

At that moment, concurrency stops being a theoretical computer science concept and becomes a production problem.

This article explores concurrency in .NET, common issues developers face, practical solutions, and architectural approaches that help systems remain correct under load.


Understanding Concurrency

Concurrency occurs when multiple operations execute during overlapping periods of time and potentially access shared resources.

Examples include:

  • Multiple HTTP requests updating the same entity
  • Several background workers processing the same message
  • Multiple threads writing to a shared collection
  • Distributed services updating shared state

Concurrency itself is not a bug. In fact, modern applications rely heavily on it for scalability and responsiveness.

Problems arise when concurrent operations modify shared state without proper coordination.


Concurrency vs Parallelism

These terms are often used interchangeably but mean different things.

Concurrency

Multiple tasks make progress during overlapping time periods.

Task A: ------
Task B:   ------
Enter fullscreen mode Exit fullscreen mode

Tasks may not execute simultaneously.

Parallelism

Multiple tasks execute at the exact same time on different CPU cores.

CPU Core 1: Task A
CPU Core 2: Task B
Enter fullscreen mode Exit fullscreen mode

Parallelism is a form of concurrency.

In .NET:

await Task.WhenAll(tasks);
Enter fullscreen mode Exit fullscreen mode

provides concurrency.

Parallel.ForEach(items, item =>
{
    Process(item);
});
Enter fullscreen mode Exit fullscreen mode

provides parallel execution.


The Most Common Concurrency Problems

Race Conditions

A race condition occurs when the result depends on the timing of multiple operations.

Consider:

public class Counter
{
    public int Value;

    public void Increment()
    {
        Value++;
    }
}
Enter fullscreen mode Exit fullscreen mode

If two threads execute Increment() simultaneously:

  1. Thread A reads 10
  2. Thread B reads 10
  3. Thread A writes 11
  4. Thread B writes 11

Expected result:

12
Enter fullscreen mode Exit fullscreen mode

Actual result:

11
Enter fullscreen mode Exit fullscreen mode

The update is lost.


Deadlocks

Deadlocks occur when two or more threads wait forever for each other.

Example:

lock(lockA)
{
    lock(lockB)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

And elsewhere:

lock(lockB)
{
    lock(lockA)
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

A classic deadlock scenario.

Symptoms:

  • Requests hang forever
  • CPU usage remains low
  • No exceptions are thrown

Lost Updates

A very common database problem.

Imagine:

User A reads balance = 100
User B reads balance = 100

User A adds 50
User B adds 20

User A saves 150
User B saves 120
Enter fullscreen mode Exit fullscreen mode

Final balance:

120
Enter fullscreen mode Exit fullscreen mode

Expected:

170
Enter fullscreen mode Exit fullscreen mode

One update overwrote another.


Double Processing

Suppose multiple workers consume messages:

Worker A processes Order #123
Worker B processes Order #123
Enter fullscreen mode Exit fullscreen mode

Now:

  • Two emails sent
  • Two invoices created
  • Customer charged twice

This is a concurrency problem at the distributed system level.


Thread Safety in .NET

A thread-safe component behaves correctly when accessed simultaneously from multiple threads.

Not thread-safe:

List<int>
Dictionary<TKey, TValue>
HashSet<T>
Enter fullscreen mode Exit fullscreen mode

Thread-safe alternatives:

ConcurrentDictionary<TKey, TValue>
ConcurrentQueue<T>
ConcurrentBag<T>
ConcurrentStack<T>
Enter fullscreen mode Exit fullscreen mode

Example:

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

cache.TryAdd(1, "John");
Enter fullscreen mode Exit fullscreen mode

Using lock

The simplest synchronization primitive.

private readonly object _lock = new();

public void Increment()
{
    lock (_lock)
    {
        _count++;
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Easy to understand
  • Reliable
  • Fast for many scenarios

Drawbacks:

  • Blocks threads
  • Doesn't work well with async code

Never do:

await SomeOperation();

lock(_lock)
{
}
Enter fullscreen mode Exit fullscreen mode

Or:

lock(_lock)
{
    await SomeOperation();
}
Enter fullscreen mode Exit fullscreen mode

The latter won't even compile.


Using Interlocked

For simple atomic operations.

Instead of:

_count++;
Enter fullscreen mode Exit fullscreen mode

Use:

Interlocked.Increment(ref _count);
Enter fullscreen mode Exit fullscreen mode

Other operations:

Interlocked.Add()
Interlocked.Exchange()
Interlocked.CompareExchange()
Enter fullscreen mode Exit fullscreen mode

This is usually faster than locking.


Async Concurrency and SemaphoreSlim

For asynchronous code, SemaphoreSlim is often the correct choice.

Example:

private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task UpdateAsync()
{
    await _semaphore.WaitAsync();

    try
    {
        await SaveChangesAsync();
    }
    finally
    {
        _semaphore.Release();
    }
}
Enter fullscreen mode Exit fullscreen mode

This behaves similarly to a lock but supports asynchronous workflows.


Optimistic Concurrency with Entity Framework

Entity Framework provides optimistic concurrency control.

The idea is simple:

Assume conflicts are rare. Detect them when they happen.

Example:

public class Product
{
    public int Id { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

When updating:

await context.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

EF includes the row version in the SQL statement.

If another user modified the row:

DbUpdateConcurrencyException
Enter fullscreen mode Exit fullscreen mode

is thrown.

This is one of the best solutions for CRUD applications.


Handling DbUpdateConcurrencyException

Example:

try
{
    await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    // Reload entity
    // Merge changes
    // Retry
}
Enter fullscreen mode Exit fullscreen mode

Typical strategies:

  1. Client wins
  2. Database wins
  3. Manual merge

Most enterprise systems choose manual merge.


Distributed Concurrency Problems

Things become more interesting when multiple application instances exist.

Imagine:

Pod A
Pod B
Pod C
Enter fullscreen mode Exit fullscreen mode

All processing jobs simultaneously.

A regular C# lock no longer helps.

Locks work only within a process.


Distributed Locks

Solutions include:

  • Redis locks
  • SQL Server application locks
  • ZooKeeper
  • Consul

Redis example using RedLock:

await using var lockHandle =
    await redLockFactory.CreateLockAsync(
        "process-order",
        TimeSpan.FromSeconds(30));

if (lockHandle.IsAcquired)
{
    await ProcessOrder();
}
Enter fullscreen mode Exit fullscreen mode

Only one node processes the operation.


Idempotency: The Secret Weapon

Many concurrency problems disappear when operations are idempotent.

An operation is idempotent if executing it multiple times produces the same result.

Example:

Bad:

Charge card
Enter fullscreen mode Exit fullscreen mode

Good:

Charge payment with transaction id X
Enter fullscreen mode Exit fullscreen mode

Repeated requests become safe.

This principle powers:

  • Payment gateways
  • Event-driven systems
  • Message consumers

The Outbox Pattern

One of the most valuable patterns for distributed systems.

Problem:

Save Order
Publish Event
Enter fullscreen mode Exit fullscreen mode

What if the database succeeds but event publication fails?

Now systems become inconsistent.

Solution:

Save Order
Save Event
Commit Transaction
Enter fullscreen mode Exit fullscreen mode

A background process later publishes events.

This guarantees eventual consistency.


Retry Policies and Concurrency

Many concurrency failures are transient.

Examples:

  • Deadlocks
  • Lock timeouts
  • Temporary network failures

Using Polly:

var policy = Policy
    .Handle<SqlException>()
    .WaitAndRetryAsync(3,
        attempt => TimeSpan.FromSeconds(attempt));

await policy.ExecuteAsync(async () =>
{
    await SaveChangesAsync();
});
Enter fullscreen mode Exit fullscreen mode

Retries dramatically improve resilience.

However:

Only retry idempotent operations.


Common Interview Scenario

Problem

Two users edit the same customer record.

How would you prevent data loss?

Solution

Use optimistic concurrency.

[Timestamp]
public byte[] RowVersion { get; set; }
Enter fullscreen mode Exit fullscreen mode

When saving:

DbUpdateConcurrencyException
Enter fullscreen mode Exit fullscreen mode

indicates another update occurred.

The application can then:

  • Reload
  • Merge
  • Ask the user which version to keep

Common Production Scenario

Problem

A scheduled job runs every minute.

Deployment scales from:

1 instance
Enter fullscreen mode Exit fullscreen mode

to:

5 instances
Enter fullscreen mode Exit fullscreen mode

Now the same job executes five times.

Solution Options

Distributed Lock

Ensure only one instance runs.

Leader Election

One node becomes active.

Queue-Based Architecture

Push work into a queue and let workers consume safely.

The queue-based solution is usually the most scalable.


Common API Scenario

Problem

Customers double-click the "Place Order" button.

Two requests arrive.

Two orders are created.

Solution

Use an idempotency key.

POST /orders

Idempotency-Key:
8b8c85ea-5e4d-4c92-a9e7
Enter fullscreen mode Exit fullscreen mode

Store the key.

If the request is repeated:

Return existing result.
Enter fullscreen mode Exit fullscreen mode

No duplicate orders.


Performance Considerations

Not every concurrency problem should be solved with locking.

Excessive locking can create:

  • Thread contention
  • Reduced throughput
  • Poor scalability

Preferred order:

  1. Immutable data
  2. Thread-safe collections
  3. Atomic operations
  4. Optimistic concurrency
  5. Locks
  6. Distributed locks

The further down the list, the higher the complexity.


Practical Rules of Thumb

When building .NET applications:

  • Prefer async over blocking threads.
  • Avoid shared mutable state.
  • Use ConcurrentDictionary for shared caches.
  • Use Interlocked for counters.
  • Use SemaphoreSlim for async synchronization.
  • Use EF Core optimistic concurrency for CRUD operations.
  • Make APIs idempotent.
  • Use distributed locks sparingly.
  • Design for retries.
  • Assume operations can execute twice.

Conclusion

Concurrency is not merely a multithreading concern—it is a data consistency concern.

As systems scale, concurrency challenges evolve:

Single Thread
    ↓
Multiple Threads
    ↓
Multiple Requests
    ↓
Multiple Processes
    ↓
Multiple Servers
Enter fullscreen mode Exit fullscreen mode

The techniques evolve as well:

lock
    ↓
SemaphoreSlim
    ↓
Optimistic Concurrency
    ↓
Idempotency
    ↓
Distributed Locks
    ↓
Event-Driven Patterns
Enter fullscreen mode Exit fullscreen mode

The most successful .NET systems do not attempt to eliminate concurrency. Instead, they embrace it while ensuring that concurrent operations remain safe, predictable, and recoverable.

Top comments (0)