std::mutex, lock_guard, unique_lock
When multiple threads access shared data and at least one of them writes, the program has a data race — and undefined behavior. A mutex (mutual exclusion lock) is the fundamental tool for preventing this: only one thread can hold the mutex at a time, so protected code runs as a critical section. C++ provides std::mutex for the lock itself and RAII wrappers (lock_guard, unique_lock, scoped_lock) that ensure the mutex is always released — even when exceptions are thrown.
The problem: data races
A data race occurs when two threads access the same memory location, at least one access is a write, and there is no synchronization between them. Data races are undefined behavior in C++: the result is not merely unpredictable, it is completely invalid — the compiler can assume data races don't exist and generate code that is incorrect in surprising ways.
// DATA RACE — undefined behavior:
int counter = 0;
std::thread t1([] { for (int i = 0; i < 1000; ++i) ++counter; });
std::thread t2([] { for (int i = 0; i < 1000; ++i) ++counter; });
t1.join(); t2.join();
// counter might be anything — increments from t1 and t2 interleave.
// ++counter is not atomic: it compiles to read-modify-write, three steps.
// Why it's UB, not just "unpredictable":
// The compiler may cache counter in a register, reorder the operations,
// or generate completely different code based on the assumption no race exists.std::mutex — mutual exclusion
std::mutex has three operations: lock() (blocks until the mutex is available then takes it), unlock() (releases it), and try_lock() (non-blocking; returns true if the lock was acquired). Call them directly only when necessary — RAII wrappers are almost always safer.
#include <mutex>
std::mutex mtx;
int counter = 0;
void safe_increment() {
mtx.lock();
++counter; // only one thread runs this at a time
mtx.unlock();
// BUG: if ++counter throws (it won't, but in general), unlock is never called
}
// Prefer RAII wrappers over raw lock/unlock.std::lock_guard — simple RAII locking
std::lock_guard<std::mutex> locks the mutex on construction and unlocks it when the object is destroyed. It cannot be unlocked early — use it when the lock should be held for the entire scope.
std::mutex mtx;
int counter = 0;
void safe_increment() {
std::lock_guard<std::mutex> guard(mtx); // locks here
++counter;
// unlocks here — destructor runs even if an exception is thrown
}
// C++17: deduction guides — no need to write the type:
std::lock_guard guard(mtx); // deduces std::lock_guard<std::mutex>
// Common pattern with shared state in a class:
class SafeCounter {
public:
void increment() {
std::lock_guard guard(mtx_);
++value_;
}
int get() const {
std::lock_guard guard(mtx_);
return value_;
}
private:
mutable std::mutex mtx_;
int value_ = 0;
};The mutex member is declared mutable so it can be locked inside const member functions — the lock protects the physical state, not the logical state.
std::unique_lock — flexible locking
std::unique_lock is a more flexible RAII wrapper: it can unlock and re-lock during its lifetime, defer initial locking, and work with condition variables. Use it when you need these capabilities; use lock_guard otherwise (simpler is better).
std::mutex mtx;
// Deferred locking — don't lock yet:
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... do some work that doesn't need the lock ...
lock.lock(); // lock now
// ... critical section ...
lock.unlock(); // unlock early if needed
// ... more non-critical work ...
// destructor unlocks again if still locked
// Try-lock:
if (lock.try_lock()) {
// acquired the lock
} else {
// couldn't get it — do something else
}
// Condition variables require unique_lock:
#include <condition_variable>
std::condition_variable cv;
bool data_ready = false;
// Consumer thread:
std::unique_lock lock(mtx);
cv.wait(lock, [] { return data_ready; }); // atomically releases lock, sleeps, re-acquires
// process data...
// Producer thread:
{
std::lock_guard guard(mtx);
data_ready = true;
}
cv.notify_one();std::scoped_lock (C++17) — lock multiple mutexes
Locking two mutexes in different orders in different threads causes deadlock — each thread holds one lock and waits for the other. std::scoped_lock (C++17) accepts multiple mutexes and uses a deadlock-avoidance algorithm to lock them all atomically.
std::mutex mtx_a, mtx_b; // DEADLOCK risk: if another thread locks b then a: // std::lock_guard ga(mtx_a); // std::lock_guard gb(mtx_b); // might wait forever // SAFE: scoped_lock locks both atomically (deadlock-avoidance algorithm): std::scoped_lock guard(mtx_a, mtx_b); // C++17, locks both safely // both mutexes are held here // both released when guard is destroyed
Key rules to remember
Always use RAII lock guards — never raw lock()/unlock()
If an exception is thrown between lock() and unlock(), the mutex is never released and all other threads block forever. lock_guard prevents this.
Minimize the critical section — lock only what you need, as briefly as possible
A wide lock serializes all threads through that section, negating the benefit of concurrency. Move computation outside the lock; only protect the shared state update.
Use scoped_lock to acquire multiple mutexes — avoid ordering them manually
Manually locking mutexes in inconsistent order across threads causes deadlock. scoped_lock uses a deadlock-avoidance algorithm to prevent this.
Use unique_lock (not lock_guard) for condition variable waits
cv.wait() needs to temporarily release the mutex while sleeping. lock_guard can't do this; unique_lock can.