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: ------
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
Parallelism is a form of concurrency.
In .NET:
await Task.WhenAll(tasks);
provides concurrency.
Parallel.ForEach(items, item =>
{
Process(item);
});
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++;
}
}
If two threads execute Increment() simultaneously:
- Thread A reads 10
- Thread B reads 10
- Thread A writes 11
- Thread B writes 11
Expected result:
12
Actual result:
11
The update is lost.
Deadlocks
Deadlocks occur when two or more threads wait forever for each other.
Example:
lock(lockA)
{
lock(lockB)
{
}
}
And elsewhere:
lock(lockB)
{
lock(lockA)
{
}
}
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
Final balance:
120
Expected:
170
One update overwrote another.
Double Processing
Suppose multiple workers consume messages:
Worker A processes Order #123
Worker B processes Order #123
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>
Thread-safe alternatives:
ConcurrentDictionary<TKey, TValue>
ConcurrentQueue<T>
ConcurrentBag<T>
ConcurrentStack<T>
Example:
var cache = new ConcurrentDictionary<int, string>();
cache.TryAdd(1, "John");
Using lock
The simplest synchronization primitive.
private readonly object _lock = new();
public void Increment()
{
lock (_lock)
{
_count++;
}
}
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)
{
}
Or:
lock(_lock)
{
await SomeOperation();
}
The latter won't even compile.
Using Interlocked
For simple atomic operations.
Instead of:
_count++;
Use:
Interlocked.Increment(ref _count);
Other operations:
Interlocked.Add()
Interlocked.Exchange()
Interlocked.CompareExchange()
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();
}
}
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; }
}
When updating:
await context.SaveChangesAsync();
EF includes the row version in the SQL statement.
If another user modified the row:
DbUpdateConcurrencyException
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
}
Typical strategies:
- Client wins
- Database wins
- 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
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();
}
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
Good:
Charge payment with transaction id X
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
What if the database succeeds but event publication fails?
Now systems become inconsistent.
Solution:
Save Order
Save Event
Commit Transaction
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();
});
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; }
When saving:
DbUpdateConcurrencyException
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
to:
5 instances
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
Store the key.
If the request is repeated:
Return existing result.
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:
- Immutable data
- Thread-safe collections
- Atomic operations
- Optimistic concurrency
- Locks
- 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
ConcurrentDictionaryfor shared caches. - Use
Interlockedfor counters. - Use
SemaphoreSlimfor 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
The techniques evolve as well:
lock
↓
SemaphoreSlim
↓
Optimistic Concurrency
↓
Idempotency
↓
Distributed Locks
↓
Event-Driven Patterns
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)