5.2.24 · D4C++ Programming

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

2,613 words12 min readBack to topic

Before we start, one picture fixes the vocabulary we lean on the whole page — what a lock actually does to overlapping threads in time.

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

Read it left to right as time. Two horizontal bars are two threads. The amber block is the critical section (the code between lock and unlock). The rule the mutex enforces: the amber blocks never overlap vertically — thread 2 waits (dashed) until thread 1 releases.


Level 1 — Recognition

Goal: name the right tool. No code writing yet.

Recall Solution 1.1

std::lock_guard<std::mutex>. It is the minimal RAII lock: locks in its constructor, unlocks in its destructor, and offers no other operations. "Simplest tool that fits" → lock_guard.

Recall Solution 1.2

std::unique_lock<std::mutex> is required. std::condition_variable::wait must unlock while sleeping and re-lock on wakeup; only unique_lock exposes unlock()/lock(). lock_guard cannot unlock before its destructor, so it cannot participate.

Recall Solution 1.3
  • t.join()block the current thread until t finishes, then release its resources.
  • t.detach()let t run on its own; you give up the ability to wait for it, and it cleans itself up when done. Exactly one must be called on a joinable thread before destruction.

Level 2 — Application

Goal: write correct, compiling code.

Recall Solution 2.1
int counter = 0;
std::mutex mtx;
void add(int N) {
    for (int i = 0; i < N; ++i) {
        std::lock_guard<std::mutex> lk(mtx); // lock this iteration
        ++counter;                           // read-modify-write, now serialized
    }                                        // lk destroyed -> unlock
}

Two threads, each doing N increments, with every ++counter inside a mutex → no lost updates. Guaranteed final value .

Recall Solution 2.2
std::mutex io_mtx;
void say(const std::string& s) {
    std::lock_guard<std::mutex> lk(io_mtx);
    std::cout << s << "\n";
}

std::cout is one shared object; without the lock two threads' characters interleave. The lock covers the whole (short) scope and never unlocks early → lock_guard is the correct, cheapest choice.

Recall Solution 2.3

std::thread is move-only — it owns one unique OS thread, so copying is deleted. push_back(t) tries to copy → compile error. Fix: move it.

pool.push_back(std::move(t)); // transfer ownership; t is now empty (non-joinable)

See Move semantics for why a move leaves t in a valid but resource-less state.


Level 3 — Analysis

Goal: find the bug, count the outcomes, reason about behavior.

Recall Solution 3.1

++counter = read counter into a register → increment the register → write back. Interleaving that loses an update (start counter = 41):

time Thread A Thread B counter
1 read 41 41
2 read 41 41
3 inc → 42 41
4 inc → 42 41
5 write 42 42
6 write 42 42

Both threads incremented, but the final value is 42, not 43 — one increment is lost. This unsynchronized shared write is a data raceUndefined behavior in C++.

Recall Solution 3.2
std::mutex mtx;
void worker() {
    std::unique_lock<std::mutex> lk(mtx); // locked
    int x = read_shared();                // shared read under lock
    lk.unlock();                          // release EARLY
    heavy(x);                             // no shared state -> no lock needed
}

lock_guard cannot unlock before scope end, so it would hold the lock through all of heavy — blocking others for no reason. unique_lock can unlock() early, so it is mandatory here. Use the flexible tool only when you use the flexibility.

Recall Solution 3.3

Naive body: lock(a.m); lock(b.m); .... Then:

  • Thread 1 locks a, wants b.
  • Thread 2 locks b, wants a.
  • Each holds what the other needs → circular wait = deadlock. Neither ever proceeds.
Figure — Concurrency — std - thread, std - mutex, std - lock_guard, std - unique_lock

It is a timing bug because it only triggers when both threads grab their first lock before either grabs its second. If thread 1 finishes entirely before thread 2 starts, no deadlock — the program "works" and hides the bug. That intermittency is exactly what makes lock-ordering bugs dangerous. See Deadlock and lock ordering.


