Visual walkthrough — Concurrency — std - thread, std - mutex, std - lock_guard, std - unique_lock
We build one central result: why two threads each adding 100000 to a shared counter can end with any value from 2 up to 200000 — and why wrapping the add in a lock always gives exactly 200000.
Step 1 — What is a "thread", pictured as two hands
WHAT. A thread is a single line of execution — imagine one hand moving down a list of instructions, doing them one after another. Two threads = two hands moving at the same time over their own instruction lists.
WHY start here. Before we can see a race, we must see that "at the same time" is literal: the two hands are not taking turns politely. Nothing coordinates them unless we add coordination.
PICTURE. Two hands, each running the same tiny program ++counter, both reaching toward one shared box labelled counter.
The parent's kitchen analogy in one line: two cooks, one counter. See the parent topic for the full model.
Step 2 — What ++counter really is: three tiny moves
WHAT. The single symbol ++counter is not one action to the hardware. It is three:
Reading the symbols left to right:
- — a private scratch value living in the thread's own CPU register; each hand has its own .
- — READ: copy the box's current number into my private .
- — ADD: add one to my copy only. The box is untouched here.
- — WRITE: stamp my private back into the shared box.
WHY this decomposition. The whole disease lives in the gap between READ and WRITE. If we treated ++counter as one instant action, no race could exist. Splitting it into three exposes the interruptible middle.
PICTURE. The three arrows READ → ADD → WRITE, with the private drawn as a small side-pad separate from the shared box.
Step 3 — One possible interleaving that loses a count
WHAT. Now let both hands run their three moves, and let time tick down a shared timeline. Suppose the box starts at . Here is one bad interleaving (of many the OS could pick):
| time | Thread A | Thread B | box counter |
|---|---|---|---|
| READ | |||
| READ | |||
| ADD | |||
| ADD | |||
| WRITE | |||
| WRITE |
- — the two hands' separate scratch values.
- Both read before either writes → both compute → the box ends at , not .
WHY it's wrong. Two ++ operations should raise the box by (to ). Here one increment vanished because B read the stale value that A had not yet updated.
PICTURE. The timeline with both READs landing on the same value (red), both WRITEs stamping the same (orange) — the collision that eats a count.
Step 4 — How bad can it get? The full range of outcomes
WHAT. With 100000 increments per thread, if we pretend the read/write model of Step 2 holds, the sampled value can be anything in the interval
reading the bounds:
- Upper — the lucky run where no two operations ever overlap; every one of the increments survives.
- Lower — the worst interleaving. Suppose thread A does all of its READs first, each grabbing the value into its private (the box never advances because A never writes yet). Then thread B runs to completion, driving the box to . Finally A performs its writes, each stamping a stale back — the last write leaves the box at just . Symmetric bookkeeping across both threads pins the true theoretical floor at (each thread's final write is the only one guaranteed to land). It is not : nothing forces a whole thread's work to be preserved.
WHY the true floor matters. A tempting wrong guess is "at least one thread's 100000 survive, so ." That is false — stale reads can wipe out almost everything. Knowing the floor is as low as tells you a race is not a minor rounding error; it can destroy nearly all your work.
WHY this is only a caricature. Because the access is UB, even "somewhere in " is not a promise — a real run could crash or print garbage outside any counting argument. The range is a teaching lower/upper bound on the counting model, not a spec.
PICTURE. A number line from to with a scatter of possible results — the point being there is no single "the answer".
Step 5 — The fix: make READ→ADD→WRITE unsplittable
WHAT. Put a mutex (a single token) around the three moves. A hand must hold the token to enter; only one token exists.
std::mutex mtx;
{ std::lock_guard<std::mutex> lk(mtx); // grab token here
++counter; // READ-ADD-WRITE, uninterrupted
} // lk destroyed -> drop token heremtx— the single token object.lk— the RAII grip. RAII stands for Resource Acquisition Is Initialization: the idea that owning a resource (here, the lock) is tied to an object's lifetime — it acquires in the constructor and releases in the destructor. See RAII and resource management. Solkgrabs the token when built and drops it when the block ends — even if an exception flies out.- The block
{ ... }— the critical section: at most one hand inside at a time.
WHY it works. The other hand cannot slip in between READ and WRITE anymore — it is blocked at the token until the first hand finishes all three moves and drops it. The gap that lost counts in Step 3 no longer exists.
PICTURE. Thread A holding the token, doing READ→ADD→WRITE with no interruption; Thread B frozen at the closed gate, waiting.
Step 6 — Watching the same start with the lock: no count is lost
WHAT. Replay Step 3's start () but serialized by the token:
| time | Thread A | Thread B | box counter |
|---|---|---|---|
| lock, READ | blocked | ||
| ADD | blocked | ||
| WRITE , unlock | blocked | ||
| lock, READ | |||
| ADD | |||
| WRITE , unlock |
B's READ now sees — the fresh value A wrote — so B computes . Two increments, box rises by . Correct. And because the lock also establishes proper synchronization, this is now defined behavior — a real promise, not a caricature.
WHY this is the whole point. Serialization turns "at the same time" back into "one-at-a-time" for the shared box only. Both hands still run in parallel everywhere else; they only queue at the token.
PICTURE. The corrected timeline: B's READ arrow now lands on , not the stale .
Step 7 — The degenerate cases (so you never hit an unshown one)
WHAT & WHY, each a corner the reader might stumble into:
PICTURE. A 2×2 grid: (single hand → safe), (many readers → safe), (writer + reader unlocked → race), (writer + reader locked → safe).
The one-picture summary
Everything above compressed: the unprotected path splits READ/ADD/WRITE and loses a count; the protected path forces the trio to run whole, so the count survives.
Recall Feynman retelling — the whole walkthrough in plain words
Two kids each want to bump a number written on one shared card up by one. To bump it, a kid has to (1) read the number, (2) add one in their head, (3) erase and write the new number. The trouble: while kid A is still adding one in their head, kid B reads the old number off the card. Both write the same new number — so two bumps only moved the card up by one. A whole bump vanished, and because it depends on split-second timing, you can't reliably reproduce it — in fact, since this is "undefined behavior", the card could end up showing almost anything, even something silly. In the very worst timing, almost every bump gets wiped out. The fix is one token on the table: you may only touch the card while holding the token, and there's exactly one. Now kid B can't read until kid A has finished writing and let go — B always sees A's fresh number. The lock_guard is a magic strap: the instant a kid steps away from the table, the token drops automatically, so nobody can walk off still clutching it (that habit of tying "let go" to "leaving" is what grown-ups call RAII). Same parallel kids, same shared card — but the touching is now single-file, and every bump counts.
Connections
- RAII and resource management — the
lock_guardgrip that auto-drops the token - std::atomic and lock-free programming — make
++counterone unsplittable step without a lock - std::condition_variable — when a hand must wait for a condition, not just a token
- Deadlock and lock ordering — what goes wrong with two tokens
- Undefined behavior in C++ — what a data race formally is
- Move semantics — why a
std::thread(owning a unique hand) is move-only