Concurrency[conc]
std::mutex in C++: how to lock it, and which lock to use
- Section
- Concurrency [conc]
- Standard
- C++17
- Compilers
- MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2, Clang 23.1 (libc++)
- Published
- Length
- 13 min read
In this answer12 clauses
- 1What std::mutex does
- 2Lock with an object, never by hand
- 3Lock vs mutex: which lock for which job
- 4The lock that locks nothing
- 5Two mutexes: the deadlock, and std::scoped_lock
- 6Recursive, timed and shared mutexes
- 7A mutex or std::atomic
- 8A mutex or a semaphore
- 9How big a mutex is
- 10The Visual Studio 17.10 mutex crash
- 11Tools that find mutex mistakes
- 12A checklist for every mutex
Abstract
Lock a std::mutex through a named lock object: std::scoped_lock lock(m); (C++17; std::lock_guard before it) takes the mutex in its constructor and releases it in its destructor on every path out, exceptions included. Use std::unique_lock only to release early, move the lock or wait on a condition variable, and std::shared_lock on a std::shared_mutex for readers. Take two mutexes in one std::scoped_lock lock(a, b); and two threads naming them in opposite orders cannot deadlock.

A std::mutex lets one thread at a time touch shared data. Do not call its lock() and unlock() by hand: name a lock object, std:: (std::lock_guard before C++17), which calls lock() in its constructor and unlock() in its destructor on every path out, exceptions included. Use std::unique_lock only to release early, move the lock or give it to a condition variable, and std::shared_lock on a std::shared_mutex for readers. Take two mutexes in one std::, which cannot deadlock against a thread doing the same in the other order.
This answer is written for C++17, which added std::scoped_lock and std::shared_mutex. std::mutex itself is C++11 and the semaphores C++20; neither C++23 nor C++26 changed the mutex or lock classes. Since When’s C++17 table lists the first compiler releases with std::scoped_lock.
The samples were built with MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2 and Clang 23.1 (libc++): the first two on Windows x64 with Microsoft’s standard library, the others on Compiler Explorer, Linux x86-64. Every program prints the same output on every run, so there is no race demo with a wrong total: every threaded sample printed one output in 30 runs with MSVC and with Clang 19.1, and every correct one ran clean under ThreadSanitizer with GCC 16.2 and Clang 23.1.
What changed: two mutexes are locked with std::lock(a, b) and two std::lock_guards with std::adopt_lockstd::.
since C++17
What std::mutex does
Two threads that access the same object without synchronization, at least one of them writing, have a data race, which is undefined behavior; a mutex makes them take turns. Here threads add sensor readings to a count and a sum that must agree, with totals_mutex declared beside them. How to start and join the threads is in std::thread and std::jthread.
struct Totals {
long count = 0; // how many readings
long sum = 0; // their sum: must agree with count
};
Totals totals;
std::mutex totals_mutex; // guards totals
void add_readings(int reading, int times) {
for (int i = 0; i < times; ++i) {
std::scoped_lock lock(totals_mutex); // one thread at a time
++totals.count;
totals.sum += reading;
} // unlocked here, every time round the loop
}
int main() {
std::thread first(add_readings, 3, 100000);
std::thread second(add_readings, 5, 100000);
first.join();
second.join();
std::cout << "count " << totals.count << ", sum " << totals.sum
<< '\n';
}— end example]
It printed count 200000, sum 800000 on all four. std::mutex is exclusive, not recursive, and not copyable ([thread.mutex.class]). std::scoped_lock calls the mutex’s lock() in its constructor and unlock() in its destructor; a thread that calls lock() while another holds the mutex waits inside the call:

