Mutexes & Locks

Reviewed & published by Brayan K

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.

Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 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.

#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' goes out of scope at the end of each loop turn.
        lock_guard<mutex> guard(mtx);   // <- the talking stick
        counter++;                      // critical section: one thread at a time
    }
}

int main() {
    // Launch two threads that both hammer the same counter.
    thread t1(addOneMillion);
    thread t2(addOneMillion);

    t1.join();   // wait for t1 to finish
    t2.join();   // wait for t2 to finish

    // WITH the mutex this is always exactly 2,000,000.
    // WITHOUT it (remove the lock_guard) you get a smaller, random number
    // because counter++ is really read-modify-write and the steps interleave.
    cout << "Final counter: " << counter << endl;   // Final counter: 2000000
    return 0;
}

// ⚠️ No expected-output panel for this one, on purpose.
// Threads interleave differently on every run and on every machine, so
// there is no output to promise.
//
// One real run on the machine that builds this site printed:
//    Final counter: 2000000

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.

#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, 50000);
    a.join();
    b.join();

    cout << "Total: " << total << endl;

    // ✅ Expected output:
    //    Total: 100000
    return 0;
}

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.

#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 condition_variable, or move it around.
    unique_lock<mutex> u(mtxA, defer_lock);  // not locked yet
    cout << "unique_lock: doing setup without the lock..." << endl;
    u.lock();                                // lock only when needed
    cout << "unique_lock: now in the critical section" << endl;
    u.unlock();                              // and release early if you want
}

void useScopedLock() {
    // scoped_lock (C++17): locks SEVERAL mutexes at once, deadlock-free.
    // Internally it uses std::lock's ordering algorithm for you.
    scoped_lock both(mtxA, mtxB);
    cout << "scoped_lock: locked A and B together, no deadlock" << endl;
}

int main() {
    useLockGuard();
    useUniqueLock();
    useScopedLock();
    return 0;
}

// ⚠️ No expected-output panel for this one, on purpose.
// Threads interleave differently on every run and on every machine, so
// there is no output to promise.
//
// One real run on the machine that builds this site printed:
//    lock_guard: locked, will auto-unlock
//    unique_lock: doing setup without the lock...
//    unique_lock: now in the critical section
//    scoped_lock: locked A and B together, no deadlock

🔎 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.

#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(mtxA);   // always A first...
    lock_guard<mutex> b(mtxB);   // ...then B
    cout << "good1: locked in A->B order" << endl;
}

// ✅ FIX 2 — std::lock grabs both at once with no fixed order needed.
void good2() {
    lock(mtxA, mtxB);                          // atomically locks both
    lock_guard<mutex> a(mtxA, adopt_lock);     // adopt: "already locked"
    lock_guard<mutex> b(mtxB, adopt_lock);
    cout << "good2: std::lock + adopt_lock" << endl;
}

// ✅ FIX 3 — scoped_lock: the modern one-liner for the same thing.
void good3() {
    scoped_lock guard(mtxA, mtxB);             // C++17, deadlock-free
    cout << "good3: scoped_lock(A, B)" << endl;
}

int main() {
    thread t1(good1), t2(good2), t3(good3);
    t1.join(); t2.join(); t3.join();
    cout << "No deadlocks — all threads finished." << endl;
    return 0;
}

// ⚠️ No expected-output panel for this one, on purpose.
// Three runs of this agreed and the fourth did not, which is exactly how a
// threading bug hides. The lines always appear — their order does not.
//
// One real run on the machine that builds this site printed:
//    good1: locked in A->B order
//    good2: std::lock + adopt_lock
//    good3: scoped_lock(A, B)
//    No deadlocks — all threads finished.

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.

#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();              // wake one waiting consumer
    }
    {
        lock_guard<mutex> g(mtx);
        done = true;
    }
    cv.notify_all();                  // wake everyone to let them exit
}

void consumer() {
    while (true) {
        unique_lock<mutex> u(mtx);    // cv needs a unique_lock
        // wait() releases the lock and sleeps until the predicate is true,
        // then re-locks. The predicate guards against spurious wake-ups.
        cv.wait(u, [] { return !jobs.empty() || done; });

        while (!jobs.empty()) {
            cout << "  Consumed " << jobs.front() << endl;
            jobs.pop();
        }
        if (done) break;              // producer signalled "no more jobs"
    }
}

int main() {
    thread c(consumer);
    thread p(producer);
    p.join();
    c.join();
    cout << "All jobs handled." << endl;
    return 0;
}

// ⚠️ No expected-output panel for this one, on purpose.
// Threads interleave differently on every run and on every machine, so
// there is no output to promise.
//
// One real run on the machine that builds this site printed:
//    Produced 1
//    Produced 2
//    Produced 3
//    Produced 4
//    Produced 5
//      Consumed 1
//      Consumed 2
//      Consumed 3
//      Consumed 4
//      Consumed 5
//    All jobs handled.

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:

