Race Conditions
A race condition occurs when multiple threads access shared data concurrently, and the outcome depends on the timing of their execution. Understanding race conditions is essential for writing correct concurrent code.
What Is a Race Condition?
csharp1int counter = 0;23// Two threads incrementing the same counter4void Increment()5{6 for (int i = 0; i < 100000; i++)7 {8 counter++; // NOT thread-safe!9 }10}1112Thread t1 = new Thread(Increment);13Thread t2 = new Thread(Increment);14t1.Start(); t2.Start();15t1.Join(); t2.Join();1617Console.WriteLine(counter); // Expected: 200000, Actual: varies (less)
Why Does This Happen?
The counter++ operation is NOT atomic. It actually involves three steps:
11. READ: Load counter value from memory22. MODIFY: Add 1 to the value33. WRITE: Store result back to memory
When threads interleave these steps:
1Thread 1 Thread 22-------- --------3READ counter (= 5)4 READ counter (= 5)5ADD 1 (= 6)6 ADD 1 (= 6)7WRITE counter (= 6)8 WRITE counter (= 6)910Result: counter = 6, but we expected 7!
Types of Race Conditions
Check-Then-Act
csharp1// RACE: Another thread could modify between check and act2if (!dictionary.ContainsKey(key))3{4 dictionary[key] = value; // Another thread might have added it!5}
Read-Modify-Write
csharp1// RACE: Value could change between read and write2balance = balance + deposit; // Not atomic
Compound Operations
csharp1// RACE: List could change between Count check and Add2if (list.Count < maxSize)3{4 list.Add(item); // Another thread might have filled it!5}
Demonstrating a Race Condition
csharp1class BankAccount2{3 public decimal Balance { get; private set; } = 1000;45 public void Withdraw(decimal amount)6 {7 if (Balance >= amount) // Check8 {9 Thread.Sleep(1); // Simulate processing (makes race more likely)10 Balance -= amount; // Act11 }12 }13}1415// Two threads trying to withdraw the full balance16var account = new BankAccount();17var t1 = new Thread(() => account.Withdraw(1000));18var t2 = new Thread(() => account.Withdraw(1000));1920t1.Start(); t2.Start();21t1.Join(); t2.Join();2223Console.WriteLine(account.Balance); // Could be -1000! Both threads passed the check.
Symptoms of Race Conditions
Race conditions are notorious for being:
- Intermittent - May only occur under specific timing
- Hard to reproduce - May work 99% of the time
- Heisenbugs - May disappear when you add debugging code
- Environment-dependent - May only occur on production servers
Common Race Condition Scenarios
Lazy Initialization
csharp1// RACE: Multiple threads could create instances2private static ExpensiveObject? _instance;34public static ExpensiveObject Instance5{6 get7 {8 if (_instance == null) // Both threads see null9 {10 _instance = new ExpensiveObject(); // Both create!11 }12 return _instance;13 }14}
Caching
csharp1// RACE: Cache might be populated multiple times2if (!cache.ContainsKey(key))3{4 var value = ExpensiveComputation(key);5 cache[key] = value; // Another thread might have already cached it6}
Event Handlers
csharp1// RACE: Handler could become null between check and invoke2if (OnCompleted != null)3{4 OnCompleted(this, EventArgs.Empty); // OnCompleted could be null now!5}67// Safe pattern:8var handler = OnCompleted;9handler?.Invoke(this, EventArgs.Empty);
Detecting Race Conditions
Thread Sanitizers
Use tools like:
- Visual Studio's Concurrency Visualizer
- dotnet-trace
- Race condition analyzers
Stress Testing
csharp1// Run many threads to increase chance of race2var tasks = Enumerable.Range(0, 100)3 .Select(_ => Task.Run(() => SuspiciousOperation()))4 .ToArray();56await Task.WhenAll(tasks);
Assertions
csharp1// Add assertions that fail if race occurs2Debug.Assert(counter >= 0, "Race condition detected!");
Solutions Preview
Race conditions are solved through synchronization:
| Technique | Use Case |
|---|---|
lock | General mutual exclusion |
SemaphoreSlim | Limiting concurrent access |
Interlocked | Atomic operations on primitives |
| Concurrent collections | Thread-safe data structures |
| Immutable data | No shared mutable state |
These are covered in the following lessons.
Key Takeaways
- Race conditions occur when threads access shared data concurrently
- They cause unpredictable, hard-to-reproduce bugs
- Common patterns: check-then-act, read-modify-write
- The fix is synchronization (locks, semaphores, etc.)
- Concurrent collections provide thread-safe alternatives
- Best practice: minimize shared mutable state