Worked examples — Concurrency — std - thread, std - mutex, std - lock_guard, std - unique_lock
The scenario matrix
Before any code, let us name every case class so we can prove we missed none. Think of it like listing every quadrant before drawing a circle: if you skip a quadrant, a reader will one day land there and be lost.
| Cell | Situation | The danger / question | Covered by |
|---|---|---|---|
| A | Unprotected shared counter | Data race → wrong total | Ex 1 |
| B | Same counter, mutex added | Does the total become exact? | Ex 1 |
| C | Shared std::cout (I/O object) |
Interleaved characters | Ex 2 |
| D | Lock held during slow work | Needless blocking of others | Ex 3 |
| E | Two mutexes, opposite orders | Deadlock (circular wait) | Ex 4 |
| F | Wait-for-a-condition | lock_guard cannot sleep-unlock |
Ex 5 |
| G | Degenerate: zero iterations / empty work | Does anything even happen? | Ex 6 |
| H | Degenerate: same mutex locked twice by one thread | Self-deadlock (UB) | Ex 7 |
| I | Limiting case: N threads, N growing | Does correctness survive scale? | Ex 8 |
| J | Word problem: bank / ticket counter | Translate a real story to locks | Ex 4, Ex 9 |
| K | Exam twist: moving a thread / a unique_lock |
Ownership transfer, no copy | Ex 10 |
Every numeric claim below is machine-checked in the verify block. The concurrency logic (which is timing-dependent in C++) is checked with deterministic models — we simulate the interleaving in Python so the reasoning is provable, not "trust me".
Example 1 — Cells A & B: the race and its cure
Step 1 — Decompose ++counter.
Why this step? Because the "atom" is not the whole statement. At the hardware level ++counter is read → add 1 → write back: three separate steps.
Step 2 — Max value. Why? The best case is the two threads never interleave — they run one fully after the other. Then every increment counts: .
Step 3 — Min value (worst interleave). Why? If both threads read the same old value, both compute the same new value, and both write it — one increment is destroyed. In the pathological extreme, one thread does all 100000 increments while the other reads a stale value before each of its writes; the smallest guaranteed value is just (thread A's 100000 plus at least the last surviving write). This is why a data race is undefined behavior — the standard makes no promise, so we only bound it.
Step 4 — With lock_guard.
Why? The guard makes at most one thread inside the region, so read-add-write is effectively atomic. No increment is ever lost: exactly .
Verify: ✓ (checked below). The racy lower bound and upper bound are checked as an inequality.
Example 2 — Cell C: shared std::cout
Step 1 — Count total characters. Why? Locking never adds or drops data; it only orders it. characters land either way.
Step 2 — Intact blocks without a lock. Why? Interleaving can split any thread's output. Guaranteed intact blocks (worst case).
Step 3 — Intact blocks with lock_guard<mutex> around the whole print.
Why? Each thread holds the lock for its entire 5-char write, so no other thread can slip a character in between. All blocks stay intact.
Verify: total; intact-with-lock ; intact-without (worst) ✓.
Example 3 — Cell D: releasing the lock early
Step 1 — Case (a): lock held for everything. Why? Since the lock covers read (1) + math (100) = 101 units, and only one worker runs at a time inside it, the serialized total is units.
Step 2 — Case (b): unlock after the read. Why? Now only the 1-unit read is serialized; the 100-unit math runs in parallel outside the lock. Serialized lock time units.
Step 3 — The moral.
Why unique_lock here and not lock_guard? Only unique_lock can unlock() early. Use flexibility only when you gain something — here a 101× reduction in serialized time.
Verify: , ✓. See std::condition_variable and std::async and std::future for other ways to overlap work.
Example 4 — Cells E & J: two mutexes, deadlock, and the fix

Step 1 — Model the naive circular wait. Why? Thread 1 holds A and wants B; thread 2 holds B and wants A. Neither can proceed → a cycle in the "waits-for" graph. Look at the red arrows in the figure forming a loop — that loop is the deadlock.
Step 2 — Count completions under the worst interleave. Why? Under the deadlocking schedule, of the transfers complete.
Step 3 — The fix: defer_lock + std::lock.
Why? std::lock(la, lb) acquires all-or-nothing using back-off: if it can't get both, it releases and retries, so no thread ever holds one while waiting for another. The cycle can never form. Both transfers complete: of .
Step 4 — Sanity on the money. Why? Locking never changes arithmetic, only timing. If A starts at 100, B at 100, and we transfer 30 A→B and 40 B→A, final A , final B . Total conserved: .
Verify: naive completions , safe completions ; final balances , , total ✓.
Example 5 — Cell F: waiting for a condition
Step 1 — What cv.wait(lk, pred) does.
Why this tool? A busy-loop while(empty()){} burns a whole CPU core. A std::condition_variable lets the thread sleep and be woken only when notified.
Step 2 — Why it needs unique_lock.
Why? wait must: unlock the mutex before sleeping (so a producer can add an item), then re-lock on wakeup. lock_guard cannot unlock-then-relock; only unique_lock exposes that. That is one unlock + one relock per wakeup = 2 lock-state changes.
Step 3 — Items consumed. Why? After a genuine wakeup the predicate is true (queue has ≥1 item); the consumer pops exactly .
Step 4 — Spurious wakeups.
Why the predicate form wait(lk, pred)? A thread may wake with no item (a "spurious wakeup"). The predicate re-checks and goes back to sleep, consuming items on a false wakeup. Always pass the predicate.
Verify: lock-state changes per wakeup ; items on true wakeup ; on spurious wakeup ✓.
Example 6 — Cell G: the degenerate empty case
Step 1 — Loop with zero iterations.
Why? The condition 0 < 0 is false immediately, so the body never runs. lock_guard is inside the body → it is never constructed.
Step 2 — Counter and lock counts.
Why? No body ⇒ no increments ⇒ counter stays ; the mutex is locked times, across all threads.
Verify: final counter ; total locks ✓.
Example 7 — Cell H: locking the same mutex twice
Step 1 — What a non-recursive mutex promises.
Why? std::mutex is not recursive: re-locking a mutex the same thread already holds is undefined behavior — in practice it self-deadlocks.
Step 2 — Count blocked threads.
Why? The thread waits for a lock only it can release, but it is stuck waiting — so it can never reach the unlock. Exactly thread is permanently blocked, and since it holds the mutex, any other thread wanting it is blocked too.
Step 3 — The fix.
Why? If you truly need re-entrancy, use std::recursive_mutex; usually the real fix is restructuring so you lock only once. Never rely on std::atomic here — a plain re-lock of a normal mutex is simply UB. Compare std::atomic and lock-free programming for lock-free alternatives.
Verify: self-deadlocked threads ✓.
Example 8 — Cell I: scaling to N threads
Step 1 — Formula. Why? Each increment is serialized and none is lost, so the total is simply every increment summed: .
Step 2 — Evaluate. Why? .
Step 3 — Why correctness is -independent. Why? The mutex guarantees mutual exclusion regardless of how many threads compete — adding threads changes speed and contention, never the final value. (More threads on one mutex can slow you down; for pure counters prefer std::atomic and lock-free programming.)
Verify: ✓.
Example 9 — Cell J: ticket counter (real-world)
Step 1 — Why the lock is essential.
Why? Without it, two threads could both read tickets == 1, both decrement, and sell tickets that don't exist (overselling — a classic race). The lock makes the check-and-decrement one atomic decision.
Step 2 — Count outcomes. Why? Only 3 tickets exist. With the lock, exactly customers succeed, remain, and are turned away. No overselling is possible.
Step 3 — Conservation check. Why? Sold + remaining must equal the starting stock: ✓, and everyone is accounted for: .
Verify: sold , remaining , turned away ; conservation and ✓.
Example 10 — Cell K: ownership transfer (exam twist)
Step 1 — Threads are move-only.
Why? A std::thread owns exactly one OS thread — a unique resource. Move semantics transfers that ownership; copying it would imply two owners of one thread, which is forbidden.
Step 2 — State after the move.
Why? std::move(t1) hands the OS thread to t2. Now t2 is joinable and t1 is empty (not joinable). OS threads in existence .
Step 3 — The copy line.
Why? The copy constructor is deleted → std::thread t2 = t1; does not compile (0 successful copies).
Verify: joinable-t1 (false), OS threads , copy compiles (false) ✓.
Recall Rapid recall
Worst-case racy total of two 100000 loops ::: at least 100001, at most 200000; safe = exactly 200000 Lock-held time for 4 workers, read 1 + math 100, unlocking early ::: 4 units (vs 404 held throughout) Naive A-then-B / B-then-A transfers that complete under deadlock ::: 0 of 2; with std::lock, 2 of 2 Lock-state changes during one cv.wait wakeup ::: 2 (unlock to sleep, relock on wake) Final counter for zero-iteration loop on 5 threads ::: 0, mutex locked 0 times Tickets sold from stock 3 with 10 buyers under a lock ::: 3 sold, 0 left, 7 turned away Copying a std::thread ::: does not compile — move-only
Connections
- Parent topic
- RAII and resource management — why the guard's destructor is the hero of every example
- Deadlock and lock ordering — Example 4's circular wait, in depth
- std::condition_variable — Example 5's wait/notify machinery
- std::atomic and lock-free programming — a lock-free cure for Examples 1 & 8
- std::async and std::future — overlapping work like Example 3 without manual threads
- Move semantics — Example 10's ownership transfer
- Undefined behavior in C++ — the formal status of Examples 1 & 7