Mutexes, Locks & Critical Sections
Master synchronization primitives: mutex, lock_guard, unique_lock, scoped_lock, shared_mutex. Learn to protect shared data, prevent deadlocks, and understand the difference between data races and race conditions.
Why Synchronization Matters
When multiple threads access the same memory and at least one of them writes, you have a data race — a form of undefined behavior in C++. The standard does not merely say "the result is unpredictable." It says *anything can happen*: corrupted data, crashes, security vulnerabilities, or seemingly correct behavior that breaks when you change compiler flags.
A mutex (mutual exclusion) is the fundamental synchronization primitive. Only one thread can hold a mutex's lock at a time. Any other thread that tries to lock it will block until the mutex is released. By protecting shared data with a mutex, you serialize access and eliminate data races.
std::mutex and lock_guard
The simplest and safest pattern is std::lock_guard — an RAII wrapper that locks a mutex on construction and unlocks it on destruction, even if an exception is thrown.
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx;
int shared_counter = 0;
void increment(int times) {
for (int i = 0; i < times; ++i) {
std::lock_guard<std::mutex> lock(mtx); // RAII lock
++shared_counter; // safe: only one thread at a time
} // lock released here automatically
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment, 10000);
}
for (auto& t : threads) {
t.join();
}
std::cout << "Counter: " << shared_counter << "\n";
// Always prints 100000 — no data race
return 0;
}unique_lock — Flexible Locking
std::unique_lock is more flexible than lock_guard. It supports deferred locking, timed locking, manual lock/unlock, and is required by std::condition_variable.
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex mtx;
void try_lock_example() {
// Deferred locking — don't lock yet
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// Try to lock with a timeout
if (lock.try_lock_for(std::chrono::milliseconds(100))) {
std::cout << "Acquired lock\n";
// ... do work ...
} else {
std::cout << "Could not acquire lock within 100ms\n";
}
// Automatically unlocks when lock goes out of scope
}
void manual_control() {
std::unique_lock<std::mutex> lock(mtx);
// ... critical section ...
lock.unlock(); // release early if needed
// ... non-critical work ...
lock.lock(); // re-acquire
// ... another critical section ...
}scoped_lock — Deadlock-Free Multi-Lock (C++17)
When you need to lock multiple mutexes simultaneously, std::scoped_lock (C++17) uses a deadlock-avoidance algorithm internally. It replaces the error-prone manual use of std::lock.
#include <mutex>
#include <thread>
#include <iostream>
struct BankAccount {
std::mutex mtx;
double balance = 1000.0;
};
void transfer(BankAccount& from, BankAccount& to, double amount) {
// Lock both mutexes without risk of deadlock
std::scoped_lock lock(from.mtx, to.mtx);
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
std::cout << "Transferred " << amount << "\n";
}
} // both mutexes released
int main() {
BankAccount alice, bob;
// These two transfers lock accounts in different order,
// but scoped_lock prevents deadlock.
std::thread t1(transfer, std::ref(alice), std::ref(bob), 100.0);
std::thread t2(transfer, std::ref(bob), std::ref(alice), 50.0);
t1.join();
t2.join();
std::cout << "Alice: " << alice.balance
<< ", Bob: " << bob.balance << "\n";
return 0;
}shared_mutex — Reader-Writer Lock (C++17)
When reads greatly outnumber writes, std::shared_mutex allows multiple concurrent readers but exclusive writer access. Use std::shared_lock for readers and std::unique_lock for writers.
#include <shared_mutex>
#include <mutex>
#include <thread>
#include <iostream>
#include <map>
#include <string>
class ThreadSafeCache {
mutable std::shared_mutex smtx_;
std::map<std::string, int> data_;
public:
int read(const std::string& key) const {
std::shared_lock lock(smtx_); // multiple readers OK
auto it = data_.find(key);
return it != data_.end() ? it->second : -1;
}
void write(const std::string& key, int value) {
std::unique_lock lock(smtx_); // exclusive access
data_[key] = value;
}
};A data race occurs when two threads access the same memory location concurrently and at least one writes, with no synchronization. In C++ this is undefined behavior — not merely "nondeterministic output" but anything-can-happen UB.
A race condition is a higher-level logic bug where the program's correctness depends on timing. Even code free of data races can have race conditions (e.g., check-then-act on a concurrent map). Mutexes eliminate data races; eliminating race conditions requires careful design of your concurrent algorithms.
Always use RAII lock wrappers (lock_guard, scoped_lock, unique_lock) instead of raw mutex::lock()/unlock() calls. Keep critical sections as short as possible — never perform I/O, allocations, or blocking calls while holding a lock if you can avoid it. When you need multiple mutexes, use std::scoped_lock to avoid deadlocks. If you must lock manually, always lock in a consistent global order.
- A data race is undefined behavior — use a mutex to protect every shared mutable variable
std::lock_guardis the simplest RAII lock; prefer it for straightforward critical sectionsstd::unique_lockadds flexibility: deferred locking, timed locking, and manual lock/unlockstd::scoped_lock(C++17) locks multiple mutexes simultaneously without deadlockstd::shared_mutex(C++17) allows multiple concurrent readers but exclusive writers- Keep critical sections short and never hold a lock while waiting on another thread
Quiz — Test Your Knowledge
(20 XP)1. What is the main advantage of `std::scoped_lock` over manually calling `lock()` on multiple mutexes?
2. Which lock type should you use with `std::condition_variable`?
3. In C++, what is the consequence of a data race?