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
std::thread and calling .join(). Everything about protecting shared data, we'll build here.💡 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.
#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.
#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.
#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.
#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.
#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.
#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.
#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 baremtx.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+ twoadopt_lockguards 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 alock_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) beforemtx.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
mtxcalls another that also locksmtx→ self-deadlock. Restructure, or userecursive_mutexas 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 |
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.
#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, orscoped_lock - ✅
std::condition_variablelets threads wait/notify (producer/consumer) - ✅
std::atomicgives lock-free counters;recursive_mutexis 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.