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
  1. 1What std::mutex does
  2. 2Lock with an object, never by hand
  3. 3Lock vs mutex: which lock for which job
  4. 4The lock that locks nothing
  5. 5Two mutexes: the deadlock, and std::scoped_lock
  6. 6Recursive, timed and shared mutexes
  7. 7A mutex or std::atomic
  8. 8A mutex or a semaphore
  9. 9How big a mutex is
  10. 10The Visual Studio 17.10 mutex crash
  11. 11Tools that find mutex mistakes
  12. 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.

Two lines approach a narrow gate in a hatched wall with blue posts. The upper line passes through the gate and runs on; the lower one stops just short of it, its last stretch dotted.
Figure 1 — One thread at a time passes; the other waits its turn.

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::scoped_lock lock(m); (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::scoped_lock lock(a, b);, 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_lock std::scoped_lock lock(a, b);.

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.

[Example 1:
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:

Two timelines. Thread 1 calls lock(), holds the mutex (hatched), then calls unlock(). Thread 2 calls lock() meanwhile and waits (dotted) until thread 1’s unlock(), marked by an arrow; then it holds the mutex and calls unlock().
Figure 2 — Only one thread holds the mutex at a time; the other waits inside lock() until it is released.

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:

[Example 2:
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::lock_guard<std::mutex> lock(totals_mutex); 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:

[Example 3:
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::shared_mutex' is only available from C++17 onwards); Microsoft’s STL provides it in C++14 mode too. C++14’s own reader-writer mutex is std::shared_timed_mutex.

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:

[Example 4:
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::lock_guard<std::mutex>{totals_mutex}; a temporary yes all four succeeded
std::scoped_lock{totals_mutex}; a temporary yes all four succeeded
std::unique_lock<std::mutex>{totals_mutex}; a temporary yes all four succeeded
std::unique_lock<std::mutex>(totals_mutex); a lock of no mutex, named totals_mutex yes all four, not about the lock succeeded
std::scoped_lock(totals_mutex); an empty std::scoped_lock<> named totals_mutex not on Clang 19.1 MSVC, GCC; not Clang 23.1 succeeded
std::scoped_lock lock; a lock of no mutex yes MSVC, Clang 19.1, GCC succeeded
std::lock_guard<std::mutex>(totals_mutex); a lock_guard of no mutex on none an error on all four not built
std::scoped_lock lock(totals_mutex); 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:

[Example 5:
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::unique_lock<std::mutex>(totals_mutex); 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::scoped_lock(totals_mutex); 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::lock_guard<std::mutex>(totals_mutex);, 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:

[Example 6:
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:

A ring of four boxes. Thread 1 holds std::mutex a (solid arrow) and waits for std::mutex b (dotted arrow); thread 2 holds b and waits for a. The four arrows close the loop.
Figure 3 — Each thread holds one mutex and waits for the other, so neither can go on: a deadlock.

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]):

[Example 7:
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:

[Example 8:
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’s try_lock() failed until the second unlock().
  • std::timed_mutex (C++11) adds try_lock_for: try_lock_for(std::chrono::milliseconds(20)) gave up while another thread held the mutex, and succeeded once it was released.
  • std::shared_mutex (C++17) admits many readers (std::shared_lock) or one writer: two readers held it at once while a writer’s try_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:

[Example 9:
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.

[Example 10:
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::counting_semaphore, std::binary_semaphore, 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:

[Example 11:
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::mutex totals_mutex; 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:

[Example 12:
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;):

[Example 13:
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:

[Example 14:
#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:

[Example 15:
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::scoped_lock lock(m);, never lock() and unlock() by hand (CP.20).
  • Two or more mutexes: one std::scoped_lock lock(a, b); (CP.21).
  • The mutex declared beside the data it guards (CP.50), marked GUARDED_BY where Clang checks it.
  • Held briefly (CP.43), with no unknown code, such as a callback, called under it (CP.22).
  • std::unique_lock only for what it adds; std::shared_lock on a std::shared_mutex for 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::condition_variable 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::counting_semaphore or std::binary_semaphore (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.

Read next

  1. std::thread and std::jthread: start, join and stop a threadConcurrency · Miriam Castellanos · written for C++20
  2. std::optional in C++: how to use it and when not toStandard Library · Miriam Castellanos · written for C++17
  3. C++ generators: co_yield and std::generatorConcurrency · Miriam Castellanos · written for C++23