1. Introduction
In a multithreaded application a shared resource is anything that two or more threads can access at the same time. This can be:
- A variable (for example, a global counter or a list).
- An object (for example, a collection of users).
- A file or a network socket.
- Any data structure modified by different threads.
In our console apps we’ll most often run into variables and objects that are "shared" between threads.
Analogy
Imagine two people trying to write something into the same notebook at the same time without agreeing on turns. In the best case you get a messy entry, in the worst case someone overwrites someone else's data. In programming it's exactly the same, only these “people” are threads.
Briefly about typical resources with data races
In the table below are the most common resources that are dangerous for concurrent access from different threads:
| Resource | Problem groups | Example |
|---|---|---|
| Variables of type int | Incorrect increment/decrement | Counters, indexes |
| Shared collections | Loss/corruption of items, exceptions | Shared orders list |
| Objects | Inconsistent state changes | Flags, properties |
| Files | Data corruption, incorrect read/write | Log files, configuration |
2. Race condition: how does it show up?
Example: Visit counter
Say we want to count how many times a user clicked a button (or, in our example, how many times different threads incremented a variable). Simple version of the code:
int counter = 0;
void Increment() {
counter++;
}
Now we create two threads, each calling Increment() 100,000 times:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Expected: 200000, got: {counter}");
}
}
How many times should counter logically be incremented? 200000! But if you run this code multiple times you'll almost certainly see different numbers: 185000, 192500, 198765… Why?
3. Why counter++ is not an atomic operation?
How counter++ actually works
In C# and other high-level languages the program is translated into a set of machine instructions. Unfortunately the operator counter++ does not turn into one magical "add 1 to variable" command. Here's what actually happens:
- The thread READS the value from memory (counter).
- Increases that value by 1 (in a CPU register).
- Writes the new value back to memory (counter).
If two threads do this at almost the same time they can both read the same old value, increment it, and both write the result back, losing one increment.
Race scenario
Say counter was 1000. Both threads read that value (step 1), both increment locally to 1001 (step 2), and then both write back 1001 (step 3). Horrible: one increment is simply lost!
Race visualization
| Time | Thread 1 | Thread 2 | Value of counter |
|---|---|---|---|
| 1 | Read 1000 | 1000 | |
| 2 | Read 1000 | 1000 | |
| 3 | Increment to 1001 | Increment to 1001 | 1000 (not written yet) |
| 4 | Write 1001 | 1001 | |
| 5 | Write 1001 | 1001 |
As a result, two increments only increased the value by 1!
4. A few more examples: "invisible bugs"
What if the race condition doesn't involve numbers?
Now imagine several threads adding items to the same list:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Expected: 20000, got: {numbers.Count}");
}
}
This code can also give different results on each run: sometimes the program will crash (exception), sometimes you'll see fewer items than expected.
Why? Because the collection List<T> is not thread-safe out of the box. So when two threads call Add at the same time the internal structure of the list can get corrupted.
5. Atomicity of operations
What is an atomic operation?
An operation is atomic if it executes entirely without being interruptible by another thread in the middle. It's like a "transaction": either all of it happens or none of it.
- Assignment operations for int like myVar = 42; are atomic on most platforms (unless it's a huge object).
- But counter++ is not atomic — it's three separate actions.
Special atomic operations
In .NET there are special classes for atomic operations: for example, Interlocked. We'll look at this approach in upcoming lectures.
Example of an atomic increment using Interlocked.Increment:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // atomic operation!
6. Why catching a race condition is hard?
Race conditions are dangerous because:
- They may appear only under high load.
- They are not caught 100% of the time — maybe 5% or even 0.01% of runs.
- They fail "randomly" and happen where nobody expects them.
How to recognize the problem?
If each run of your program gives different (and incorrect) results, you should suspect a data race.
Programmer jokes
"If a bug appears rarely and is fixed by adding Thread.Sleep(50) — you have bigger problems than it seems."
7. Useful nuances
Synchronization
To protect critical sections (parts of code that work with shared resources) you need to synchronize them. But that's a topic for later lectures. Right now the main thing is to learn to notice and explain the problem.
Typical mistakes by beginners
Many beginners think: “I have counter++ — what could go wrong?” Unfortunately, once you have more than one thread, everything can go wrong! Even seemingly simple things: reading and writing variables, adding items to a list, changing object state and much more.
The place of data races in real development
In modern multithreaded applications (for example, in server APIs, web request handling, games and mobile apps) there are almost always shared resources. Without synchronization data races lead to incorrect order processing, crashes, memory leaks and huge debugging headaches.
In interviews for middle/senior positions you'll definitely be asked: "What is a race condition? How to avoid it?" If you can give the examples above — and explain the mechanics — recruiters will be happy!
GO TO FULL VERSION