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
- 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.
#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: 2000000Your 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 safelyPro 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
| Task | Code | Notes |
|---|---|---|
| Simple RAII lock | lock_guard<mutex> g(m); | Default choice |
| Flexible lock | unique_lock<mutex> u(m); | For condition vars / early unlock |
| Lock many mutexes | scoped_lock g(a, b); | C++17, deadlock-free |
| Lock both atomically | lock(a, b); | Then adopt_lock guards |
| Wait for a condition | cv.wait(u, pred); | Needs a unique_lock |
| Wake a waiter | cv.notify_one(); | Or notify_all() |
| Lock-free counter | atomic<int> n{0}; | n++ is indivisible |
| Re-lockable mutex | recursive_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
- ✅ 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
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
- Previous: Concurrency in C++: Threads, Futures, Promises, async()
- Next: Atomic Operations & Low-Level Synchronization — Use std::atomic and memory ordering for lock-free concurrent data
- Quick reference: C++ cheat sheet
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.