Race Conditions
Two tasks each adding one to the same counter can add one in total; here is why a read-modify-write loses writes.
counter = counter + 1; // read, modify, write: three steps, not oneA thread is one line of execution inside a running program. A program can have several threads running at the same moment, on different processor cores. A race condition is a bug where two threads use the same piece of data and the answer depends on which thread gets there first.
Why one plus one can be one
Adding one to a number looks like a single step. It is three. The processor loads the current value, adds one to it, then stores the result back. That is a read-modify-write. An operation that cannot be interrupted part way through is called atomic. counter += 1 is not atomic.
Now put it on two threads. Both load 0. Both compute 1. Both store 1. Two increments ran and the counter reads 1. One write was lost.
The same shape elsewhere
Any check followed by an action has the same flaw. if (!cache.ContainsKey(key)) cache.Add(key, value); is a read and then a write, so two threads can both pass the check and the second Add throws. So is reading a value, deciding something from it, and writing a new value back.
What trips people up
Races are not reliable failures. The same code can lose nothing for a month and then lose thousands of writes on a busy afternoon, so a passing test proves very little. Marking the field volatile does not fix it either. That keyword is about when other threads see a value; the problem here is that your update is three steps rather than one.
Example
int counter = 0;
// A Barrier makes a fixed number of threads wait for each other.
// It is used here only to force the interleaving every single run.
using var bothHaveRead = new Barrier(2);
void AddOne()
{
int seen = counter; // step 1: read the current value
bothHaveRead.SignalAndWait(); // wait until the other task has also read
counter = seen + 1; // steps 2 and 3: add one, store the result
}
Task first = Task.Run(AddOne);
Task second = Task.Run(AddOne);
await Task.WhenAll(first, second);
Console.WriteLine($"two tasks each added one, counter = {counter}");
Console.WriteLine($"expected 2, so {2 - counter} write was lost");
// Output:
// two tasks each added one, counter = 1
// expected 2, so 1 write was lostWhen to use it
- Explaining why a request counter in a web service undercounts under load.
- Diagnosing a cache that occasionally throws a duplicate-key exception on startup.
- Reviewing a parallel import that writes fewer rows than it reads.
- Deciding whether a shared field needs a lock before adding a background task.
More examples
Check-then-act on a Dictionary
The check and the act are two separate operations, so another thread fits between them. This is the most common race in real code, and the usual symptom is an occasional duplicate-key exception in a cache or registry. Two threads writing a Dictionary at the same time can also corrupt its internal arrays, after which an unrelated read loops forever or throws.
// Two tasks warm the same cache entry.
Dictionary<string, string> cache = new();
void Warm(string key, string value)
{
if (!cache.ContainsKey(key)) // the check
{
cache.Add(key, value); // the act
}
}
Task a = Task.Run(() => Warm("region", "eu-west-1"));
Task b = Task.Run(() => Warm("region", "eu-west-2"));
await Task.WhenAll(a, b);
// Both tasks can finish the check before either reaches Add. The loser then
// throws ArgumentException: an item with the same key has already been added.A shared List losing items
Reach for this when someone reports that a parallel import produced fewer rows than it read. A collection method is not atomic just because it is one line of your code; List<T>.Add is itself a read-modify-write on the list's size field. The fix is a lock around the list or a collection built for concurrent use.
List<int> results = new();
Task Fill(int start) => Task.Run(() =>
{
for (int i = start; i < start + 10_000; i++)
{
// Add reads the internal size, writes the slot, then writes size back.
results.Add(i);
}
});
await Task.WhenAll(Fill(0), Fill(10_000));
// results.Count is usually below 20000: two Adds can read the same size and
// write to the same slot. The run can also throw IndexOutOfRangeException
// while the list is copying itself into a bigger array.A race across two fields
Shows the kind of race that no single atomic operation can fix. Even if each field were updated atomically, a reader between the two lines sees a state the program is never supposed to be in. When a rule spans more than one variable, the only answer is to make the whole update exclusive, which is what the lock statement does.
// The rule this code is supposed to keep: total is always the price sum of
// exactly itemCount items.
int itemCount = 0;
decimal total = 0m;
void AddItem(decimal price)
{
itemCount += 1;
// Another thread reading both fields right here sees one more item
// than the total accounts for.
total += price;
}
Discussion