1. More complex examples of Race Condition
A Race Condition is one of the sneakiest types of bugs. Why? Because they usually happen rarely, depend on CPU speed, OS scheduling, random thread switches, and reproduce at the worst possible time. No static analyzer catches them. Unit tests won't catch them (unless you're a mind reader), but once... they show up in production.
To avoid such unpleasant surprises, you need a good understanding of how Race Conditions arise and what to do about them.
Example 1. A bank without banking ethics
Suppose we have a very simple bank account class:
public class Account
{
public int Balance = 0;
public void Deposit(int amount)
{
Balance += amount;
}
public void Withdraw(int amount)
{
Balance -= amount;
}
}
Imagine two threads try to deposit 100 and 200 euros at the same time, and then each withdraws 50 euros. In a single-threaded world the balance would always be: (0 + 100 + 200 - 50 - 50) = 200.
What happens in multithreaded reality? Let's run an experiment:
var acc = new Account();
var t1 = new Thread(() => {
acc.Deposit(100);
acc.Withdraw(50);
});
var t2 = new Thread(() => {
acc.Deposit(200);
acc.Withdraw(50);
});
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine(acc.Balance); // And here each run can give a different result!
Explanation:
If both threads read Balance as 0, then one writes 100, the other — 200, but scheduling switches happen between those actions — the balance can "lose" or "gain" money. A bank without locks is a fraudster's dream!
Example 2. A flag variable
A very common mistake is using a flag variable as a state indicator:
bool isReady = false;
void Worker()
{
while (!isReady)
{
// waiting...
}
// do something
}
Another thread may write isReady = true. At first glance it looks safe: what could go wrong? It turns out that even reading and writing a boolean can be unsafe! Reason: compiler optimizations, CPU caches, instruction reordering on multiprocessor systems.
What can happen?
One thread may never notice the variable change and continue spinning in the loop, even though another thread set isReady = true long ago. To transmit "flags" between threads always use proper primitives (volatile, Interlocked, events, etc. — see official docs).
Example 3. Null-check and object creation
Consider a singleton class:
public class Singleton
{
private static Singleton _instance;
public static Singleton Instance
{
get
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
}
}
The code looks trivial? But if many threads call Instance at the same time, multiple instances may be created! This is a classic double-check locking issue without synchronization: the field _instance can be subject to a race unless protected by lock.
2. How a Race Condition appears in code
The "Read-Modify-Write" operation — anatomy of evil
Let's take our increment again: counter++
At the CPU level, it's:
- Read from memory: register = counter;
- Increment: register = register + 1;
- Write back: counter = register;
What happens if two threads execute this block almost simultaneously?
- Both threads read counter = 0.
- Both increment their register to 1.
- Both write back 1.
Result: after two increments the value increased by only 1! One increment got "eaten" — and nobody noticed.
Visualization of two threads "colliding"
Thread A | Thread B | counter-------------------------------------------
Reads (0) | | 0
| Reads (0) | 0
Increment (1) | | 0
| Increment (1) | 0
Writes (1) | | 1
| Writes (1) | 1 <-- Oops! Expected 2, got 1
Why are Race Conditions so hard to catch?
- Race Conditions "mask themselves". Add a couple of Console.WriteLine calls — and the bug may disappear. Just because threads ran a different path.
- The bug depends on number of cores, their load, OS and .NET version. Yesterday it worked, today it broke.
- The result is non-deterministic: the bug appears not always, only in "special cases".
- Debugging this is a real adventure.
3. Race Condition in collections and .NET classes
Race Conditions happen not only with variables. Collections, queues and even standard .NET classes are not always thread-safe.
Example: List<T> is not thread-safe
If two threads call .Add() on a regular List<T> at the same time, consequences can be catastrophic:
var list = new List<int>();
var tasks = new List<Task>();
for (int t = 0; t < 10; t++)
{
int threadNum = t;
tasks.Add(Task.Run(() => {
for (int i = 0; i < 1000; i++)
list.Add(threadNum * 1000 + i);
}));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // Very likely to be < 10000
Here the Race Condition happens inside Add(), because the collection may resize its internal array, copy elements, while another thread is adding... The result — lost data, exceptions or corrupted collection.
Example: Multiple threads use the same indexer
var array = new int[10];
void Worker(int index, int value)
{
array[index] = value;
}
Parallel.Invoke(
() => Worker(5, 1),
() => Worker(5, 2)
);
As a result array[5] may end up as either 1 or 2 — depending on which thread wrote last. Sometimes this is desired, but more often it's a source of subtle and hard-to-find bugs.
4. Useful nuances
Race Condition and non-atomic operations
Be especially careful if an operation seems "small" but isn't atomic.
- i++, i--
- a = b
- myObject.Property = value
- "Checked a flag — then changed a value"
- "Got an object — changed its internal state"
All of the above are not atomic by themselves! Other threads can "intercept" between the micro-steps.
Myths and pitfalls about Race Condition
Myth 1: "If a variable is an int, it's safe, it's a primitive."
Reality: Even operations on int don't guarantee atomicity unless marked volatile or used through a special API.
Myth 2: "If I write to a collection and another thread only reads — it's okay."
Reality: No! In .NET collections don't guarantee integrity under concurrent read/write; you can get corrupted objects or weird exceptions.
Myth 3: "Race Conditions only show up in super loaded services."
Reality: Even small utilities, games, desktop apps can hit race conditions. They just occur less often or not immediately.
How to protect against Race Condition?
- Use synchronization primitives (lock, Mutex, Monitor, etc.).
- For basic increment/decrement numeric operations — use Interlocked (docs).
- For collections — use thread-safe classes (ConcurrentBag, ConcurrentDictionary, etc. — see documentation).
- Don't use flag variables and states without protection (volatile, events, synchronization primitives).
When does a Race Condition arise
| Scenario | Can there be a Race Condition? | How to protect |
|---|---|---|
| Multiple threads read data | No (if there's no modification) | - |
| One thread writes, another reads | Yes | |
| Multiple threads write | Yes | |
| Multiple threads modify a collection | Yes | Thread-safe collections |
| Multiple threads use a flag | Yes | volatile, lock, events |
Atomicity is not a panacea
Sometimes even atomic operations don't save you from logical "races".
Example:
if (!cache.ContainsKey(key))
{
cache[key] = GetData(key);
}
If two threads enter this if at the same time, both will see the key missing and both will create the value — the second will overwrite the first! Atomic ops on int or Interlocked won't help here — you need full locking or a specialized function (GetOrAdd on ConcurrentDictionary).
4. Typical student mistakes when dealing with Race Condition
Mistake #1: thinking lock is needed only for "something big".
In reality even a simple variable can break without protection.
Mistake #2: locking the wrong object.
For example, locking a string or an object that's accessible outside the class. As a result the synchronization doesn't work as intended.
Mistake #3: relying on "almost always correct" results.
If a bug is rare — it doesn't mean you can skip protecting resources.
Mistake #4: using async methods without synchronization.
Hoping "it'll be fine" and getting chaotic bugs in the most unexpected places.
Mistake #5: writing a Singleton with double-check but without lock.
Instead of a single instance you get multiple copies in memory, and debugging becomes a nightmare.
GO TO FULL VERSION