5.2.24 · D3C++ Programming

Worked examples — Concurrency — std - thread, std - mutex, std - lock_guard, std - unique_lock

2,844 words13 min readBack to topic

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

Figure — Concurrency — std - thread, std - mutex, std - lock_guard, std - unique_lock

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