C++ Part I · Module 26 · Concurrency Fundamentals
std::mutex and Lock Guards
A mutex removes the third clause of Chapter 26.3's definition: it orders the accesses, so there is no race. That is the whole of what it does and it is enough for almost everything an engineer needs, which is why this is the one concurrency primitive worth knowing properly rather than approximately. The chapter has two halves. The first is a discipline, and it is not negotiable: a mutex is acquired and released by an RAII guard, never by paired calls, for exactly the reason Chapter 22.4 established for every other resource — a lock released by a statement at the end of a function is not released when the function returns early, and is not released when an exception unwinds through it, and a mutex that is never released is a program that stops. The second half is the cost, because a mutex is cheap enough that most people over-estimate it and expensive enough that the thing you put inside it matters. Both numbers are measured here, along with what happens to them when more than one thread wants the lock at the same time.
Advanced19 min readC++Part IFundamentalsConcurrency
Part I · Module 26 · Chapter 26.4 · std::mutex and Lock Guards
1. What It Does
#include <mutex>
class Counters {
public:
void record(int unit)
{
std::lock_guard<std::mutex> guard{mutex_};
++perUnit_[unit];
++total_;
}
private:
std::mutex mutex_;
std::map<int, long> perUnit_;
long total_ = 0;
};A std::mutex is a lock that at most one thread holds at a time. A thread calling lock() when another holds it blocks until the holder calls unlock().
That gives you two things, and the second is easy to overlook.
Mutual exclusion, which is what the name says: the code between the lock and the unlock — the critical section — runs with no other thread inside it.
An ordering, which is what makes it remove the race. Everything a thread did before releasing the mutex happens-before everything another thread does after acquiring it. So the data you wrote inside the critical section is visible to the next holder, with no further ceremony. Chapter 26.8 §5 is the formal version; the practical consequence is that a mutex protects the data, not merely the code.
The frozen code sample for this chapter is the line that matters:
std::lock_guard<std::mutex> guard{mutex_};Constructing the guard locks; destroying it unlocks. There is no unlock() in correct code.
2. Why the Guard Is Not a Convenience
The version without a guard is wrong in three ways, and only the first is obvious.
void record(int unit)
{
mutex_.lock();
if (unit < 0) return; // 1. returns holding the lock -- FOREVER
++perUnit_[unit]; // 2. may throw (allocation) -- unwinds holding it
++total_;
mutex_.unlock();
}An early return skips the unlock. The mutex is now permanently held by a thread that has moved on, and the next thread to want it blocks forever. This is not a crash and not a wrong answer; the program simply stops, which is the hardest failure to diagnose because there is no error to search for.
An exception skips the unlock too. ++perUnit_[unit] on a std::map allocates a node, and allocation can throw. Chapter 22.4 §2 measured exactly this shape for a file handle: the manual version gave opened=1 closed=0 LEAKED=1 on the throwing path while the RAII version gave closed=1. A mutex is the same resource problem with a worse failure mode, because a leaked file descriptor costs you a descriptor and a leaked mutex costs you the program.
And you cannot fix it by being careful, because a function that is correct today acquires an early return next month. The guard makes the property structural rather than remembered — Chapter 22.4 §8's argument.
The guard's release runs during unwinding for the reason Chapter 22.3 §2 measured: destructors of automatic objects run as an exception propagates, in reverse construction order, in frames that know nothing about the exception.
3. The Cost
100,000 increments of one counter, four ways. Each figure is a minimum of 31 repetitions within a run, reported as the median over eleven whole-process runs:
| how | ns per operation | range | relative |
|---|---|---|---|
plain ++counter (racy, with a barrier) | 0.28 | 0.27–0.32 | 1.00× |
atomic fetch_add, relaxed | 1.88 | 1.88–1.93 | 6.71× |
atomic fetch_add, seq_cst | 1.88 | 1.88–1.93 | 6.71× |
mutex + lock_guard | 4.57 | 4.57–5.09 | 16.32× |
A lock and an unlock together cost about 4.6 nanoseconds when nobody else wants the lock. That is roughly sixteen plain increments, or two and a half atomic increments.
Here is a single run of the same program, captured verbatim, and it is worth a paragraph of its own:
C. incrementing a counter 100000 times, four ways
plain ++counter (no safety) : 0.28 ns/op
atomic fetch_add, relaxed : 1.92 ns/op (7.0x)
atomic fetch_add, seq_cst : 2.18 ns/op (7.9x)
mutex + lock_guard : 5.17 ns/op (18.8x)Read that run on its own and you would conclude that seq_cst costs about 13% more than relaxed, and you would write it down, and you would be wrong. Over eleven runs the two rows have the same median and the same range — 1.88 ns, 1.88–1.93 — so there is no measurable difference between them at all on this machine, and the gap in that one run is noise. Its seq_cst figure of 2.18 ns is larger than the largest of all eleven later runs; its mutex figure of 5.17 ns likewise sits above the 4.57–5.09 range.
That is the whole argument for medians and ranges in one screenshot. A single run is a sample from a distribution you have not characterised yet, and the difference you are excited about may be entirely the distribution's width. Chapter 26.8 §4 returns to why relaxed and seq_cst cost the same here even though the compiler emits genuinely different instructions for them.
Two readings of that, and both matter.
It is cheap in absolute terms. Four and a half nanoseconds is about a dozen cycles. For anything that does real work inside the critical section — a map insertion, a string append, any I/O — the lock is a rounding error, and the common fear of mutex overhead is misplaced.
It is expensive relative to the thing people most often protect. If the critical section is ++counter, the lock costs sixteen times the work it protects. That is the case where std::atomic is the right answer, and Chapter 26.7 §3 is the comparison.
For scale, Chapter 26.2 §7's figure: starting and joining a thread is about 14.4 µs, which is roughly three thousand mutex locks. The primitives in this chapter are not where your time goes.
4. What Contention Costs
The uncontended number is the easy one. The contended number needs a better experiment than the obvious one, and the detour is worth taking because it is the same trap you will fall into when you measure your own system.
The obvious experiment does not work. Run N threads hammering one mutex, print nanoseconds per increment, read off the curve. I ran exactly that and it gave 7.7 / 9.3 / 12.1 / 13.3 ns at 1, 2, 4 and 8 threads on a busy machine, and 4.8 / 6.7 / 6.3 / 7.2 ns on a quieter one — different numbers, and the second set is not even monotonic, with four threads apparently cheaper than two. The reason is the one Chapter 26.1 §1 gives for its own ceiling: an eight-thread benchmark needs eight free cores, and a machine with anything else running does not have them — on this one, four of the eight are efficiency cores to begin with. An absolute multi-threaded timing on a loaded machine measures the load.
So measure a ratio instead, with both arms paying the same ambient cost. Two arms, same thread count, back to back in one process:
- shared — N threads, one mutex, one counter. Contended.
- sharded — N threads, N mutexes and N counters, each padded to its own cache line. The identical work, uncontended.
Whatever the rest of the machine is doing inflates both arms, so dividing one by the other cancels it. Median of seven runs, each arm a minimum of 15 repetitions:
| threads | shared (ns/op) | sharded (ns/op) | contention cost |
|---|---|---|---|
| 1 | 4.81 [4.78–4.85] | 4.80 [4.72–4.87] | 1.00× [0.99–1.01] |
| 2 | 7.55 [5.52–7.94] | 2.45 [2.44–2.52] | 3.09× [2.24–3.15] |
| 4 | 13.87 [9.28–14.49] | 1.80 [1.77–1.95] | 7.59× [5.17–8.05] |
| 8 | 12.87 [7.38–14.36] | 1.20 [1.03–1.34] | 10.99× [5.49–11.95] |
One run of that harness, verbatim — the table above is the median of seven such runs, so no single run reproduces it exactly, and the ratio column is the part that holds steady:
threads shared(ns) sharded(ns) ratio
1 4.78 4.73 1.01x
2 7.36 2.45 3.01x
4 14.17 1.64 8.65x
8 12.36 1.03 12.05xFour things to read off it.
The single-thread row is the control, and it reads 1.00× [0.99–1.01]. With one thread the two arms are the same situation — one thread, one mutex — so anything other than 1.00 would have meant the harness was biased. It is the check that makes the other three rows worth believing, and it is the step most benchmarks skip.
Contention costs about 3× at two threads, 7.6× at four, and roughly 11× at eight. The ranges separate cleanly for 1 against 2 and 2 against 4. They do not separate for 4 against 8 — [5.17–8.05] overlaps [5.49–11.95] — so the honest claim is "about an order of magnitude by eight threads", not a precise 11.0.
The sharded column is the fix, measured. Per-operation cost falls from 4.80 ns at one thread to 1.20 ns at eight, because eight threads on eight private mutexes actually run in parallel. Sharding did not shave a few percent off the lock; it turned the operation into a quarter of its single-threaded cost.
And the shared column barely improves at all — 4.81 ns at one thread, 12.87 at eight, with eight times the work to do. The mutex is serialising the increments and charging for the privilege. Chapter 26.1 §3 measured the same shape from the other direction, as a 0.13× speedup on eight threads for a single shared atomic counter.
The mechanism is cache coherence rather than the mutex's own logic. The lock itself is a word that every contending core must own exclusively to modify, so it moves between cores on every acquisition, and the protected counter does the same. At eight threads the cores spend their time transferring two cache lines back and forth.
The practical rule: contention is a design problem, not a tuning problem. The answers are to shard the state so threads use different mutexes, to batch so each acquisition does more work, or not to share at all — Chapter 26.3 §8's first option, which removes both the race and the contention.
5. The Guards, and When Each Is Right
| type | since | use it for |
|---|---|---|
std::lock_guard | C++11 | the default — lock on construction, unlock on destruction, nothing else |
std::scoped_lock | C++17 | two or more mutexes at once, deadlock-free — Chapter 26.5 §4 |
std::unique_lock | C++11 | when you need to unlock early, relock, or hand the lock to a condition variable |
std::shared_lock | C++14 | a reader's share of a std::shared_mutex |
Three notes on choosing.
lock_guard is the default because it does the least. It cannot be unlocked early, moved, or deferred, which means reading it tells you the lock is held for exactly this scope. unique_lock is strictly more capable and therefore strictly less informative.
scoped_lock should be the default for more than one mutex, and Chapter 26.5 §4 measures why: it uses a deadlock-avoidance algorithm, so two call sites naming the mutexes in opposite orders both succeed.
unique_lock is required by condition_variable::wait, because waiting has to release the lock and reacquire it — Chapter 26.6 §3. That is its main use; reaching for it elsewhere usually means the critical section wants splitting.
A word on std::shared_mutex, which looks like free performance for read-heavy data and often is not. Readers take a shared lock concurrently, but the bookkeeping is more expensive than a plain mutex's, so for short critical sections a shared_mutex can be slower even with many readers. It pays when reads are long and writes are rare, and the only way to know is Chapter 25.10's discipline: measure it, and revert it if the number did not move.
6. What Belongs Inside the Critical Section
The rule is as little as possible, and nothing that can block.
// wrong: the lock is held across I/O
{
std::lock_guard<std::mutex> guard{mutex_};
const auto row = format(txn);
out_ << row << '\n'; // a filesystem write, holding the lock
}
// better: build outside, hold the lock only for the handoff
const auto row = format(txn); // no shared state touched
{
std::lock_guard<std::mutex> guard{mutex_};
queue_.push_back(row);
}Three things never to do while holding a lock:
I/O. A write can block for milliseconds. Every other thread waits. Chapter 22.9's one-writer-and-a-queue design exists for this.
Call unknown code. A callback, a virtual function, or anything that might itself take a lock, which is Chapter 26.5's deadlock. If you must, document the lock you hold as part of the contract.
Wait for another thread. Holding a lock while waiting for something that needs the lock is a deadlock with one mutex.
And the thing that is not a reason to shorten a critical section: the 4.57 ns in §3. Splitting one critical section into two to save a few nanoseconds doubles the acquisitions and, worse, creates a window between them in which the invariant is visibly broken. Correctness of the invariant decides the boundaries; cost does not.
7. Recursive, Timed, and the Ones to Avoid
Three variants exist and two of them are usually a sign of a design problem.
std::recursive_mutex may be locked again by the thread that already holds it. It exists because a class whose public functions call each other would otherwise deadlock on itself. That is a real situation and the better fix is almost always to split each public function into a locking wrapper and a private _locked worker that assumes the lock is held:
void record(int unit) { std::lock_guard<std::mutex> g{mutex_}; recordLocked(unit); }
void recordAll(std::span<const int> units)
{
std::lock_guard<std::mutex> g{mutex_};
for (int u : units) recordLocked(u); // one lock, not n
}That is faster, clearer about which functions assume the lock, and removes the question of how deep the recursion went.
std::timed_mutex supports try_lock_for. Reaching for it usually means you expect contention bad enough to need a timeout, which is a reason to fix the contention. Its honest use is in a watchdog or a diagnostic path that must not hang.
try_lock on a plain mutex is occasionally right — a statistics update you are happy to skip rather than block for — and is frequently the beginning of a busy-wait loop, which is Chapter 26.6 §2's measured mistake: waiting 200 ms for a flag burned a median 209 ms of CPU by spinning where a condition variable used 0.1 ms for the same wait — about 2000×.
8. Checking the Discipline
Four mechanical checks, cheapest first.
Grep for .lock() and .unlock(). In correct code these appear only inside the implementation of a guard. A bare mutex_.unlock() in your own code is a defect until proven otherwise.
Make the data private and the mutex mutable — §6's callout. This is the check the compiler performs for you.
Run TSan. Chapter 26.3 §7: it reports a race whether or not the run went wrong, so it finds the path that touches the data without the lock. Chapter 25.6 §6's cost applies — roughly an order of magnitude, so it belongs in the test build.
Use clang's thread-safety annotations if you can. Clang supports __attribute__((guarded_by(mutex_))) and -Wthread-safety, which makes the "this mutex guards these fields" claim machine-checkable at compile time. It requires annotating the code and it is the only tool here that catches the error without the racy path having executed.
9. The Table
| the question | the answer | measured |
|---|---|---|
| what does a mutex give | mutual exclusion and an ordering | §1 |
| how should it be held | std::lock_guard<std::mutex> guard{mutex_}; | §1 |
why not lock()/unlock() | an early return or a throw skips the unlock | §2 |
| what does a leaked mutex cost | the program stops — no crash, no wrong answer | §2 |
| what does lock+unlock cost | 4.57 ns [4.57–4.68] uncontended | §3 |
| relative to a plain increment | 16.3× | §3 |
| relative to an atomic | about 2.4× | §3 |
| relative to starting a thread | about 1/3000 | §3 |
| what does contention cost | 3.09× / 7.59× / ~11× at 2 / 4 / 8 threads | §4 |
| how was that measured | as a ratio against N private mutexes, so load cancels | §4 |
| what proves the harness | the 1-thread control reads 1.00× [0.99–1.01] | §4 |
| what does sharding buy | per-op cost falls 4.80 → 1.20 ns from 1 to 8 threads | §4 |
| which guard for two mutexes | std::scoped_lock | §5, Chapter 26.5 §4 |
| which for a condition variable | std::unique_lock | §5, Chapter 26.6 §3 |
| what goes inside the critical section | as little as possible, and no I/O or unknown code | §6 |
| what does a mutex actually protect | data, not code — so encapsulate both together | §6 callout |
10. What to Actually Do
- Always hold a mutex with a guard.
std::lock_guardby default; never a barelock()/unlock()pair in your own code. - Put the mutex and the data it guards in one class, data private, mutex
mutable, every access through a member function. That is the only way the association is enforced. - Return protected data by value, never by reference — a reference outlives the lock.
- Use
std::scoped_lockfor two or more mutexes andstd::unique_lockonly when you need early unlock or a condition variable. - Keep I/O, unknown code and waits out of the critical section.
- Let the invariant decide the boundaries, not the 4.57 ns. Splitting a critical section to save nanoseconds opens a window where the invariant is broken.
- Treat contention as a design problem: shard the state, batch the work, or do not share it.
- Prefer a locking wrapper plus a
_lockedworker to arecursive_mutex. - Do not build a busy-wait out of
try_lock. Chapter 26.6 §2 measured a 200 ms wait costing a median 209 ms of CPU when spun against 0.1 ms with a condition variable. - Run TSan in the test build, and consider clang's
-Wthread-safetyannotations, which catch the unguarded path at compile time.
11. Mistakes Worth Making Once
lock() … unlock() with an early return between them. The program stops, silently.
The same with a throwing allocation between them. Chapter 22.4 §2 measured the file version: closed=0 LEAKED=1.
A second function touching the protected data without the lock. It compiles, and Chapter 26.3 §3 measured that it may give the right answer for years.
Returning const T& to protected data. The reference is read after the lock is gone.
Holding the lock across a file write. Every other thread waits for the filesystem.
Calling a user callback while holding a lock. Chapter 26.5's deadlock, arranged by someone else's code.
A recursive_mutex to fix self-deadlock. The wrapper-plus-_locked split is better and faster.
Splitting a critical section for speed. You created a window where the invariant is visibly broken.
Eight threads on one mutex. About 11× the per-operation cost of eight threads on eight mutexes, and no parallelism at all.
Quoting an absolute multi-threaded timing curve from a loaded machine. §4's first attempt gave 7.7/9.3/12.1/13.3 ns one day and 4.8/6.7/6.3/7.2 the next, the second not even monotonic. Measure a ratio against a control instead.
12. Interview Relevance
The frozen curriculum marks this chapter Critical, and the opening question is usually "how do you protect shared state?" The expected answer is a mutex; the answer that stands out adds that it must be held by an RAII guard and says why — an early return or a thrown exception skips a manual unlock(), and a mutex that is never released stops the program rather than crashing it, which is the hardest failure to diagnose because there is no error to look for.
The follow-up worth preparing is what a mutex actually protects, because the strong answer is data, not code: the mutex establishes an ordering between a release and the next acquisition, it has no idea which variables you meant, and the association is enforced only if you put the mutex and the data in one class with the data private. Adding that a getter must return by value — because a reference outlives the lock — shows you have been bitten by it.
If cost comes up, the numbers are short and useful: 4.57 ns for an uncontended lock and unlock, about 16× a plain increment and 2.4× an atomic, against ~14 µs to start a thread. And contention costing about 3× at two threads, 7.6× at four and roughly an order of magnitude at eight, which is why contention is a design problem rather than a tuning one. If you get the chance to say how you measured it — a ratio against the same threads on private mutexes, with the single-threaded row used as a control that must come out at 1.00× — that is the part that shows you measure things for a living rather than quoting a benchmark you ran once.
13. What You Should Be Able to Say Now
- That a mutex gives both mutual exclusion and an ordering, and that the ordering is what removes Chapter 26.3's third clause.
- That the correct spelling is
std::lock_guard<std::mutex> guard{mutex_};and thatlock()/unlock()pairs are a defect. - Why: an early return or a throw skips the unlock, and a permanently held mutex stops the program with no error to find.
- That lock and unlock cost 4.57 ns [4.57–4.68] uncontended — 16.3× a plain increment, about 2.4× an atomic, and about 1/3000 of starting a thread.
- That contention costs 3.09× at two threads, 7.59× at four and roughly 11× at eight, measured as a ratio against the identical work on private mutexes.
- That the ratio's 1-thread row is the control and must read 1.00× — it reads 1.00× [0.99–1.01] — and that an absolute ns-per-op curve on a loaded machine is not a measurement at all.
- That sharding is the measured fix: per-operation cost falls from 4.80 ns at one thread to 1.20 ns at eight.
- That contention is fixed by sharding, batching or not sharing — Chapter 26.1 §3's 0.13× is the same mistake.
- Which guard to use when:
lock_guardby default,scoped_lockfor several mutexes,unique_lockfor early unlock and condition variables,shared_lockfor ashared_mutex. - That a mutex protects data, so the mutex and its data belong in one class with the data private, the mutex
mutable, and getters returning by value. - That
recursive_mutexusually signals a missing wrapper/_lockedsplit, and thatshared_mutexis not automatically faster.
14. Exercises
Exercise 1 — the frozen line. Write the Counters class from §1 and drive it from four threads. Confirm with TSan that it reports nothing.
Exercise 2 — break it three ways. Replace the guard with lock()/unlock(), then add an early return, then make the body throw. Observe each outcome, and note which of the three your test suite would catch.
Exercise 3 — the costs. Reproduce §3's four-row table as a median over seven runs. Confirm your ranges exclude each other before quoting a ratio.
Exercise 4 — contention, measured honestly. First build the naive version: N threads on one mutex, printing nanoseconds per increment at 1, 2, 4 and 8 threads. Run it twice with something heavy running in the background and once with the machine idle, and satisfy yourself that the curve is not reproducible. Then build §4's two-arm ratio harness and confirm the single-thread control comes out at 1.00×.
Exercise 5 — the leak of protected state. Add a getter returning const std::map<int,long>& and write a test that reads it from one thread while another records. Run it under TSan.
Exercise 6 — annotations. Add clang's guarded_by attribute and -Wthread-safety, then write a function that touches the data without the lock and confirm it fails to compile.
15. Summary
- A mutex gives mutual exclusion and an ordering. The ordering is what makes it remove a data race: everything written before the unlock is visible to the next thread that locks.
- Hold it with a guard —
std::lock_guard<std::mutex> guard{mutex_};— always. A manualunlock()is skipped by an early return and by an exception, and a mutex that is never released stops the program: no crash, no wrong answer, no error message to search for. - Measured, uncontended: lock + unlock = 4.57 ns [4.57–4.68], against 0.28 ns for a plain increment (16.3×) and 1.88 ns for an atomic. For comparison, starting a thread is ~14.4 µs — about 3,000 locks.
- So the lock is cheap in absolute terms and expensive relative to a bare increment — which is exactly the case where
std::atomicis the right tool. - Contention costs 1.00× / 3.09× / 7.59× / ~11× at 1, 2, 4 and 8 threads — measured as the ratio of one shared mutex to N private ones, so that the machine's other load cancels out. The 1-thread row is the control, and it reads 1.00× [0.99–1.01].
- An absolute ns-per-op curve would have been worthless: the same binary gave 7.7/9.3/12.1/13.3 ns under one load and 4.8/6.7/6.3/7.2 under another, the latter not even monotonic.
- Sharding is the fix, and it was measured: the private-mutex arm falls from 4.80 ns at one thread to 1.20 ns at eight — real parallelism. Chapter 26.1 §3's 0.13× is the same finding from the other side.
- A mutex protects data, not code, and nothing in the language records which data. Put the mutex and its fields in one class, fields private, mutex
mutable, and return by value — a reference outlives the lock. - Keep I/O, unknown code and waits out of the critical section, and let the invariant decide its boundaries rather than the nanoseconds.
scoped_lockfor several mutexes,unique_lockfor condition variables, a wrapper plus a_lockedworker instead ofrecursive_mutex, and no busy-waiting ontry_lock.
Chapter 26.5 is what happens when two pieces of code each take two of these correctly, in different orders.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