Lock with an object, never by hand
A lock() written by hand needs a matching unlock() on every path out, and an exception or an early return skips it (CP.20: “Use RAII, never plain lock()/unlock()”). This add_reading() throws on a negative reading:
void add_reading(int reading) {
totals_mutex.lock();
if (reading < 0)
throw std::invalid_argument("negative reading");
++totals.count;
totals.sum += reading;
totals_mutex.unlock(); // never reached when it throws
}
// ...
add_reading(7);
try {
add_reading(-4);
} catch (const std::invalid_argument& e) {
std::cout << "caught: " << e.what() << '\n';
}
bool is_free = another_thread_can_lock(totals_mutex);
std::cout << "totals_mutex is "
<< (is_free ? "free" : "still locked") << '\n';— end example]
another_thread_can_lock() calls try_lock() on a new thread. That fails while another thread holds the mutex; on a free one it succeeded in every run here, though the standard lets it fail even then ([thread.mutex.requirements.mutex.general]).
All four printed caught: negative reading, then totals_mutex is still locked: the throw skipped the unlock(), so another thread’s try_lock() failed. The sample’s main calls unlock() itself before it returns: destroying a mutex that a thread owns, or a thread ending while it owns one, is undefined behavior ([thread.mutex.class]).
With std:: in place of the two calls, main printed totals_mutex is free: the destructor ran during the unwinding, and another thread’s try_lock() succeeded. A lock is RAII, the idea behind the rule of zero and the special member functions.
Lock vs mutex: which lock for which job
The mutex is what threads take turns on; a lock is the object that takes it and gives it back. The sizes were the same on all three libraries (x86-64):
| Lock | Since | For | Size |
|---|---|---|---|
std::scoped_lock |
C++17 | one mutex or several, for the whole scope | 8 bytes, one mutex |
std::lock_guard |
C++11 | one mutex, for the whole scope | 8 bytes |
std::unique_lock |
C++11 | release early and relock, defer, try, time out, move; a condition variable waits with it | 16 bytes |
std::shared_lock |
C++14 | many readers at once of a std::shared_mutex |
16 bytes |
std::unique_lock and std::shared_lock at work:
void report() {
std::unique_lock<std::mutex> lock(totals_mutex);
Totals now = totals;
lock.unlock(); // printing needs no lock: let the others in
std::cout << "count " << now.count << ", sum " << now.sum << '\n';
lock.lock(); // take it again to write reported
reported = now.count;
} // unlocks only if it holds the mutex: unique_lock knows
int read_offset() {
std::shared_lock lock(calibration_mutex); // readers share it
return offset;
}
void set_offset(int value) {
std::scoped_lock lock(calibration_mutex); // a writer is alone
offset = value;
}— end example]
As C++14, all four rejected std::scoped_lock, and GCC and Clang 23.1 (libc++) also rejected std::shared_mutex (GCC: note: 'std::); Microsoft’s STL provides it in C++14 mode too. C++14’s own reader-writer mutex is std::.
The lock that locks nothing
A lock object without a name locks nothing, or nothing for long (CP.44: “Remember to name your lock_guards and unique_locks”). Here add_reading() makes a temporary, and print_totals_mutex() asks a second thread to try_lock() the mutex:
void add_reading(int reading) {
std::lock_guard<std::mutex>{totals_mutex}; // a temporary
print_totals_mutex();
++totals.count;
totals.sum += reading;
}— end example]
It printed inside add_reading, totals_mutex is free: the temporary released the mutex at the semicolon. Each line below took the lock’s place, as C++17, with /W4 (MSVC) and -Wall -Wextra (GCC, Clang); without them, GCC and MSVC warned only on the temporaries:
| Line | Declares | Compiles | Warned | Second thread’s try_lock() |
|---|---|---|---|---|
std:: |
a temporary | yes | all four | succeeded |
std:: |
a temporary | yes | all four | succeeded |
std:: |
a temporary | yes | all four | succeeded |
std:: |
a lock of no mutex, named totals_mutex |
yes | all four, not about the lock | succeeded |
std:: |
an empty std::scoped_lock<> named totals_mutex |
not on Clang 19.1 | MSVC, GCC; not Clang 23.1 | succeeded |
std:: |
a lock of no mutex | yes | MSVC, Clang 19.1, GCC | succeeded |
std:: |
a lock_guard of no mutex |
on none | an error on all four | not built |
std:: |
the named lock | yes | none | failed |
The temporaries drew a warning on all four because all three libraries mark the constructors of lock_guard, scoped_lock and unique_lock [[nodiscard]] (the standard does not): Microsoft’s STL since Visual Studio 2019 16.9, libc++ since LLVM 19 (lock_guard since 16), libstdc++ since GCC 14. Older releases give no warning: GCC 13 compiles these lines silently. Only Microsoft’s STL and libc++ 23 mark std::shared_lock. clang-tidy’s bugprone-unused-raii flagged them too. libc++’s warning and clang-tidy’s:
warning: ignoring temporary created by a constructor declared with 'nodiscard' attribute [-Wunused-value]
warning: object destroyed immediately after creation; did you mean to name the object? [bugprone-unused-raii]— end example]
With parentheses the line is a declaration ([stmt.ambig]), so std:: hides the mutex behind a new lock of the same name. Each compiler warned about something else (a hidden global, a vexing parse, redundant parentheses); clang-tidy said nothing.
std:: is the dangerous one, silent on Clang 23.1 with libc++. Clang 19.1 rejected it (cannot use parentheses when declaring variable with deduced class template specialization type), as every Clang from 5 to 20 does on Compiler Explorer, but the line is valid C++17 ([stmt.ambig], [dcl.type.class.deduct]): GCC and MSVC accept it, and Clang 21’s release notes list the fix.
std::, the mistake cppreference’s lock_guard page shows, compiles nowhere: lock_guard has no default constructor.
Two mutexes: the deadlock, and std::scoped_lock
Each Buffer has its own mutex. Thread one moves readings from a to b, locking a, then b; thread two moves some back, locking b, then a. If both take their first mutex before either takes its second, each waits for the other forever. Here two std::latches (C++20) force that timing, and try_lock() stands where lock() would hang:
bool transfer(Buffer& from, Buffer& to, int n) {
std::lock_guard<std::mutex> lock_from(from.mutex);
both_hold_one.arrive_and_wait(); // C++20: the bad timing, for sure
bool got = to.mutex.try_lock(); // lock() would wait here for ever
both_have_tried.arrive_and_wait();
if (got) {
from.readings -= n;
to.readings += n;
to.mutex.unlock();
}
return got;
}
// ...
Buffer a, b;
bool first = false;
bool second = false;
std::thread one([&] { first = transfer(a, b, 3); }); // a, then b
std::thread two([&] { second = transfer(b, a, 1); }); // b, then a— end example]
All four printed thread one got b: no and thread two got a: no. The figure’s a and b are a.mutex and b.mutex:

The fix (CP.21) is one std::scoped_lock taking both, in each thread, in either order. With several mutexes it locks them through std::lock, whose “sequence of calls does not result in deadlock” ([thread.lock.algorithm]):
void transfer(Buffer& from, Buffer& to, int n) {
std::scoped_lock lock(from.mutex, to.mutex); // both, or neither
from.readings -= n;
to.readings += n;
}
// ...
std::thread one([&] {
for (int i = 0; i < 100000; ++i)
transfer(a, b, 2); // locks a and b
});
std::thread two([&] {
for (int i = 0; i < 100000; ++i)
transfer(b, a, 1); // locks b and a
});— end example]
From 200,000 readings in each buffer, it ended with a 100000, b 300000, total 400000 on every run. transfer(a, a, n) would lock one std::mutex twice, which is undefined, so a self-transfer needs if (&from == &to) return; first. Before C++17 it took std::lock, then a std::lock_guard adopting each mutex:
void transfer(Buffer& from, Buffer& to, int n) {
std::lock(from.mutex, to.mutex); // both, without deadlock
std::lock_guard<std::mutex> lock_from(from.mutex, std::adopt_lock);
std::lock_guard<std::mutex> lock_to(to.mutex, std::adopt_lock);
from.readings -= n;
to.readings += n;
}— end example]
It printed the same, as C++14 and as C++17.
Recursive, timed and shared mutexes
Locking a std::mutex the calling thread already owns is undefined behavior: lock() requires that the caller not own it ([thread.mutex.requirements.mutex]). The other mutex types:
std::recursive_mutex(C++11) lets its owner lock it again. Locked twice and unlocked once, it stayed held: another thread’stry_lock()failed until the secondunlock().std::timed_mutex(C++11) addstry_lock_for:try_lock_for(std::gave up while another thread held the mutex, and succeeded once it was released.chrono:: milliseconds(20)) std::shared_mutex(C++17) admits many readers (std::shared_lock) or one writer: two readers held it at once while a writer’stry_lock()failed. A reader that asks for the write lock has undefined behavior ([thread.sharedmutex.class]), and nothing is promised about writer starvation.
A recursive mutex usually means a public function calls another public function that locks. Let the public functions lock and a private one assume the lock:
class SensorHub {
public:
void add(int reading) {
std::scoped_lock lock(mutex_);
add_locked(reading);
}
void add_all(const std::vector<int>& readings) {
std::scoped_lock lock(mutex_);
for (int reading : readings)
add_locked(reading); // no second lock
}
// ...
private:
void add_locked(int reading) { // the caller holds mutex_
++totals_.count;
totals_.sum += reading;
}
mutable std::mutex mutex_; // guards totals_
Totals totals_;
};— end example]
A mutex or std::atomic
One counter or one flag needs no mutex: ++ on a std::atomic is one atomic read-modify-write.
std::atomic<long> reading_count{0}; // one value: no mutex needed
void count_readings(int times) {
for (int i = 0; i < times; ++i)
++reading_count; // an atomic read-modify-write
}
int main() {
std::thread first(count_readings, 100000);
std::thread second(count_readings, 100000);
first.join();
second.join();
std::cout << "count " << reading_count << '\n';
}— end example]
It printed count 200000 on all four. When several values must change together, as count and sum do in Totals, use a mutex. Anything cleverer is lock-free programming, and CP.100 says: “Don’t use lock-free programming unless you absolutely have to.”
A mutex or a semaphore
A mutex has an owner: unlock() requires “The calling thread owns the mutex” ([thread.mutex.requirements.mutex.general]). A semaphore (std::, std::, C++20) is a counter with no owner: acquire() takes one, waiting while the count is zero, and release() adds one. The standard puts no owner in release()’s precondition ([thread.sema.cnt]), so a thread that never acquired it can release it:
int reading = 0;
std::binary_semaphore reading_ready(0); // C++20: starts at 0
void consumer() {
reading_ready.acquire(); // waits until the count is 1, takes it
std::cout << "consumer got " << reading << '\n';
}
void producer() {
reading = 42;
reading_ready.release(); // a thread that never acquired it
}— end example]
It printed consumer got 42 on all four. A mutex protects data; a semaphore limits how many threads proceed, or signals one thread from another.
How big a mutex is
sizeof in bytes, from MSVC 19.44 (Microsoft’s STL, Windows x64), GCC 16.2 (libstdc++) and Clang 23.1 (libc++), both on Linux x86-64:
| Type | Microsoft’s STL | libstdc++ | libc++ |
|---|---|---|---|
std::mutex |
80 | 40 | 40 |
std::recursive_mutex |
80 | 40 | 40 |
std::timed_mutex |
160 | 40 | 96 |
std::shared_mutex |
8 | 56 | 144 |
std::lock_guard |
8 | 8 | 8 |
std::scoped_lock, one mutex |
8 | 8 | 8 |
std::unique_lock |
16 | 16 | 16 |
std::shared_lock |
16 | 16 | 16 |
Microsoft’s STL is not the larger one across the board. On Linux, 40 bytes is glibc’s pthread_mutex_t on x86-64, and libstdc++’s std::shared_mutex wraps its 56-byte pthread_rwlock_t. Microsoft’s std::mutex keeps an older layout, with a 64-byte buffer, for binary compatibility; the comment over it in Microsoft’s source reads “TRANSITION, ABI: We should directly store _M_srw_lock above.”
The Visual Studio 17.10 mutex crash
Visual Studio 2022 17.10 made std::mutex’s constructor constexpr, as the standard has had it since C++11. Microsoft’s STL changelog warns that programs outside the binary-compatibility rules “may encounter null dereferences in mutex machinery”.
In an issue on the same project, a program built with 17.10 crashed against msvcp140.dll 14.32.31326.0 and ran with 14.40.33810.0; another contributor answered “This is by design.” and pointed to the changelog’s note. The rule, from Microsoft’s binary-compatibility page: the Redistributable must be at least as new as the newest build tools used.
The escape hatch is _DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR. constinit std:: compiled and locked on all four as C++20; with the macro defined, MSVC rejected it (error C2127: 'totals_mutex': illegal initialization of 'constinit' entity with a non-constant expression).
Tools that find mutex mistakes
ThreadSanitizer reports a data race when a run makes one: -fsanitize=thread on GCC and Clang, with -O1 -g as Clang’s ThreadSanitizer page advises (the Toolchain page covers the sanitizers). This is the first section’s counter without its mutex, the page’s one racy program:
struct Totals {
long count = 0;
long sum = 0;
};
Totals totals; // no mutex: a data race
void add_readings(int reading, int times) {
for (int i = 0; i < times; ++i) {
++totals.count;
totals.sum += reading;
}
}
int main() {
std::thread first(add_readings, 3, 1000);
std::thread second(add_readings, 5, 1000);
first.join();
second.join();
std::cout << "both threads joined\n";
}— end example]
With GCC 16.2 and Clang 23.1 (-fsanitize=thread -g -O1) it printed both threads joined and a report. One run’s, from Clang 23.1 with libc++, in a build that wrote the report to a file without the deeper stack frames (line 31 there is ++totals.count;):
WARNING: ThreadSanitizer: data race (pid=2)
Write of size 8 at 0x5555569f78d8 by thread T2:
#0 add_readings(int, int) /app/example.cpp:31:9 (output.s+0xea6bc)
Previous write of size 8 at 0x5555569f78d8 by thread T1:
#0 add_readings(int, int) /app/example.cpp:31:9 (output.s+0xea6bc)
Location is global 'totals' of size 16 at 0x5555569f78d8 (output.s+0x14a38d8)— end example]
It goes on to list both threads, Thread T1 (tid=4, finished) among them, and ends SUMMARY: ThreadSanitizer: data race /app/example.cpp:31:9 in add_readings(int, int); a second report follows for sum. T1 had already finished: the tool reports the missing synchronization, not a collision it happened to see.
It also flags a deadlock that did not happen: for two lock_guards taken in opposite orders by threads that ran one after the other, it reported WARNING: ThreadSanitizer: lock-order-inversion (potential deadlock). The std::scoped_lock version drew no report.
Clang’s -Wthread-safety finds an unguarded access at compile time; mark the data GUARDED_BY its mutex:
#define GUARDED_BY(x) __attribute__((guarded_by(x)))
// ...
class SensorHub {
public:
void add(int reading) {
std::scoped_lock lock(totals_mutex_);
++totals_.count;
totals_.sum += reading;
}
long count() const {
return totals_.count; // warning: read without the mutex
}
private:
mutable std::mutex totals_mutex_;
Totals totals_ GUARDED_BY(totals_mutex_);
};— end example]
Clang 23.1 with libc++ passed the writes under std::scoped_lock and warned on the read:
warning: reading variable 'totals_' requires holding mutex 'totals_mutex_' [-Wthread-safety-analysis]— end example]
Clang’s Thread Safety Analysis needs a mutex type annotated as a capability. libc++’s std::mutex is, with no macro, since LLVM 21; an older libc++ needs _LIBCPP_ENABLE_THREAD_SAFETY_ANNOTATIONS defined, or it gives the false warnings below. With libstdc++ or Microsoft’s STL, Clang warned that 'guarded_by' attribute requires arguments whose type is annotated with 'capability' attribute and flagged the locked writes too: use it with libc++, or a mutex type you annotate yourself.
A checklist for every mutex
- A named lock object,
std::, neverscoped_lock lock(m); lock()andunlock()by hand (CP.20). - Two or more mutexes: one
std::(CP.21).scoped_lock lock(a, b); - The mutex declared beside the data it guards (CP.50), marked
GUARDED_BYwhere Clang checks it. - Held briefly (CP.43), with no unknown code, such as a callback, called under it (CP.22).
std::unique_lockonly for what it adds;std::shared_lockon astd::shared_mutexfor readers.- One counter or flag: a
std::atomic. - The tests run once with
-fsanitize=thread.
More on threads and shared data: Concurrency.
Questions
What is the difference between lock_guard, unique_lock and scoped_lock?
All three lock a mutex in their constructor and release it in their destructor. std::lock_guard (C++11) holds one mutex for the whole scope; std::scoped_lock (C++17) does the same for one mutex or several, taking several without deadlock. std::unique_lock (C++11) can also release early and relock, defer or try the lock, time out and be moved, and it is the lock a std:: waits with. On Microsoft’s STL, libstdc++ and libc++ (x86-64), lock_guard and a one-mutex scoped_lock took 8 bytes, unique_lock 16.
Can I lock a std::mutex twice from the same thread?
No. lock() requires that the calling thread does not own the mutex, so locking a std::mutex the thread already holds is undefined behavior; an implementation that detects it may throw std::system_error with resource_deadlock_would_occur. A std::recursive_mutex allows it, and stays held until it has been released as many times as it was locked. The usual better fix is a private function that assumes the lock, called by public functions that take it.
What is the difference between a mutex and a semaphore?
A mutex has an owner: the thread that locked it must release it, and it protects data. A std:: or std:: (C++20) is a counter with no owner: acquire() takes one, waiting while the count is zero, and release() adds one, with no ownership condition in the standard. Use a semaphore to limit how many threads proceed at once, or to signal from one thread to another.
Should I use std::mutex or std::atomic?
A std::atomic, for one counter or one flag: two threads each incrementing a std::atomic of long 100,000 times printed 200000 with MSVC, GCC and Clang, with no mutex. A mutex, when several values must change together, such as a count and a sum that must agree. For lock-free code beyond that, the Core Guidelines say not to write it unless you absolutely have to (CP.100).
Can a std::mutex be copied or moved?
Neither. std::mutex is not copyable or movable, so a class with a std::mutex member is not either, as static_assert confirmed with MSVC, GCC and Clang. To make such a class copyable, write its copy constructor yourself: lock the source object’s mutex while copying the data, and let the new object default-construct its own mutex.
How do I find a data race in C++?
Build with ThreadSanitizer (-fsanitize=thread on GCC and Clang, with -O1 -g) and run the program: on a race it prints WARNING: ThreadSanitizer: data race, with both accesses, their threads and the variable. It reports the missing synchronization, not only a collision it happens to see: in one run the first thread had already finished when the report came. Clang -Wthread-safety finds unguarded accesses at compile time, given an annotated mutex such as the std::mutex of libc++ 21 and later.
Sources
- [thread.mutex.class] Class mutexC++ standard working draft
- [thread.lock.algorithm] Generic locking algorithmsC++ standard working draft
- [thread.sema.cnt] Class template counting_semaphoreC++ standard working draft
- CP: Concurrency and parallelismC++ Core Guidelines
- VS 2022 ChangelogMicrosoft STL
- C++ binary compatibility between Visual Studio versionsMicrosoft Learn
- Thread Safety AnalysisClang documentation
- ThreadSanitizerClang documentation