JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

The Race Condition: When count++ Lies

Two threads each add 1 a million times to one shared counter, and the final answer is somehow less than two million. No bug in your logic, no typo — just the most famous trap in concurrent programming. This guide takes count++ apart instruction by instruction to show exactly where the updates vanish, and names the hazard precisely so the next guide can fix it.

A counter that loses count

You finished the last guide able to start a thread with pthread_create() and wait for it with pthread_join(). So here is the smallest interesting program two threads can run: both add 1 to one shared global counter, half a million times each. The whole point of guide 2 was that threads live in one shared address space, so both threads see the same variable named count. With one thread the answer is obviously a million. With two threads splitting the work, the answer should still be a million — we are just doing the same additions on two runners. Run it, though, and you get something like 743912. Run it again: 689004. The number is different every time, and it is never quite right.

The program is tiny: a single shared global `long count = 0;`, a worker function whose whole body is a loop running `count++` 500000 times, and a main() that starts two threads on that worker, joins both, then prints count. (Both threads share count exactly because, from guide 2, a global lives in the one shared address space.) Nothing in it is wrong in the ordinary sense — no off-by-one, no uninitialized variable, no bad pointer. The loop is correct, the bound is correct, the addition is correct. And yet updates are leaking away. This is your first taste of concurrency nondeterminism: the output depends not only on what you wrote, but on the invisible, run-to-run timing of how the two threads happened to interleave. To see where the lost updates go, we have to stop trusting that `count++` is one single action.

count++ is three steps, not one

From the assembly rung you already know that the CPU does not have an instruction for "add one to this variable in memory and you may not be interrupted." A memory location is not a register; to do arithmetic on it the processor must first load it into a register, modify the register, then store it back. So the innocent-looking `count++` is really a read-modify-write sequence of three separate machine steps, and crucially, the thread can be paused between any two of them.

count++   compiles to roughly three instructions:

    load   count -> reg     ; read the current value from memory
    add    reg, 1           ; modify it in the register
    store  reg -> count     ; write the new value back to memory

A context switch may land BETWEEN any of these three.
One C statement becomes three machine operations: read, modify, write. The thread holds the in-progress value in a private register the whole time — a value no other thread can see until the final store.

That register matters enormously. Each thread has its own registers (guide 2: registers are the private part), so while thread A is partway through its read-modify-write, it is holding the half-finished result in a register that thread B cannot see. As far as B is concerned, the shared count in memory still has the old value — because A has not stored its new value yet. The window between A's load and A's store is exactly where the danger lives. Now we can watch two threads collide inside that window.

Watching the update vanish

Suppose count is currently 41, and both thread A and thread B want to do one increment. The correct outcome, after both have run, is 43. Walk through one unlucky interleaving — one specific order in which the scheduler happens to alternate the two threads' instructions — and watch what really happens.

  1. Thread A loads count into its register: A's register now holds 41. The value in memory is still 41.
  2. Before A can do anything else, a context switch hands the CPU to thread B. (A is frozen mid-increment, its register still holding 41.)
  3. Thread B loads count into ITS register: B's register holds 41. B adds 1, getting 42. B stores 42 back to memory. Memory is now 42.
  4. The scheduler switches back to thread A, which resumes exactly where it paused — still holding 41 in its register, with no idea anything changed. A adds 1, getting 42. A stores 42 back to memory.
  5. Final result: count is 42. Two increments ran, but count only went up by one. B's update was silently overwritten by A's stale store — one increment was lost, with no error, no crash, no warning.

That is the whole disaster in one picture. Both threads read 41, both computed 42, both stored 42 — and the second store landed on top of the first like a careless save in a shared document, throwing one person's edit away. This is a read-modify-write hazard: the read and the write are not glued together, so another thread can slip in between them and make the value you read go stale before you write back. Multiply this rare collision across a million loop iterations and you get exactly the thousands of quietly missing counts you saw in section one.

Naming it precisely: race condition vs data race

Now we name the bug, because the two names people use are not quite synonyms and the difference matters. A race condition is the general, higher-level fault: the program's correctness depends on the timing or interleaving of events, so different schedules give different results. Our counter has one — its final value literally depends on who-stored-last. A data race is the specific, lower-level technical condition that causes ours: two or more threads access the same memory location concurrently, at least one access is a write, and there is no synchronization ordering them. `count++` from two threads is a textbook data race: concurrent, overlapping, write-involving, unsynchronized.

A subtle but liberating point: the race was there *even on the runs that happened to print 1000000*. Correctness by luck is not correctness. The bug is the possibility of a bad interleaving, not the particular run where it bit you — which is exactly why races are so maddening to debug. They are classic nondeterministic bugs: they appear once in ten thousand runs, vanish the moment you add a print statement to investigate (slowing one thread enough to dodge the window), and never reproduce under the debugger. A race that hides whenever you look at it even has a name, the heisenbug.

What would fix it — and what does not

The cure all races share is the same idea: make the read-modify-write indivisible so no thread can ever observe count halfway through another thread's update. We need the three machine steps to behave as if they were one unbreakable step. There are two honest ways to get there, and the rest of this rung is about both. The first is to make the operation itself an atomic operation — a hardware-supported instruction (or a C11 atomic type / the gcc built-in fetch-and-add) that performs read-modify-write as a single uninterruptible unit. With that, `count++` truly cannot be split, and the counter is correct.

The second, more general way is to wrap the dangerous lines in a critical section — a stretch of code that only one thread may execute at a time — and protect it with a lock. The block "load, add, store count" becomes a region you must hold a mutex to enter, so while A is inside it, B simply waits its turn; the interleaving from section three becomes impossible because B can never load count while A is mid-update. That is precisely the subject of the next and final guide in this rung, Why We Need Synchronization — atomics handle one tiny operation, but a mutex protects a whole multi-line invariant, which is what real programs need.