1. Introduction
In multithreaded applications, having a race condition is not "if" but "when." Even if you think your code is solid and you only have "two small threads" where "everything is obvious and simple," a race condition can hide in the most harmless-looking logic.
What exactly is a race condition and why is it so scary? Imagine two people trying to edit the same paper at the same time — one writes, the other erases. Sometimes it's fine, sometimes it becomes unreadable. In programming the consequences can be even more fun: bugs don't appear always, but only in certain, almost random, conditions.
Race condition — a situation where the outcome of program execution depends on which thread accessed a resource or performed an action first. This issue only arises with concurrent (multithreaded) access when two or more threads touch shared data or resources.
What happens during a race?
Here's a simple diagram. Imagine we have two threads and one shared resource (for example, variable X):
+---------+ +---------+
| Thread 1| | Thread 2|
+----+----+ +----+----+
| |
| Read X |
| <-------------------|
| |
| Increment X |
|-------------------> |
| |
| Write X |
| <-------------------|
If both threads read the value of X, increment it, and write it back at the same time, one will "overwrite" the other's change, and the total number of increments won't match what you expect.
2. Classic Race Condition Example
Let's look at an example. Suppose we want to count button clicks from different threads or the number of processed tasks.
We take a simple variable and a few threads that increment it:
using System;
using System.Threading;
class Program
{
static int counter = 0; // Shared resource
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("Expected value: 200000");
Console.WriteLine("Actual value: " + counter);
}
static void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // << This is where the problem can happen!
}
}
}
What do we expect?
Since each thread increments counter 100000 times, we expect the final value to be 200000.
What do we get in reality?
Sometimes — yes, 200000. But more often the value will be lower — sometimes much lower. Repeat the experiment and the result will vary!
Why?
The operation counter++ is not atomic. It actually happens like this (simplified):
- Read the current value of counter (e.g. 0)
- Add 1 (result 1)
- Write it back (counter = 1)
If two threads read the old value at the same time, both can write back the new value but effectively only one increment will be applied.
Visualization with two threads:
Suppose counter = 0.
- Thread 1: reads 0
- Thread 2: reads 0
- Thread 1: computes 0 + 1 = 1
- Thread 2: computes 0 + 1 = 1
- Thread 1: writes 1
- Thread 2: writes 1 (overwrites Thread 1's increment)
"Congratulations", you just lost one increment! At thousands or millions of operations the result will fluctuate a lot.
3. More examples: not just increment!
Kitchen hustle
To make it more fun, imagine a small cafe. Two cooks are frying omelettes on the same pan but don't coordinate:
- The first puts one omelette, the second immediately puts theirs on top — they mix up;
- One thinks "I already put two omelettes", the other thinks the same, but in the pan there are three while they thought four;
- Chaos starts...
In programming a race condition causes the same kind of "chaos": the result depends on a sequence of fast uncontrolled operations.
When threads interfere: concurrent data access
Suppose you implement a banking app and a client both deposits and withdraws money from the same account using two threads (for example, one — an online transfer, the other — the cashier):
account.Balance += 500; // Thread 1: deposit
account.Balance -= 300; // Thread 2: withdrawal
If these operations are not protected, the final balance can be incorrect: some operations simply get "lost" if threads run concurrently.
4. Useful nuances
Why race condition is a problem?
Hard to catch and reproduce. A bug may show up only on an overloaded machine or in rare conditions.
Hard to debug. When debugging threads may "run differently" and the bug disappears.
Data integrity breach. You get incorrect, corrupted data, sometimes silently.
Security. In critical apps race conditions can lead to leaks, data corruption and even vulnerabilities.
Timing "race" diagram
+-----------------------+ +-----------------------+
| Thread 1 | | Thread 2 |
+-----------------------+ +-----------------------+
| 1. Read counter | | |
| 2. Increment counter | | |
| (but don't write) | | |
| | | 1. Read counter |
| | | 2. Increment counter |
| | | 3. Write counter |
| | | (counter = 1) |
| 3. Write counter | | |
| (counter = 1) | | |
+-----------------------+ +-----------------------+
Both threads did an increment, but only one increment was actually recorded!
Where race conditions often appear
- Any global or static variables accessed by multiple threads.
- Lists, queues, collections that are filled from different threads.
- Events and delegates if subscribe/unsubscribe happens concurrently (e.g., UI + background tasks).
- Caching, dictionaries, connection management.
- Any interaction with files, logs, databases without transactions or locks.
How to avoid race conditions: a short intro
- Synchronization! (more in next lectures).
- Use language and library constructs: lock, Monitor, mutexes, semaphores, etc.
- For simple operations — atomic methods (Interlocked.Increment and others).
- Use thread-safe collections (ConcurrentBag, ConcurrentDictionary).
- Always ask: "what happens if my two functions are called at the same time?"
5. Practical tips
Tips for finding and diagnosing races
- Don't trust even the simplest operations (increment ++, assignment) when using multiple threads.
- Avoid shared mutable state when possible.
- If you see "flaky" bugs that are hard to reproduce — think about races!
- Use thread analysis tools (dotTrace, Concurrency Visualizer, Thread Sanitizer).
- Run load tests — the more threads and operations, the higher the chance to uncover the bug.
What is and isn't safe without synchronization
| Operation | Safe in multithreaded environment? | Explanation |
|---|---|---|
| Assignment int | 🟩 Sometimes* | Only if one thread writes and others read, otherwise — race |
| Increment (++/--) | 🟥 No | Not atomic! Race Condition |
| Reading string | 🟩 Sometimes* | If the string is not modified after creation |
| Assigning an object | 🟩 Sometimes* | Provided there are no concurrent writes |
| Adding to List<T> | 🟥 No | List<T> is not thread-safe |
|
🟩 Yes | Special atomic method |
— "Sometimes" means that if only one thread writes and all others only read, it's safe; if multiple threads can write concurrently — it's always a race.
6. Typical mistakes and traps
In the demo code above we saw counter++ as a problem. Another trap: incrementing or checking a value in a condition.
Example: A funny "first run" bug
if (!alreadyStarted)
{
alreadyStarted = true;
// Do initialization...
}
If several threads execute this condition at the same time, each can see alreadyStarted == false and enter! As a result — initialization runs twice, which can lead to failure.
GO TO FULL VERSION