Level 4 — Synthesis

Goal: combine tools to solve a real design.

Recall Solution 4.1
void transfer(Account& a, Account& b, int amt) {
    std::unique_lock<std::mutex> la(a.m, std::defer_lock); // construct, DON'T lock
    std::unique_lock<std::mutex> lb(b.m, std::defer_lock);
    std::lock(la, lb);   // lock BOTH atomically, deadlock-free
    a.bal -= amt;
    b.bal += amt;
} // both released by RAII

std::defer_lock makes each unique_lock adopt but not lock its mutex. std::lock(la, lb) uses a back-off algorithm: it tries to grab all, and if it can't get one it releases any it holds and retries, so no thread sits holding one mutex while waiting for another → the circular-wait condition is broken. (C++17: std::scoped_lock lk(a.m, b.m); does the same in one line.)

Recall Solution 4.2
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
 
int consume() {
    std::unique_lock<std::mutex> lk(m);          // unique_lock: cv needs unlock/relock
    cv.wait(lk, []{ return !q.empty(); });       // predicate guards spurious wakeups
    int v = q.front(); q.pop();
    return v;
} // lk released
  • unique_lock because cv.wait unlocks lk while sleeping and re-locks on wakeup — lock_guard can't.
  • Predicate form wait(lk, pred) re-checks !q.empty() on every wakeup, so a spurious wakeup (OS wakes the thread with no real notify) doesn't pop an empty queue. See std::condition_variable.

Level 5 — Mastery

Goal: design with tradeoffs; reason about limits and alternatives.

Recall Solution 5.1

Both give the correct final value .

  • (a) mutex: every ++counter pays lock + unlock (kernel-mediated contention when threads collide). Correct but heavy for a one-instruction critical section.
  • (b) std::atomic<long>: counter.fetch_add(1) (or ++counter) is a single lock-free hardware instruction; no blocking, no OS involvement. For a pure counter, std::atomic is faster — mutexes shine when the critical section is more than one trivial op. See std::atomic and lock-free programming.
Recall Solution 5.2
template <class T>
class Guarded {
    T data;
    std::mutex m;
public:
    template <class F>
    auto with(F&& f) {                       // the ONLY way to reach data
        std::lock_guard<std::mutex> lk(m);   // lock for the whole call
        return f(data);                      // hand a reference to the callback
    }                                        // auto-unlock
};
// usage: g.with([](int& x){ ++x; });

data is private; the only path to it is with, which locks first. There is no API to get a raw reference that outlives the lock, so "use without locking" won't compile. This is RAII and resource management pushed to its limit: the type system enforces the locking discipline.

Recall Solution 5.3

Use std::async/std::future when a thread's job is to compute and return a value. future::get() transfers the result out with no shared mutable state and no manual mutex — you get the value and any exception, synchronized for you. This removes an entire class of data-race bugs. Here f.get() returns . See std::async and std::future.


Recall One-line recap of the ladder

L1 name the tool ::: lock_guard simple, unique_lock flexible, join/detach on threads. L2 write it ::: guard the read-modify-write; threads are move-only. L3 analyze it ::: ++ is read-inc-write; naive two-lock ordering deadlocks by timing. L4 synthesize ::: std::lock(defer,defer) beats deadlock; unique_lock+cv for waiting. L5 master ::: atomic for counters, future for results, Guarded<T> makes locking mandatory.

Connections

  • Parent topic (Hinglish)
  • RAII and resource management — the mechanism behind every lock wrapper here
  • std::atomic and lock-free programming — Exercise 5.1's faster counter
  • std::condition_variable — Exercise 4.2's wait-without-spinning
  • Deadlock and lock ordering — Exercises 3.3 & 4.1
  • std::async and std::future — Exercise 5.3
  • Undefined behavior in C++ — what a data race actually is
  • Move semantics — why std::thread is move-only