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:

  1. Read the current value of count from memory into a CPU register.
  2. Increment the value inside the register.
  3. 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 object instance.
  • Never lock on this, typeof(Class), or string literals, as external code can lock on the same references, causing deadlocks.
  • The lock statement cannot be used across asynchronous boundaries (await expressions).

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.