C++ Mutexes & Synchronization: lock_guard, unique_lock Guide 2026
Table of Contents
Why Mutexes Exist
When multiple threads access the same data and at least one modifies it, you get a data race — undefined behavior in C++. A mutex (mutual exclusion) solves this by allowing only one thread to access the protected data at a time. The thread that locks the mutex “owns” it; other threads trying to lock it will block until the owner unlocks it.
Think of a mutex like a bathroom lock. Only one person can be inside at a time. Others wait at the door. When you’re done, you unlock and the next person enters. Simple concept, but the details of using mutexes correctly in C++ require careful attention.
std::mutex Basics
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int shared_counter = 0;
void safe_increment(int times) {
for (int i = 0; i < times; ++i) {
mtx.lock(); // Acquire the lock
++shared_counter; // Only one thread at a time here
mtx.unlock(); // Release the lock
}
}
int main() {
std::thread t1(safe_increment, 100000);
std::thread t2(safe_increment, 100000);
t1.join();
t2.join();
std::cout << "Counter: " << shared_counter << "
";
// Always 200000 — no data race!
}
Manual lock()/unlock() works but is dangerous — if an exception is thrown between them, the mutex stays locked forever (deadlock). Always use RAII wrappers instead.
std::lock_guard — RAII Locking
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
class ThreadSafeCounter {
mutable std::mutex mtx_;
int count_ = 0;
public:
void increment() {
std::lock_guard<std::mutex> lock(mtx_);
++count_;
// lock released automatically when 'lock' goes out of scope
// Even if an exception is thrown!
}
int get() const {
std::lock_guard<std::mutex> lock(mtx_);
return count_;
}
};
int main() {
ThreadSafeCounter counter;
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 10000; ++j) {
counter.increment();
}
});
}
for (auto& t : threads) t.join();
std::cout << "Counter: " << counter.get() << "
"; // 100000
}
std::lock_guard locks the mutex in its constructor and unlocks in its destructor. This RAII pattern is the standard way to use mutexes in C++. You cannot forget to unlock, and exceptions are handled safely.
std::unique_lock — Flexible Locking
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex mtx;
void demo_unique_lock() {
// Deferred locking — create without locking
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... do non-critical work ...
lock.lock(); // Lock when ready
// ... critical section ...
lock.unlock(); // Unlock early if needed
// ... more non-critical work ...
// Try-lock: non-blocking attempt
std::unique_lock<std::mutex> lock2(mtx, std::try_to_lock);
if (lock2.owns_lock()) {
std::cout << "Got the lock!
";
} else {
std::cout << "Lock was busy
";
}
// Timed lock: wait up to N milliseconds
std::timed_mutex tmtx;
std::unique_lock<std::timed_mutex> lock3(tmtx, std::chrono::milliseconds(100));
if (lock3.owns_lock()) {
std::cout << "Got timed lock
";
}
}
// unique_lock can be moved (lock_guard cannot)
std::unique_lock<std::mutex> get_lock() {
std::unique_lock<std::mutex> lock(mtx);
return lock; // Transfer ownership
}
int main() {
demo_unique_lock();
auto lock = get_lock();
// lock holds the mutex
}
std::scoped_lock — Multiple Mutexes
#include <iostream>
#include <thread>
#include <mutex>
struct Account {
std::mutex mtx;
double balance;
std::string name;
};
// Transfer money between accounts — needs BOTH locks
void transfer(Account& from, Account& to, double amount) {
// scoped_lock locks BOTH mutexes atomically — no deadlock!
std::scoped_lock lock(from.mtx, to.mtx);
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
std::cout << "Transferred $" << amount
<< " from " << from.name << " to " << to.name << "
";
}
}
int main() {
Account alice{.balance = 1000, .name = "Alice"};
Account bob{.balance = 500, .name = "Bob"};
std::thread t1(transfer, std::ref(alice), std::ref(bob), 200);
std::thread t2(transfer, std::ref(bob), std::ref(alice), 100);
t1.join();
t2.join();
std::cout << alice.name << ": $" << alice.balance << "
";
std::cout << bob.name << ": $" << bob.balance << "
";
}
std::shared_mutex — Reader-Writer Lock
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <map>
#include <string>
#include <vector>
class ThreadSafeCache {
mutable std::shared_mutex mtx_;
std::map<std::string, std::string> data_;
public:
// Multiple threads can read simultaneously
std::string get(const std::string& key) const {
std::shared_lock lock(mtx_); // Shared (read) lock
auto it = data_.find(key);
return (it != data_.end()) ? it->second : "";
}
// Only one thread can write at a time
void set(const std::string& key, const std::string& value) {
std::unique_lock lock(mtx_); // Exclusive (write) lock
data_[key] = value;
}
size_t size() const {
std::shared_lock lock(mtx_);
return data_.size();
}
};
int main() {
ThreadSafeCache cache;
cache.set("key1", "value1");
// Multiple readers can run concurrently
std::vector<std::thread> readers;
for (int i = 0; i < 5; ++i) {
readers.emplace_back([&cache]() {
for (int j = 0; j < 1000; ++j) {
cache.get("key1");
}
});
}
for (auto& t : readers) t.join();
std::cout << "Cache size: " << cache.size() << "
";
}
std::condition_variable
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void worker() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return ready; }); // Wait until ready is true
std::cout << "Worker: data is ready!
";
}
void setter() {
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one(); // Wake up one waiting thread
}
int main() {
std::thread t1(worker);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::thread t2(setter);
t1.join();
t2.join();
}
Producer-Consumer Pattern
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
template<typename T>
class ThreadSafeQueue {
std::queue<T> queue_;
mutable std::mutex mtx_;
std::condition_variable cv_;
public:
void push(T value) {
{
std::lock_guard lock(mtx_);
queue_.push(std::move(value));
}
cv_.notify_one();
}
T pop() {
std::unique_lock lock(mtx_);
cv_.wait(lock, [this] { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
return value;
}
bool empty() const {
std::lock_guard lock(mtx_);
return queue_.empty();
}
};
int main() {
ThreadSafeQueue<int> queue;
// Producer
std::thread producer([&queue]() {
for (int i = 0; i < 10; ++i) {
queue.push(i);
std::cout << "Produced: " << i << "
";
}
});
// Consumer
std::thread consumer([&queue]() {
for (int i = 0; i < 10; ++i) {
int val = queue.pop();
std::cout << "Consumed: " << val << "
";
}
});
producer.join();
consumer.join();
}
Deadlocks — Detection and Prevention
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx_a, mtx_b;
// DEADLOCK: Thread 1 locks A then B, Thread 2 locks B then A
void deadlock_thread1() {
std::lock_guard lock_a(mtx_a); // Lock A
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::lock_guard lock_b(mtx_b); // Wait for B (held by thread2)
}
void deadlock_thread2() {
std::lock_guard lock_b(mtx_b); // Lock B
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::lock_guard lock_a(mtx_a); // Wait for A (held by thread1)
// DEADLOCK: both threads wait forever
}
// FIX 1: Consistent lock ordering (always lock A before B)
// FIX 2: std::scoped_lock (recommended)
void safe_thread1() {
std::scoped_lock lock(mtx_a, mtx_b); // Lock both atomically
std::cout << "Thread 1 got both locks
";
}
void safe_thread2() {
std::scoped_lock lock(mtx_a, mtx_b); // Same order doesn't matter
std::cout << "Thread 2 got both locks
";
}
// FIX 3: Try-lock with timeout
void try_lock_approach() {
std::unique_lock lock_a(mtx_a, std::defer_lock);
std::unique_lock lock_b(mtx_b, std::defer_lock);
std::lock(lock_a, lock_b); // std::lock avoids deadlock
}
std::atomic for Simple Types
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
int main() {
std::atomic<int> counter{0};
std::atomic<bool> flag{false};
// Atomic operations — no mutex needed
counter.fetch_add(1); // counter += 1
counter.fetch_sub(1); // counter -= 1
counter.store(42); // counter = 42
int val = counter.load(); // read counter
// Compare-and-swap (CAS)
int expected = 42;
counter.compare_exchange_strong(expected, 100);
// If counter == 42, set to 100. Otherwise, expected = counter's value.
// Atomic flag — simplest atomic type
std::atomic_flag spinlock = ATOMIC_FLAG_INIT;
// spinlock.test_and_set(); // Set and return previous value
// spinlock.clear(); // Reset
// Use atomics for simple counters and flags
// Use mutexes for complex data structures
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 10000; ++j) ++counter;
});
}
for (auto& t : threads) t.join();
std::cout << "Counter: " << counter << "
"; // 100042
}
Thread-Safe Data Structures
#include <mutex>
#include <vector>
#include <algorithm>
template<typename T>
class ThreadSafeVector {
std::vector<T> data_;
mutable std::mutex mtx_;
public:
void push_back(T value) {
std::lock_guard lock(mtx_);
data_.push_back(std::move(value));
}
size_t size() const {
std::lock_guard lock(mtx_);
return data_.size();
}
// Return a copy — safe but potentially expensive
std::vector<T> snapshot() const {
std::lock_guard lock(mtx_);
return data_;
}
// Apply function under lock — avoids copying
template<typename Func>
void with_lock(Func f) {
std::lock_guard lock(mtx_);
f(data_);
}
};
Common Mistakes
// MISTAKE 1: Locking too much (performance killer)
void over_locked(std::mutex& m, std::vector<int>& v) {
std::lock_guard lock(m);
// Don't do expensive work under the lock!
auto result = expensive_computation(); // Other threads blocked
v.push_back(result);
}
// Fix: compute outside the lock, only lock for the shared access
void better(std::mutex& m, std::vector<int>& v) {
auto result = expensive_computation();
std::lock_guard lock(m);
v.push_back(result);
}
// MISTAKE 2: Returning reference to protected data
// The reference outlives the lock — caller can use it unsafely
// MISTAKE 3: Recursive locking with std::mutex
// std::mutex::lock() on an already-locked mutex = undefined behavior
// Use std::recursive_mutex if you must lock recursively
Practice Exercises
Exercise 1: Implement a thread-safe stack with push(), pop(), and empty() using std::mutex and std::lock_guard.
Exercise 2: Create a reader-writer cache using std::shared_mutex. Have 5 reader threads and 2 writer threads. Measure throughput compared to using a regular mutex.
Exercise 3: Implement a thread pool that maintains a queue of tasks (functions). Worker threads pull tasks from the queue using a condition variable.
Exercise 4: Write a program that demonstrates and then fixes a deadlock. Use two bank accounts and two threads performing transfers in opposite directions.
Mutexes and synchronization primitives are the foundation of safe multithreaded programming. Combined with threads and the async/future model (next lesson), you have all the tools to write correct concurrent C++.