Mutexes and Synchronization
2 min read
Mutexes and Synchronization
When multiple threads access shared data, you need synchronization to prevent data races. A data race — two threads accessing the same memory concurrently when at least one is writing — is undefined behavior in C++.
std::mutex
A mutex (mutual exclusion lock) ensures only one thread executes a protected section at a time:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment(int n) {
for (int i = 0; i < n; i++) {
mtx.lock();
counter++;
mtx.unlock();
}
}
int main() {
std::thread t1(increment, 100000);
std::thread t2(increment, 100000);
t1.join(); t2.join();
std::cout << counter << "\n"; // 200000 — safe
}
std::lock_guard
Manual lock()/unlock() is error-prone — an exception between them leaves the mutex locked forever. Use lock_guard instead (RAII for mutexes):
void increment_safe(int n) {
for (int i = 0; i < n; i++) {
std::lock_guard<std::mutex> lock(mtx); // locks on construction
counter++;
} // unlocks automatically here — even if an exception is thrown
}
C++17 allows template argument deduction: std::lock_guard lock(mtx).
std::unique_lock
unique_lock is more flexible than lock_guard — it can be unlocked and relocked, supports try_lock, and works with condition variables:
std::unique_lock<std::mutex> lock(mtx);
// ... do work under the lock ...
lock.unlock();
// ... do work without the lock ...
lock.lock();
// ... more work under the lock ...
std::condition_variable
A condition variable lets threads wait efficiently for a condition to become true:
#include <mutex>
#include <condition_variable>
#include <thread>
#include <queue>
#include <iostream>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> work_queue;
bool done = false;
void producer() {
for (int i = 0; i < 5; i++) {
{
std::lock_guard lock(mtx);
work_queue.push(i);
}
cv.notify_one();
}
{ std::lock_guard lock(mtx); done = true; }
cv.notify_all();
}
void consumer() {
while (true) {
std::unique_lock lock(mtx);
cv.wait(lock, []{ return !work_queue.empty() || done; });
while (!work_queue.empty()) {
std::cout << "processed: " << work_queue.front() << "\n";
work_queue.pop();
}
if (done) break;
}
}
int main() {
std::thread p(producer), c(consumer);
p.join(); c.join();
}
cv.wait(lock, predicate) atomically releases the lock and sleeps until notify_one() or notify_all() is called, then re-checks the predicate (to handle spurious wakeups).
std::atomic
For simple integer operations, atomics are faster than mutexes — no lock required:
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter{0};
void increment(int n) {
for (int i = 0; i < n; i++) counter++;
}
int main() {
std::thread t1(increment, 100000);
std::thread t2(increment, 100000);
t1.join(); t2.join();
std::cout << counter << "\n"; // 200000
}
std::atomic<T> works for basic types (int, bool, pointers). For complex operations, you still need a mutex.
Avoiding deadlocks
A deadlock occurs when two threads each hold a lock the other needs. Rules to prevent it:
- Always acquire multiple locks in the same order across all threads.
- Use
std::lock(m1, m2)orstd::scoped_lock(m1, m2)(C++17) to lock multiple mutexes atomically. - Keep critical sections short.
Learn modern C++ through working code. Each chapter introduces one concept — variables, functions, classes, templates, smart pointers, concurrency — with a clear example, a line-by-line explanation, and notes on how it applies in real programs.
View book →