#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> threads;
    for (int i = 0; i < 4; i++) threads.emplace_back(clickManyTimes);
    for (auto& t : threads) t.join();

    cout << "Hits: " << hits << endl;

    // ✅ Expected output:
    //    Hits: 100000
    return 0;
}

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.

#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
    cout << "outer(): locked, now calling inner()" << endl;
    inner();                               // would deadlock with a plain mutex
}

int main() {
    outer();
    // Tip: needing recursive_mutex is often a smell — prefer splitting the
    // work so the lock is taken in ONE place. Reach for it only when you must.
    return 0;
}

// ✅ Expected output:
//    outer(): locked, now calling inner()
//      inner(): re-locked safely

Pro Tips

Common Errors (and the fix)

📋 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

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.

#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: 30000    (3 threads x 1000 x 10)

// your code here

int main() {
    // your code here
    return 0;
}

🎉 Lesson Complete

Practice quiz

Why does counter++ across two unsynchronised threads give a wrong, random total?

  • The compiler reorders the loop
  • Integers overflow
  • counter++ is read-modify-write, and the threads' steps interleave (a data race)
  • One thread always wins and the other does nothing

Answer: counter++ is read-modify-write, and the threads' steps interleave (a data race). counter++ is three steps (read, add, write); without a mutex the threads interleave those steps and lose updates.

What is the simplest RAII lock for one mutex and one scope?

  • std::lock_guard
  • std::unique_lock
  • std::scoped_lock
  • std::recursive_mutex

Answer: std::lock_guard. lock_guard locks on construction and unlocks at end of scope with no manual control — the default for a simple critical section.

Which lock can you manually unlock and relock, and hand to a condition_variable?

  • std::lock_guard
  • std::scoped_lock
  • std::mutex itself
  • std::unique_lock

Answer: std::unique_lock. unique_lock is flexible: defer locking, early unlock/relock, and it is what condition_variable::wait requires.

Which C++17 lock takes several mutexes at once with a deadlock-free algorithm?

  • std::lock_guard
  • std::scoped_lock
  • std::recursive_mutex
  • std::timed_mutex

Answer: std::scoped_lock. scoped_lock locks multiple mutexes together using std::lock's ordering, so you needn't reason about order yourself.

What is the classic cause of a deadlock?

  • Two threads locking the same mutexes in opposite order
  • Using a lock_guard instead of unique_lock
  • Locking a mutex only once
  • Calling notify_one too early

Answer: Two threads locking the same mutexes in opposite order. Inconsistent lock ordering — thread 1 locks A then B while thread 2 locks B then A — leaves each waiting on the other forever.

When using std::lock(mtxA, mtxB) to grab both atomically, how do the guards adopt them?

  • lock_guard<mutex> a(mtxA, defer_lock);
  • lock_guard<mutex> a(mtxA, try_lock);
  • lock_guard<mutex> a(mtxA, adopt_lock);
  • lock_guard<mutex> a(mtxA);

Answer: lock_guard<mutex> a(mtxA, adopt_lock);. After std::lock grabs both, you wrap each with adopt_lock so the guard takes ownership of an already-locked mutex.

Why does condition_variable::wait need a unique_lock rather than a lock_guard?

  • lock_guard is too slow
  • wait must release the mutex while sleeping and re-acquire it on wake
  • unique_lock is thread-safe and lock_guard is not
  • It does not — lock_guard works too

Answer: wait must release the mutex while sleeping and re-acquire it on wake. wait unlocks the mutex while the thread sleeps and relocks on wake, which only unique_lock supports.

What is the role of the predicate (lambda) in cv.wait(lock, pred)?

  • It chooses which thread to wake
  • It sets the timeout
  • It locks a second mutex
  • It guards against spurious wake-ups by re-checking the condition

Answer: It guards against spurious wake-ups by re-checking the condition. The predicate makes wait return only when the condition is genuinely true, protecting against false-alarm wake-ups.

When is std::atomic<int> preferable to a mutex?

  • When protecting a whole std::queue
  • For a single shared value like a counter or flag, where each op is indivisible
  • When several variables must change together
  • Never — atomics are always slower

Answer: For a single shared value like a counter or flag, where each op is indivisible. atomic suits single values (counter/flag) with indivisible ops; multi-variable or container updates still need a mutex.

What does std::recursive_mutex allow that a plain std::mutex does not?

  • Locking from multiple processes
  • Lock-free atomic increments
  • The same thread to lock it multiple times (and unlock the same number)
  • Automatic deadlock detection

Answer: The same thread to lock it multiple times (and unlock the same number). A plain mutex self-deadlocks if one thread locks it twice; recursive_mutex counts locks so re-locking by the same thread is allowed. It is usually a design smell.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.