Prevent Race Conditions in Multi-Threaded C# Code
A race condition occurs in multi-threaded C# applications when two or
more threads access shared memory or resources concurrently, and the
final outcome depends on the non-deterministic timing of thread
execution. This guide explains how race conditions happen under the
hood, demonstrates typical failure scenarios like non-atomic operations,
and provides concrete mechanisms to eliminate them—ranging from language
constructs like the lock statement and the
Interlocked class to concurrent collections and
asynchronous synchronization primitives.
How Race Conditions Occur
Race conditions typically manifest as "read-modify-write" or "check-then-act" operations where an action is assumed to be atomic (indivisible), but is actually composed of multiple low-level CPU instructions.
Consider a simple counter increment:
count++;At the machine level, this single line compiles into three distinct operations:
- Read the current value of
countfrom memory into a CPU register. - Increment the value inside the register.
- Write the updated value back to the memory location.
If Thread A reads count (value: 5), and the operating
system's thread scheduler switches execution to Thread B before Thread A
writes the update, Thread B will also read 5. Both threads
increment the value to 6 and write it back. Despite two
increments occurring, the final value is 6 instead of
7.
Another common race condition is the "check-then-act" scenario:
if (!initialized) // Check
{
InitializeResource(); // Act
initialized = true;
}Two threads can evaluate !initialized as
true simultaneously, causing
InitializeResource() to execute twice, which may lead to
corrupted state, memory leaks, or unhandled exceptions.
How to Prevent Race Conditions in C
Preventing race conditions requires synchronizing access to shared resources so that critical sections can only be executed safely.
1. The lock
Statement (Exclusive Locking)
The lock statement uses a synchronization object to
ensure that only one thread can execute a code block at any given time.
Under the hood, C# translates lock into a
System.Threading.Monitor.Enter and
Monitor.Exit block wrapped in a
try...finally.
public class SafeCounter
{
private readonly object _lockObj = new();
private int _count;
public void Increment()
{
lock (_lockObj)
{
_count++;
}
}
public int GetCount()
{
lock (_lockObj)
{
return _count;
}
}
}Key rules for lock:
- Always lock on a dedicated, private,
readonly objectinstance. - Never lock on
this,typeof(Class), or string literals, as external code can lock on the same references, causing deadlocks. - The
lockstatement cannot be used across asynchronous boundaries (awaitexpressions).
2. The
Interlocked Class (Atomic Operations)
For simple operations like incrementing, decrementing, exchanging, or
adding values, the System.Threading.Interlocked class
provides atomic CPU-level operations. These are significantly faster
than exclusive locks because they do not cause thread context
switches.
using System.Threading;
public class FastCounter
{
private int _count;
public void Increment()
{
Interlocked.Increment(ref _count);
}
public void Add(int value)
{
Interlocked.Add(ref _count, value);
}
public int GetCount()
{
return Volatile.Read(ref _count);
}
}Interlocked.CompareExchange can also be used to
implement lock-free check-then-act algorithms:
// Atomically sets _state to 1 only if it is currently 0
if (Interlocked.CompareExchange(ref _state, 1, 0) == 0)
{
// Execution guaranteed to occur only once
InitializeResource();
}3.
Asynchronous Synchronization with SemaphoreSlim
Because the C# lock statement cannot span an
await call, asynchronous code must use
asynchronous-compatible synchronization primitives.
SemaphoreSlim initialized with a count of
(1, 1) functions as an async mutex.
public class AsyncService
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private int _data;
public async Task ModifyDataAsync()
{
await _semaphore.WaitAsync();
try
{
// Thread-safe async execution
await Task.Delay(100);
_data++;
}
finally
{
_semaphore.Release();
}
}
}4. Built-In Concurrent Collections
Instead of manually synchronizing standard collections like
List<T> or
Dictionary<TKey, TValue>, use the thread-safe
collections in System.Collections.Concurrent. These
collections use optimized locking mechanisms or lock-free techniques
internally.
using System.Collections.Concurrent;
public class Cache
{
private readonly ConcurrentDictionary<string, string> _cache = new();
public string GetOrAddData(string key)
{
// Safe check-and-insert operation
return _cache.GetOrAdd(key, k => FetchDataFromDatabase(k));
}
private string FetchDataFromDatabase(string key) => "Data";
}5. Favor Immutability
Designing shared data structures as immutable objects eliminates race conditions entirely. If an object's state cannot be modified after instantiation, any number of threads can read it concurrently without synchronization.
public record ImmutableConfiguration(string ConnectionString, int TimeoutSeconds);When changes are required, instantiate a new instance rather than modifying the existing one, ensuring that threads reading the previous reference remain unaffected.