Skip to main content
    Courses/C++/Mutexes & Locks

    Lesson 26 • Advanced

    Mutexes, Locks & Deadlock Avoidance

    By the end of this lesson you'll be able to protect shared data from data races with std::mutex, pick the right RAII lock, dodge deadlocks, coordinate threads with a condition variable, and count without locks using std::atomic — the core toolkit of safe C++ concurrency.

    What You'll Learn

    • Guard shared data with std::mutex and a RAII lock
    • Choose between lock_guard, unique_lock, and scoped_lock
    • Spot and prevent deadlocks with consistent ordering & std::lock
    • Coordinate producer/consumer threads with std::condition_variable
    • Count without locks using std::atomic
    • Know when (and when not) to use std::recursive_mutex

    💡 Real-World Analogy

    A mutex is the talking stick in a meeting: only the person holding it may speak, and everyone else waits until it's passed on. The shared data is the conversation; the mutex makes sure two people don't talk over each other and garble it. A deadlock is two people each refusing to hand over their stick until they get the other's — so nobody ever speaks again. The whole lesson is really one idea: hold the stick only while you must, and agree on who picks up which stick first.

    1. The Data Race and std::mutex

    When two threads change the same variable at once, you get a data race: the result is wrong and unpredictable. That's because counter++ is really three steps — read, add one, write back — and the threads' steps interleave. A mutex (mutual exclusion) fixes this: a thread locks it before touching the shared data and unlocks when done, so only one thread is in that critical section at a time. Always wrap the lock in a RAII guard so it unlocks even if an exception is thrown.

    Worked example: a data race fixed with std::mutex

    Run it, then try deleting the lock_guard line and watch the total go wrong.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>
    #include <vector>
    using namespace std;
    
    // A mutex (MUTual EXclusion) is a "talking stick": only the thread
    // holding it may touch the shared data. Everyone else waits their turn.
    mutex mtx;          // guards the shared 'counter' below
    long counter = 0;   // shared between all threads
    
    void addOneMillion() {
        for (int i = 0; i < 1000000; i++) {
            // lock_guard locks mtx here and AUTOMATICALLY unlocks it
            // when 'guard' goe
    ...

    Your turn. The program below counts across two threads but the increment isn't protected yet. Add the one missing line marked ___ using the hint, then run it.

    🎯 Your turn: protect the shared total

    Add a lock_guard so the total is always 100000.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>
    using namespace std;
    
    mutex mtx;
    int total = 0;
    
    void deposit(int times) {
        for (int i = 0; i < times; i++) {
            // 🎯 YOUR TURN — make the line below thread-safe.
    
            // 1) Take the lock with a RAII guard on 'mtx'
            ___;                 // 👉 lock_guard<mutex> guard(mtx);
    
            // 2) The protected work (already written for you)
            total += 1;
        }
    }
    
    int main() {
        thread a(deposit, 50000);
        thread b(deposit
    ...

    2. lock_guard vs unique_lock vs scoped_lock

    All three are RAII locks — they unlock automatically when they go out of scope, so you never forget. They differ in flexibility. lock_guard is the simplest: lock once, auto-unlock, no manual control. unique_lock adds power — you can unlock and relock, defer locking, or hand it to a condition variable. scoped_lock (C++17) locks several mutexes at once, deadlock-free. Reach for the simplest one that does the job.

    Worked example: the three RAII locks

    See lock_guard, unique_lock, and scoped_lock side by side.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>
    using namespace std;
    
    mutex mtxA, mtxB;
    
    void useLockGuard() {
        // lock_guard: simplest RAII lock. Locks now, unlocks at end of scope.
        // You CANNOT manually unlock or relock it. Perfect for small sections.
        lock_guard<mutex> g(mtxA);
        cout << "lock_guard: locked, will auto-unlock" << endl;
    }
    
    void useUniqueLock() {
        // unique_lock: like lock_guard but flexible — you can unlock/relock,
        // defer locking, hand it to a condi
    ...

    🔎 Deep Dive: which lock do I pick?

    lock_guard — your default. One mutex, one scope, zero ceremony. Use it for the vast majority of critical sections.

    unique_lock — when you need more: defer_lock to lock later, early unlock(), or (most importantly) to pass to a condition_variable, which requires a unique_lock because it must release and re-acquire the mutex while waiting.

    scoped_lock — whenever you hold two or more mutexes at once. It locks them with a deadlock-free algorithm, so you don't have to reason about ordering yourself.

    3. Deadlock — and How to Avoid It

    A deadlock is when two threads each hold a lock the other one needs, so both wait forever and your program freezes. The classic cause is inconsistent lock ordering: one thread locks A then B, another locks B then A. The cures are simple: always lock mutexes in the same order everywhere, or lock them together atomically with std::lock (then adopt them) or, cleaner still, with std::scoped_lock.

    Worked example: three ways to avoid deadlock

    Consistent ordering, std::lock + adopt_lock, and scoped_lock.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>
    using namespace std;
    
    mutex mtxA, mtxB;
    
    // ❌ DEADLOCK (do not run): two threads grab the locks in OPPOSITE order.
    // void bad1() { lock_guard<mutex> a(mtxA); lock_guard<mutex> b(mtxB); }
    // void bad2() { lock_guard<mutex> b(mtxB); lock_guard<mutex> a(mtxA); }
    // Thread 1 holds A and waits for B; thread 2 holds B and waits for A. Stuck.
    
    // ✅ FIX 1 — consistent ordering: EVERY thread locks A before B.
    void good1() {
        lock_guard<mutex> a(m
    ...

    4. std::condition_variable (Producer/Consumer)

    A mutex stops threads clashing, but how does one thread wait for another to produce work without burning the CPU in a busy loop? A condition variable lets a thread sleep until it's notified. The consumer calls cv.wait(lock, predicate): this releases the mutex, sleeps, and re-locks only when the predicate is true. The producer calls cv.notify_one() to wake it. The predicate also guards against spurious wake-ups — rare false alarms where wait returns for no reason.

    Worked example: producer/consumer queue

    One thread produces jobs, another consumes them via a condition variable.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>
    #include <condition_variable>
    #include <queue>
    using namespace std;
    
    mutex mtx;
    condition_variable cv;       // lets a thread WAIT until something is true
    queue<int> jobs;
    bool done = false;
    
    void producer() {
        for (int i = 1; i <= 5; i++) {
            {
                lock_guard<mutex> g(mtx);
                jobs.push(i);              // add a job
                cout << "Produced " << i << endl;
            }
            cv.notify_one();              // wak
    ...

    5. std::atomic — Lock-Free Counters

    For a single shared value like a counter or flag, a full mutex is overkill. std::atomic<int> makes each operation — increment, compare, exchange — a single indivisible step the hardware guarantees, with no lock at all. It's faster and simpler than a mutex for these small cases. (When you must update several variables together, or a whole container, you still need a mutex — one atomic step isn't enough.)

    Your turn again. Turn the plain counter below into a lock-free one with std::atomic. Fill in the two blanks:

    🎯 Your turn: a lock-free counter

    Declare an atomic<int> and increment it across four threads.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <atomic>
    #include <vector>
    using namespace std;
    
    // 🎯 YOUR TURN — make this counter lock-free with std::atomic.
    
    // 1) Declare an atomic<int> called "hits" starting at 0
    ___;                 // 👉 atomic<int> hits{0};
    
    void clickManyTimes() {
        for (int i = 0; i < 25000; i++) {
            // 2) Increment it atomically (no mutex needed)
            ___;         // 👉 hits++;   (atomic ++ is one indivisible step)
        }
    }
    
    int main() {
        vector<thread> t
    ...

    6. std::recursive_mutex (Brief)

    A plain std::mutex deadlocks if the same thread tries to lock it twice. A std::recursive_mutex keeps a count, so one thread may lock it several times (and must unlock the same number of times) — handy when a locked function calls another locked function. Treat it as a last resort: needing it is usually a sign the design should be restructured so the lock is taken in exactly one place.

    Worked example: re-locking with recursive_mutex

    A locked function calls another locked function safely.

    Try it Yourself »
    C++
    #include <iostream>
    #include <mutex>
    using namespace std;
    
    // A normal mutex DEADLOCKS if the same thread locks it twice.
    // recursive_mutex lets one thread re-lock it (it counts the locks),
    // which helps when a locked function calls another locked function.
    recursive_mutex rmtx;
    
    void inner() {
        lock_guard<recursive_mutex> g(rmtx);   // lock #2 by the SAME thread — OK
        cout << "  inner(): re-locked safely" << endl;
    }
    
    void outer() {
        lock_guard<recursive_mutex> g(rmtx);   // lock #1
     
    ...

    Pro Tips

    • 💡 Always RAII: use lock_guard/scoped_lock — never bare mtx.lock()/unlock(), which leak the lock on an early return or exception.
    • 💡 Minimise the critical section: lock, do the smallest amount of work, unlock. Prepare data outside the lock.
    • 💡 Multiple mutexes? scoped_lock. It replaces std::lock + two adopt_lock guards with one clean line.
    • 💡 Counter or flag? std::atomic. It's lock-free and faster than a mutex for single values.

    Common Errors (and the fix)

    • Forgetting to lock at all: two threads do counter++ with no mutex → a data race and a wrong, random total. Wrap the shared access in a lock_guard<mutex>.
    • Deadlock from inconsistent order: thread 1 locks A then B, thread 2 locks B then A → both freeze. Lock in the same order everywhere, or use scoped_lock(a, b).
    • Holding the lock too long: doing file I/O, network calls, or heavy maths while locked serialises your whole program and kills concurrency. Lock only the critical section.
    • Not using a RAII lock: calling mtx.lock() then returning (or throwing) before mtx.unlock() leaks the lock and hangs every other thread. Use a guard so it always unlocks.
    • Re-locking a plain mutex in one thread: a function holding mtx calls another that also locks mtx → self-deadlock. Restructure, or use recursive_mutex as a last resort.

    📋 Quick Reference

    TaskCodeNotes
    Simple RAII locklock_guard<mutex> g(m);Default choice
    Flexible lockunique_lock<mutex> u(m);For condition vars / early unlock
    Lock many mutexesscoped_lock g(a, b);C++17, deadlock-free
    Lock both atomicallylock(a, b);Then adopt_lock guards
    Wait for a conditioncv.wait(u, pred);Needs a unique_lock
    Wake a waitercv.notify_one();Or notify_all()
    Lock-free counteratomic<int> n{0};n++ is indivisible
    Re-lockable mutexrecursive_mutex rm;Last resort

    Frequently Asked Questions

    Q: What is the difference between lock_guard, unique_lock and scoped_lock?

    lock_guard is the simplest RAII lock: it locks one mutex on creation and unlocks at end of scope, with no manual control. unique_lock does the same but is flexible — you can unlock and relock it, defer locking, time it, and hand it to a condition_variable. scoped_lock (C++17) locks several mutexes at once using a deadlock-free algorithm. Use lock_guard for one simple section, unique_lock when you need condition variables or early unlocking, and scoped_lock for multiple mutexes.

    Q: What causes a deadlock and how do I avoid it?

    A deadlock happens when two threads each hold a lock the other needs and both wait forever. The classic cause is inconsistent lock ordering: thread 1 locks A then B while thread 2 locks B then A. Avoid it by always locking mutexes in the same order everywhere, or by locking them together with std::lock or scoped_lock, which lock all of them atomically so no thread can be left half-holding.

    Q: When should I use std::atomic instead of a mutex?

    Use std::atomic for simple shared values like counters and flags, where each operation (increment, compare-and-swap) is a single indivisible step. It is lock-free and faster than a mutex for these cases. Reach for a mutex when you must protect a larger critical section — several variables that must change together, or a container like a queue — where one atomic operation is not enough.

    Q: Why does a condition_variable need a unique_lock and a predicate?

    condition_variable::wait must release the mutex while the thread sleeps and re-acquire it on wake, so it needs a lock it can unlock and relock — that is unique_lock, not lock_guard. The predicate (the lambda) protects you against spurious wake-ups: wait re-checks the condition and only returns when it is genuinely true, so you never proceed on a false alarm.

    Q: What is recursive_mutex and should I use it?

    A plain std::mutex deadlocks if the same thread locks it twice. recursive_mutex keeps a count so one thread can lock it multiple times and must unlock it the same number of times — useful when a locked function calls another locked function. It is usually a design smell, though: prefer restructuring so the lock is taken in exactly one place, and reach for recursive_mutex only when that is genuinely impractical.

    Mini-Challenge: Thread-Safe Bank Balance

    No blanks this time — just a brief and an outline. Build a shared balance that three threads deposit into safely, run it, and check your output against the expected total in the comments. This is exactly the pattern real concurrent code is built from.

    🎯 Mini-Challenge: build a thread-safe balance

    Protect a shared int across three depositing threads with a lock_guard.

    Try it Yourself »
    C++
    #include <iostream>
    #include <thread>
    #include <mutex>
    #include <vector>
    using namespace std;
    
    // 🎯 MINI-CHALLENGE: thread-safe bank balance
    // 1. Make a shared int "balance" = 0 and a global mutex "mtx".
    // 2. Write a function deposit(int amount) that loops 1000 times and,
    //    each time, locks mtx with a lock_guard and does balance += amount.
    // 3. In main(), start 3 threads all calling deposit(10), join them,
    //    then print "Balance: " << balance.
    //
    // ✅ Expected output:
    //    Balance: 3
    ...

    🎉 Lesson Complete

    • ✅ A std::mutex + RAII lock stops data races on shared data
    • lock_guard (simple) → unique_lock (flexible) → scoped_lock (multi-mutex)
    • ✅ Avoid deadlock with consistent lock ordering, std::lock, or scoped_lock
    • std::condition_variable lets threads wait/notify (producer/consumer)
    • std::atomic gives lock-free counters; recursive_mutex is a rare last resort
    • Next lesson: Atomic Operations — lock-free synchronisation for maximum performance

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service