← Open in the full interactive course (progress tracking, search & more)

Same code. Same input. A different answer — because two threads happened to arrive in a different order this time.

"Race condition" is a phrase that gets thrown around loosely — earlier in this Part it's been used as shorthand for "something went wrong with threads." That looseness stops here. A race condition is a specific, precisely defined kind of bug, and understanding it precisely is what makes every synchronization tool elsewhere in this Part — lock, Interlocked, concurrent collections — make sense as a fix rather than a ritual you perform because someone told you to.

Here's the uncomfortable part: a race condition can sit in a codebase for months, pass every test run, survive code review, and ship to production — and still be a real, live bug the entire time. It's just been waiting for the right (wrong) timing.

In this lesson, you'll get a precise definition of a race condition, build one from scratch with a shared counter that is genuinely, reproducibly wrong, see exactly why count++ is not the atomic operation it looks like, understand why these bugs are so brutal to debug, and tour the standard fixes — ending with the strongest fix of all: making the bug impossible by removing shared mutable state entirely.

What Is It?

The Simple Explanation

A race condition is what happens when two or more threads touch the same piece of data at the same time, at least one of them is changing it, and nobody coordinated the order. The program's correctness ends up depending on which thread happens to run first, second, or in between — a matter of pure timing, which is precisely the one thing you have no control over.

The Technical Definition

A race condition occurs when the observable behavior of a program depends on the relative timing or interleaving of multiple threads accessing shared, mutable state, without sufficient synchronization to make that access safe. Three ingredients have to be present together for a race condition to even be possible:

IngredientMeaning
Shared stateMore than one thread can reach the same piece of data (a field, a static, a captured variable).
Mutable stateThat data can actually change after it's created — there's something to overwrite.
Unsynchronized accessAt least one thread writes to it, and nothing coordinates the order in which threads read or write.

Remove any one of the three and the race condition becomes structurally impossible — not "unlikely," impossible. That last sentence is the single most important idea in this lesson, and it's the thread this lesson pulls on all the way to the end.

A precise definition, on purpose

A race condition is not "a bug that happens with threads." It's not a deadlock (that's next lesson — a deadlock is threads that stop entirely; a race condition is threads that keep running and produce a wrong answer). It's not "code that's slow under load." It is specifically: shared + mutable + unsynchronized, with an outcome that depends on timing. Every example in this lesson satisfies all three conditions, and every fix works by removing at least one of them.

Why Does It Exist?

The Problem — Code That Looks Like One Step Is Actually Several

Most C# statements that look like a single, indivisible action are, at the machine level, several separate steps. counter++ reads as "increment the counter" — one idea, one line, one semicolon. But the processor can't literally add 1 directly inside memory; it has to bring the current value into a register, do the arithmetic there, and write the result back. That's three steps, not one. On a single thread, nobody ever notices, because nothing else can run in between those three steps. The instant a second thread can also read, modify, and write that same location, the gap between those steps becomes a place where information can be lost.

The Need

Multithreaded and concurrent code exists precisely to let multiple pieces of work happen at the same time — that's the entire value proposition of everything else in this Part. But "at the same time" means the runtime is free to interleave those three-step operations from different threads in essentially any order it likes. What's needed is a way to either (a) make sure the interleaving can't corrupt shared data, or (b) make sure there's no shared, mutable data for the interleaving to corrupt in the first place.

The Solution — Synchronization, or No Sharing At All

That's exactly what the synchronization primitives and concurrent collections elsewhere in this Part exist to provide — a way to make a multi-step operation on shared state behave, from every other thread's point of view, as if it happened all at once. This lesson builds the motivating bug first, so those tools land as answers to a problem you've actually watched happen, rather than incantations to memorize.

Big Picture

THE THREE INGREDIENTS, VISUALIZED
INGREDIENT 1 — SHARED
+
INGREDIENT 2 — MUTABLE
+
INGREDIENT 3 — UNSYNCHRONIZED
RESULT — RACE CONDITION IS POSSIBLE

Notice this diagram never mentions "how many threads" or "how often." Two threads, running for a fraction of a second, on a lightly-loaded system, are enough — this isn't about scale, it's about the presence of all three ingredients at once.

How It Works — count++ Is Not One Step

Here is the classic textbook example, for good reason: it's small, it's real, and it's genuinely reproducible. counter++ looks like a single atomic instruction. It compiles to three:

// counter++ conceptually becomes: int temp = counter; // 1. READ — load the current value temp = temp + 1; // 2. ADD — compute the new value counter = temp; // 3. WRITE — store it back

Now watch what happens when two threads run this same three-step sequence against the same counter, with no synchronization, and their steps happen to interleave:

