Mutexes and RAII-based locking
Why does data break when several threads touch it at once? The mutex that prevents race conditions, exception-safe RAII locking (lock_guard and unique_lock), and shared_mutex, which distinguishes reads from writes.
When several threads touch the same data at once, the program breaks quietly. This post starts from the most basic tool for preventing that — the mutex — and works through the C++ way of handling it safely with RAII locking, and on to shared_mutex, which distinguishes reads from writes.
Why you need a mutex: race conditions
Suppose two threads run the following code at the same time.
int counter = 0;
void increment() {
++counter; // dangerous
}
++counter is one line, but to the machine it is three steps: read, add one, write. If two threads interleave those steps, both can read the same value and write the same value, losing an entire increment. A problem like this, where the result depends on the order in which threads execute, is called a race condition.
The fix is to make sure “only one thread enters this region at a time”. That protected region is called a critical section, and the tool that enforces it is the mutex.
How a mutex works
A mutex (mutual exclusion) is a device only one thread can hold the lock on at a time. Its core operations are just lock and unlock.
#include <mutex>
int counter = 0;
std::mutex mtx;
void increment() {
mtx.lock();
++counter; // critical section
mtx.unlock();
}
Once one thread takes lock(), other threads block at lock() until that thread calls unlock(). Letting only one thread at a time through the critical section is how race conditions are prevented.
The trap of manual lock/unlock
The code above has a fragile part. If an exception is thrown between lock() and unlock(), or the function returns in between, unlock() never runs.
void process() {
mtx.lock();
doSomething(); // what if this throws?
mtx.unlock(); // this line is never reached -> deadlock
}
An un-unlocked mutex stays locked forever, and every thread that later tries to take it stops permanently. Unlock has to be guaranteed on every exit path — not just the normal one but exceptions and early returns too — and doing it by hand means you will always miss one somewhere.
RAII: tying cleanup to object lifetime
The C++ answer is RAII (Resource Acquisition Is Initialization). The idea is simple: tie a resource’s lifetime to an object’s lifetime. Acquire the resource in the constructor and release it in the destructor, and the moment the object leaves scope the destructor runs automatically and the resource is definitely cleaned up.
However you leave the scope — normal completion, return, or an exception — the stack unwinds and the destructor is guaranteed to be called. There is simply no way to forget the cleanup code.
The standard tool that applies this to a mutex is std::lock_guard.
void process() {
std::lock_guard<std::mutex> lock(mtx); // constructor: mtx.lock()
doSomething(); // even if this throws
} // destructor: mtx.unlock() guaranteed
lock_guard does not replace the mutex; it is an automatic handle that wraps the mutex and opens and closes it safely. It locks the mtx passed to its constructor on construction and unlocks it on destruction.
It serves the same purpose as try/finally in other languages.
// Java
lock.lock();
try {
doSomething();
} finally {
lock.unlock(); // written out by hand at every use site
}
The difference is who writes the cleanup code. try/finally repeats the cleanup everywhere the resource is used, and a missed one leaks. With RAII the cleanup logic is encapsulated in the type (the destructor), so the user cannot forget it. Even with several resources you just declare the objects side by side, and destruction happens automatically in reverse declaration order. This is why C++ has no finally keyword. The destructor does that job.
RAII is not mutex-specific. std::unique_ptr (memory), std::fstream (files), and std::vector (dynamic arrays) all work on the same principle.
lock_guard and unique_lock
There are two RAII locking wrappers: std::lock_guard and std::unique_lock. Both manage a mutex through RAII, but they differ in flexibility and cost.
lock_guard is simple. Lock on construction, unlock on destruction, that is all. You cannot release it partway through or transfer ownership. In exchange it has essentially no overhead. For the common case of just locking for the duration of a scope, it is the right answer.
unique_lock carries internal state — “am I currently holding the lock?” — so it can do more. Managing that state costs a little.
Manually releasing and retaking:
std::unique_lock<std::mutex> lock(mtx);
// critical section
lock.unlock();
// heavy work that can be done without the lock
lock.lock();
// critical section again
Deferred locking (not locking at construction):
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... other preparation ...
lock.lock();
Beyond that, ownership can be moved, so a function can return or hand off a lock, and — most importantly — using it with a condition_variable requires unique_lock (covered in the next part).
The rule of thumb is simple. Default to lock_guard. Use unique_lock only when you need to release partway, need deferred locking or moving, or are using a condition variable. Using unique_lock where you do not need it just adds the cost of managing that state.
If you need to lock several mutexes at once, use C++17’s std::scoped_lock. It locks them all safely in a deadlock-avoiding order.
Distinguishing reads from writes: shared_mutex
An ordinary mutex lets exactly one thread through at a time, read or write. But threads that only read can safely access the data concurrently, because nobody is changing it. The only problem is when somebody writes.
Picture a configuration value updated occasionally while countless threads read it constantly. An ordinary mutex needlessly queues the readers up against each other. std::shared_mutex (C++17) removes that waste by providing two locking modes.
- Shared (read) lock: can be held by many threads at once. Read-only.
- Exclusive (write) lock: held by one thread only. For writing. While it is held, every other read and write waits.
The rule is “readers coexist, writers monopolise”.
The mode is decided by which wrapper you use on the same shared_mutex: std::shared_lock for reads, std::unique_lock (or lock_guard) for writes.
#include <shared_mutex>
std::shared_mutex mtx;
int data = 0;
// read: shared_lock -> several threads can enter at once
int read() {
std::shared_lock<std::shared_mutex> lock(mtx);
return data;
}
// write: unique_lock -> exclusive
void write(int v) {
std::unique_lock<std::shared_mutex> lock(mtx);
data = v;
}
So shared_lock is “the RAII wrapper that takes a shared_mutex in read mode”. It is the same RAII flow as before, with only the target mutex and the locking mode changed.
It is not free, though. shared_mutex has complex internal state, so a single lock/unlock costs more than with an ordinary mutex. It pays off only when reads vastly outnumber writes and the critical section is long enough for concurrent reading to be worth something. When the read/write ratio is close to even or the critical section is very short, a plain mutex is often faster. Depending on the implementation you can also get writer starvation, where a steady stream of readers keeps pushing writes back, so sometimes you need to check the priority policy.
Summary
- Race condition: the corruption that results when several threads touch shared data at once. A mutex prevents it by letting one thread at a time through the critical section.
- Manual lock/unlock: easily skips unlock on exceptions and early returns, producing deadlock. Do not use it.
- RAII: ties cleanup to an object’s destructor so it runs automatically on scope exit. Same purpose as
try/finally, but the user cannot forget it. - lock_guard: the default. Light and simple.
- unique_lock: only when you need manual release, deferral, moving, or a condition variable.
- scoped_lock: several mutexes at once, without deadlock.
- shared_mutex + shared_lock: reads concurrently, writes exclusively. Use when reads overwhelmingly dominate.
Mutexes address “how do you safely lock a shared resource?” The next part goes one step further: “how do you efficiently make a thread wait and then wake it?” — that is, condition_variable and synchronisation patterns.