Race Condition in C#: Definition, Examples, Causes, Prevention, and Best Practices

A race condition in C# occurs when multiple threads access and modify shared data simultaneously, causing unpredictable and incorrect results because the execution order is uncontrolled.
A race condition is one of the most common concurrency problems in C# and multi-threaded programming. It happens when two or more threads attempt to read, modify, or write the same shared resource at the same time without proper synchronization. Since thread execution timing is unpredictable, the final output may change between program executions.
In C#, race conditions frequently occur in applications using Task, Thread, Parallel.For, background workers, or asynchronous operations. Developers often assume that simple operations such as incrementing a variable are safe, but many operations actually involve multiple CPU instructions internally.
Race conditions can lead to data corruption, incorrect calculations, inconsistent application state, and difficult-to-debug production issues. These bugs are especially dangerous because they may not appear consistently during testing and can depend heavily on CPU scheduling and system load.
Why Race Conditions Happen?
Race conditions occur because multiple threads share the same memory or resource while executing independently. The CPU rapidly switches between threads, and developers cannot predict the exact execution sequence.
For example, the statement below may appear simple:
counter++;
Internally, this operation usually performs three separate steps:
• Read the current value.
• Increment the value.
• Write the new value back.
If another thread interrupts the operation between these steps, updates may be lost.
Race Condition Example 1: Shared Counter
Shared Counter Problem
using System;
using System.Threading.Tasks;
class Program
{
static int counter = 0;
static void Main()
{
Parallel.For(0, 1000, i =>
{
counter++;
});
Console.WriteLine(counter);
}
}
The expected result is 1000, but the actual output may be lower because multiple threads overwrite each other’s updates.
Solution of Shared Counter Problem Using lock
using System;
using System.Threading.Tasks;
class Program
{
static int counter = 0;
static readonly object myLock = new object();
static void Main()
{
Parallel.For(0, 1000, i =>
{
lock (myLock)
{
counter++;
}
});
Console.WriteLine(counter);
}
}
The lock statement ensures that only one thread updates the counter at a time.
Race Condition Example 2: Bank Account Balance
Bank Account Balance Problem
class BankAccount
{
public decimal Balance = 1000;
public void Withdraw(decimal amount)
{
if (Balance >= amount)
{
Thread.Sleep(100);
Balance -= amount;
}
}
}
If two threads attempt to withdraw money simultaneously, both may read the same balance before either update occurs. This can allow the account balance to become negative or inconsistent.
Solution of Bank Account Balance Problem Using lock
class BankAccount
{
private readonly object balanceLock = new object();
public decimal Balance = 1000;
public void Withdraw(decimal amount)
{
lock (balanceLock)
{
if (Balance >= amount)
{
Balance -= amount;
}
}
}
}
The locking mechanism guarantees that balance checks and updates happen atomically.
Race Condition Example 3: Shared List Access
Shared List Access Problem
List<int> numbers = new List<int>();
Parallel.For(0, 1000, i =>
{
numbers.Add(i);
});
List<T> is not thread-safe for concurrent writes. Multiple threads modifying the collection simultaneously can corrupt internal memory structures or throw runtime exceptions.
Solution of Shared List Access Problem Using Concurrent Collections
ConcurrentBag<int> numbers = new ConcurrentBag<int>();
Parallel.For(0, 1000, i =>
{
numbers.Add(i);
});
ConcurrentBag<T> is designed for safe multi-threaded operations without manual locking.
Race Condition Example 4: File Writing
File Writing Problem
File.AppendAllText("log.txt", "Thread log entry\n");
If multiple threads write to the same file simultaneously, the file content may become corrupted or incomplete.
Solution of File Write Problem Using Synchronization
private static readonly object fileLock = new object();
lock (fileLock)
{
File.AppendAllText("log.txt", "Thread log entry\n");
}
The lock ensures that only one thread writes to the file at a time.
How to Prevent Race Conditions in C#?
Use lock for Critical Sections
The lock statement is the most common synchronization mechanism in C#. It prevents multiple threads from executing the same critical section simultaneously.
Locks are effective for protecting shared variables, collections, and resources. However, locks should remain small and focused because excessive locking reduces performance.
Use Atomic Operations with Interlocked
The Interlocked class provides lightweight atomic operations for numeric variables and references. It is faster than traditional locking because it avoids thread blocking.
Example
Interlocked.Increment(ref counter);
This operation safely increments the variable without requiring a lock.
Use Thread-Safe Collections
The .NET framework includes concurrent collections designed for multi-threaded scenarios. These collections manage synchronization internally and reduce manual locking complexity.
Examples include:
• ConcurrentDictionary
• ConcurrentQueue
• ConcurrentBag
• BlockingCollection
These collections are safer and more scalable than traditional collections in concurrent environments.
Minimize Shared State
Applications with fewer shared variables naturally experience fewer race conditions. Whenever possible, data should remain local to each thread instead of being shared globally.
Modern architectures often use immutable objects and stateless services to reduce synchronization complexity.
Prefer Async Programming Carefully
Asynchronous programming can reduce thread contention, but developers must still protect shared resources. Async code does not automatically eliminate race conditions.
Shared variables inside async methods still require synchronization when accessed concurrently.
Best Use Cases for Race Condition Prevention Techniques
Financial Applications
Banking systems process transactions from multiple users simultaneously. Race condition prevention is essential because even a small inconsistency can lead to incorrect balances or duplicated transactions.
Synchronization mechanisms ensure transaction integrity and maintain accurate financial records.
High-Traffic Web APIs
Web APIs often handle thousands of concurrent requests accessing shared caches, counters, or session data. Without proper synchronization, applications may return inconsistent results under heavy traffic.
Thread-safe collections and atomic operations improve scalability while protecting shared resources.
Real-Time Gaming Systems
Multiplayer games continuously update player positions, scores, and shared game states. Race conditions can create inconsistent gameplay experiences or synchronization errors between players.
Careful concurrency management ensures game state consistency across all connected clients.
Logging and Monitoring Systems
Logging frameworks often receive simultaneous write requests from many threads. Without synchronization, log files can become corrupted or incomplete.
Thread-safe queues and asynchronous logging mechanisms help maintain reliable logging performance.
Most Common Race Condition Issues in C#
Assuming Simple Operations Are Safe
Many developers incorrectly believe statements like counter++ are atomic. In reality, these operations involve multiple internal CPU steps and are vulnerable to interruption.
This misunderstanding is one of the most common causes of race conditions in beginner and intermediate C# applications.
Using Non-Thread-Safe Collections
Standard collections such as List<T> and Dictionary<TKey, TValue> are not designed for concurrent modifications. Developers often use them in parallel code without realizing the risk.
Concurrent collections should be preferred whenever multiple threads access shared data structures.
Locking the Wrong Object
Improper lock usage can make synchronization ineffective. For example, locking temporary objects or publicly accessible objects may not actually protect the shared resource correctly.
Locks should use dedicated private objects specifically intended for synchronization.
Overusing Shared Variables
Applications with excessive shared state become difficult to synchronize correctly. The more shared variables exist, the higher the probability of concurrency issues.
Reducing global state simplifies concurrency management significantly.
Ignoring Concurrency Testing
Race conditions are often timing-dependent and may only appear under heavy load or specific hardware conditions. Applications that are not tested with parallel execution may contain hidden concurrency bugs.
Stress testing and load testing are important for detecting these issues before production deployment.
Example of a Safe Counter Using Interlocked
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static int counter = 0;
static async Task Main()
{
Task[] tasks = new Task[100];
for (int i = 0; i < 100; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
Interlocked.Increment(ref counter);
}
});
}
await Task.WhenAll(tasks);
Console.WriteLine(counter);
}
}
This example safely increments a shared variable without race conditions.