TimeThread AThread Bcounter in memory
T010
T1READ counter → 1010
T2READ counter → 1010
T3ADD 1 → 11 (in A's local temp)10
T4ADD 1 → 11 (in B's local temp)10
T5WRITE 11 → counter11
T6WRITE 11 → counter11

Two increments happened. The counter should read 12. It reads 11. Thread B read the value before Thread A's write landed, computed its own "new" value from that same stale 10, and then overwrote Thread A's update with its own — silently. Thread A's increment isn't wrong, isn't logged as an error, isn't thrown away with a warning. It just never happened, from the counter's point of view. This is called a lost update, and it's the single most common shape a race condition takes.

Simple Example

Here's that same lost-update bug, scaled up into code you can actually run and watch fail:

int counter = 0; var tasks = new List<Task>(); for (int i = 0; i < 1_000; i++) { tasks.Add(Task.Run(() => { for (int j = 0; j < 1_000; j++) { counter++; // read, add, write — no synchronization } })); } await Task.WhenAll(tasks); Console.WriteLine(counter); // Expected: 1,000,000 // Actual: something LESS than 1,000,000 — and a DIFFERENT // number nearly every time you run it

Walking through it:

Try it yourself, mentally: if you ran this program 10 times, you would not expect to see the same wrong number 10 times. That variability — a different, unpredictable answer on every run of the exact same code — is the fingerprint of a race condition. A bug that reproduces identically every time is (almost always) something else.

Real-World Example

Swap "counter" for something a business actually cares about, and the same bug gets a name like "overselling." Picture an e-commerce checkout service tracking remaining stock for a popular item as a plain shared field:

public class InventoryService { private int _stockRemaining = 3; // only 3 left! // Two customers' checkout requests can run this // concurrently on the thread pool — classic race condition public bool TryReserve() { if (_stockRemaining > 0) { _stockRemaining--; // read, add(-1), write — not atomic return true; } return false; } }

With only 3 items left and a flash-sale spike of concurrent requests, two customers' checkouts can both read _stockRemaining as 1 before either one's decrement lands, and both get told "reservation succeeded." The store has now promised the same last item to two different customers — a real, customer-facing incident, from the exact same lost-update shape as the counter example, just wearing business clothes.

The fix — using the tools covered elsewhere in this Part — closes the gap between "check" and "act":

public class InventoryService { private int _stockRemaining = 3; private readonly object _lock = new(); // Fixed with a lock — the check-and-decrement // now happens as one indivisible step public bool TryReserve() { lock (_lock) { if (_stockRemaining > 0) { _stockRemaining--; return true; } return false; } } }

The lock statement — covered in full elsewhere in this Part — is exactly the kind of synchronization primitive that removes ingredient 3 (unsynchronized access) without removing the sharing itself.

Analogy

Two ATMs, One Account, No Lock

Imagine a joint bank account with $100 in it, and two ATM withdrawals happening at almost the same instant — one for $70, one for $50 — at two different physical machines. Each machine independently checks the balance ("is $100 ≥ $70? Yes."), independently approves the withdrawal, and independently subtracts its amount afterward. If both machines check the balance before either one writes its update back, both approve — and the bank has just handed out $120 from a $100 account.

The balance was shared (both machines can read and write it), mutable (withdrawals change it), and unsynchronized (neither machine coordinated with the other about the order of "check" and "act"). That's every race condition in this lesson, dressed up as a very expensive banking bug — which is exactly why real banking systems synchronize this kind of check-then-act operation with exactly the tools this lesson is building toward.

Under the Hood

It's worth being precise about why counter++ isn't atomic, rather than just accepting it as a rule. At the intermediate-language level, a field increment like this genuinely does compile down to separate load, add, and store instructions — there is no single CPU instruction in the general case that C# emits to "increment a shared field, guaranteed no other thread can interleave." Even operations that feel smaller than counter++ — a plain field read, a plain field write — can, in principle, be affected by the runtime's memory model in ways that surprise people coming from single-threaded intuition. The takeaway is not "memorize which specific operations are unsafe" — it's "assume ordinary reads and writes of shared mutable state are not automatically safe under concurrency, and reach for an explicit synchronization tool whenever more than one thread can touch the same mutable data."

This is also exactly why Interlocked.Increment — one of the standard fixes below — exists as a distinct API rather than everyone just trusting counter++: it maps to a genuine hardware-level atomic instruction, doing in one indivisible step what counter++ does in three separate ones.

Common Confusion

1. A race condition ≠ a deadlock

These get lumped together as "threading bugs," but they're nearly opposite failure modes. A race condition is threads that keep running and quietly produce a wrong result. A deadlock — the very next lesson in this Part — is threads that stop running entirely, each waiting on the other forever, producing no result at all. Different symptom, different cause, different fix.

2. "It didn't happen on my machine" is not evidence it can't happen

A race condition's window of opportunity — the gap between the read and the write — might be a few nanoseconds. On a lightly-loaded single-core dev machine, with few threads genuinely running in parallel, that window may almost never get hit. Under production concurrency — many simultaneous requests, real multi-core parallelism, unpredictable scheduling pauses — the exact same code hits that window constantly. The bug was there the whole time; the conditions to trigger it reliably just weren't.

3. "It passed every test" is not proof of correctness for concurrent code

A traditional unit test runs the same code the same way, usually on one thread, and either it produces the right answer or it doesn't — deterministically. Concurrent code doesn't work that way: running the exact same test a thousand times might pass all thousand times and still contain a live race condition, simply because the unlucky interleaving never happened to occur during those thousand runs. "It worked when I tested it" genuinely is not evidence of correctness here — it's just evidence that you didn't get unlucky during testing.

Common Mistakes

Mistake 1 — Assuming a simple-looking operation is atomic

Treating counter++, list.Add(x) on a plain List<T>, or a check-then-set pair (if (dict.ContainsKey(k)) ... dict[k] = v;) as if the single line of C# guarantees a single indivisible operation underneath.

Assume the opposite by default for any operation on shared mutable state: it's probably multiple steps, and those steps can be interleaved by another thread unless something explicitly prevents it.

Mistake 2 — Testing only on a single-core mental model

Reasoning about concurrent code as if only one thread can truly be "in" it at a time, because that's what it feels like watching a debugger step through one thread.

Reason about every possible interleaving of the threads involved — including ones that seem unlikely. If an interleaving is possible, given enough load and enough time, it eventually happens.

Mistake 3 — Reaching for a lock as the only tool, out of habit

Wrapping every piece of shared state in a lock, everywhere, without asking whether the state needed to be shared and mutable in the first place.

Ask first whether the shared mutable state can be avoided entirely (see "When Should I Use It?" below) — a lock is a correct fix, but it's not the only fix, and it's not always the best one.

When Should I Use Each Fix?

Every fix for a race condition works by removing one of the three ingredients from the Big Picture. Here's the standard toolkit, ranked from "patch the symptom" to "make the bug structurally impossible":

FixWhich ingredient it removesWhen to reach for it
lock (a synchronization primitive)Unsynchronized accessGeneral-purpose — protects a block of code that touches shared state, covered fully among the locking primitives elsewhere in this Part.
Interlocked.Increment / similarUnsynchronized accessSimple single-value operations (increment, add, compare-and-swap) — cheaper than a full lock for just that.
A concurrent collectionUnsynchronized accessShared collections specifically — purpose-built, covered fully elsewhere in this Part, to be safe without you hand-rolling locking around every operation.
ImmutabilityMutability itselfThe strongest fix — removes the possibility at the design level rather than patching around it. See below.
The strongest fix isn't a lock — it's nothing to lock. An object that is genuinely immutable after construction cannot have a race condition on its own state, for a simple structural reason: ingredient 2 (mutability) is gone. There's no write for another thread's read to race against, because there's no write, ever, after the object is built. This is exactly why readonly struct and records — from Advanced Part I — are concurrency tools, not just "cleaner code" tools: an immutable value can be freely read by any number of threads simultaneously, with zero synchronization, and zero risk. Where you have a choice, designing shared data to be immutable eliminates an entire category of bug that locking merely manages.

Mental Model

Race condition = shared + mutable + unsynchronized + timing decides the outcome.

Remember:
· A "simple" operation like x++ is really read → add → write, three separate steps.
· Interleave those three steps across threads and an update can be silently lost.
· No crash, no exception, no error message — just a quietly wrong number.
· Different outcome on different runs is the signature of a race condition.
· Fix it by synchronizing access (lock, Interlocked, concurrent collections) — or, better, by removing the mutability entirely.

Key Takeaway


Check Your Understanding

You've built a genuine race condition from scratch and seen exactly why it happens. Let's check the precise definition — and the reasoning behind the fixes — actually stuck.

1. Which combination of conditions is required for a race condition to be possible?

Show answer

Correct: B

Why B is correct: All three ingredients are required together — shared state that multiple threads can reach, that state being mutable so there's something to overwrite, and unsynchronized access so nothing coordinates the timing. Remove any one and a race condition on that data becomes impossible.

Why A is incorrect: Running on multiple cores is necessary for true parallelism but says nothing about whether shared mutable state is involved — plenty of multi-core code has no shared mutable state and no race condition risk.

Why C is incorrect: Multiple threads alone aren't enough — if those threads never touch the same mutable data, there's nothing to race on.

Why D is incorrect: Slowness is a performance characteristic, not a correctness bug — a race condition is about a wrong result, not a slow one.

Reinforcement: Shared + mutable + unsynchronized is the precise, complete definition — memorize the trio, not a vague feeling about "threading bugs."

2. Two threads both execute counter++ on the same shared int, with no synchronization, and their operations happen to interleave badly. What is the most accurate description of the result?

Show answer

Correct: B

Why B is correct: Because counter++ is read-add-write, one thread can read the pre-update value, compute its own increment from it, and write that back after overwriting the other thread's update — a lost update. The counter is still a valid, plausible-looking number; it's just wrong by exactly the number of lost increments, and nothing flags it.

Why A is incorrect: There's no exception — the code runs without error; it just silently produces a mathematically wrong total.

Why C is incorrect: The C# compiler does not detect or serialize this at compile time — that's exactly why explicit synchronization tools exist; nothing protects you automatically.

Why D is incorrect: The value stays a sensible-looking integer, just undercounted — it doesn't become corrupted or negative from this particular bug.

Reinforcement: A lost update is quiet and plausible-looking, which is exactly what makes it dangerous — nothing about the output screams "bug" on its own.

3. A developer runs their multithreaded counter code once on their laptop, gets the correct total, and concludes the code is thread-safe. What's the flaw in that reasoning?

Show answer

Correct: B

Why B is correct: Race conditions depend on a specific, often narrow timing window being hit. A single run — especially on a lightly-loaded machine where the risky interleaving may rarely occur — can easily produce the correct answer even though the underlying code is genuinely unsafe. Correctness under concurrency has to be reasoned about structurally (are the three ingredients present?), not just observed empirically once.

Why A is incorrect: This is precisely the trap — a passing run is not proof of correctness for concurrent code, unlike deterministic single-threaded code where a correct run is much stronger evidence.

Why C is incorrect: Laptops run multithreaded code correctly all the time; the issue isn't the hardware, it's whether the risky interleaving happened to occur during that particular run.

Why D is incorrect: Getting the correct answer once doesn't imply anything was used incorrectly — it can happen with genuinely broken, unsynchronized code that simply got lucky that run.

Reinforcement: "It worked when I tested it" is not evidence of correctness for concurrent code — reason about the three ingredients, don't rely on a single observed run.

4. Why is an immutable object (e.g., a readonly struct or a record with init-only properties) described as the strongest fix for race conditions, rather than just another synchronization option alongside lock?

Show answer

Correct: B

Why B is correct: A race condition requires shared, mutable, unsynchronized state. lock and Interlocked address the "unsynchronized" part while leaving the state mutable and shared — they manage the risk. Immutability instead removes mutability itself: once nothing can change after construction, there's no write for a concurrent read to race against, so the object can be freely shared across any number of threads with zero risk and zero synchronization code.

Why A is incorrect: Immutability's benefit here is correctness and simplicity, not an inherent universal speed advantage — performance depends on the specific scenario.

Why C is incorrect: There's no automatic locking involved at all — the safety comes from there being nothing to protect, not from hidden synchronization machinery.

Why D is incorrect: The opposite is true — an immutable object can be accessed by any number of threads simultaneously, precisely because there's no mutation to coordinate.

Reinforcement: Locking manages a risk that immutability eliminates outright — that's what makes it the strongest available fix, not just one option among equals.

5. An InventoryService.TryReserve() method checks if (_stockRemaining > 0) and then decrements _stockRemaining as two separate statements, with no lock. What makes this a race condition, specifically?

Show answer

Correct: B

Why B is correct: This is a check-then-act race — the same shape as the lost-update bug, just with a condition attached. Both threads can pass the "is stock > 0" check using the same stale value before either one's decrement is visible to the other, so the method reports success to both callers even though there wasn't enough stock for both.

Why A is incorrect: A method's name has no effect on its actual thread safety — this is purely about the presence or absence of synchronization around the shared state.

Why C is incorrect: Plain reads of an int can generally be observed safely in the sense of not tearing a value on typical hardware, but that's a different concern from this check-then-act race — the danger here is the gap between the check and the write, not the read mechanics of int itself.

Why D is incorrect: The field doesn't need to be static — any field or shared variable reachable from multiple threads (an instance field on a service used by concurrent requests, exactly as shown, is plenty) can exhibit this bug.

Reinforcement: A race condition doesn't require a bare increment — any unsynchronized check-then-act sequence on shared mutable state has the same lost-update shape, just applied to a decision instead of a plain add.

You now have a precise, working definition of a race condition — and you've watched one happen. Next up: the other classic concurrency failure mode, where threads don't produce a wrong answer, they simply stop forever — deadlocks.


dotnetmadeeasy.com — Learn C# and .NET, the right way.