std::lock_guard vs std::unique_lock in C++
In modern C++, managing mutex ownership through RAII (Resource
Acquisition Is Initialization) wrappers prevents race conditions and
accidental deadlocks caused by unhandled exceptions. The C++ Standard
Library offers two primary utilities for this purpose:
std::lock_guard and std::unique_lock. While
both automatically release associated mutexes when falling out of scope,
they differ substantially in flexibility, performance characteristics,
and feature sets. This article examines the core distinctions between
the two, their internal mechanics, and the specific scenarios where each
should be used.
Feature Comparison and Flexibility
The defining difference between the two wrappers is flexibility:
std::lock_guardis a strictly scoped, minimal wrapper. It acquires the mutex immediately upon construction and releases it exclusively when destroyed. It cannot be manually unlocked, deferred, or transferred.std::unique_lockis a feature-rich wrapper. It tracks whether it currently owns the mutex via an internal boolean flag, allowing the lock state to change during its lifetime.
Because std::unique_lock maintains internal state, it
enables several operations unavailable to
std::lock_guard:
- Deferred Locking: By passing
std::defer_lockto the constructor, you can instantiate astd::unique_lockwithout immediately locking the underlying mutex, allowing you to lock it explicitly at a later point via.lock(). - Explicit Unlock and Relock: You can call
.unlock()before the wrapper goes out of scope to shorten critical sections, and call.lock()again when necessary. - Timed and Non-blocking Locking: It supports
std::try_to_lockto avoid blocking, as well as timed locking operations (try_lock_forandtry_lock_until) when paired with astd::timed_mutex.
Move Semantics and Ownership Transfer
std::lock_guardis neither copyable nor movable. Its ownership is permanently tied to the scope in which it is instantiated.std::unique_lockis movable, though not copyable. Mutex ownership can be returned from functions, stored in standard containers, or transferred across different execution scopes.
Compatibility with Condition Variables
A critical functional difference lies in interaction with
std::condition_variable.
The std::condition_variable::wait method requires a
reference to std::unique_lock<std::mutex>. During a
wait, the condition variable must atomically release the lock and block
the thread, then reacquire the lock once notified. Because
std::lock_guard cannot be explicitly unlocked and relocked,
it cannot be used with std::condition_variable. (Note:
std::condition_variable_any works with any lockable type,
including std::lock_guard, but incurs additional
overhead).
Performance and Overhead
std::lock_guardhas zero runtime overhead compared to callinglock()andunlock()directly on a bare mutex. The compiler can easily inline its constructor and destructor.std::unique_lockhas a slight overhead in both memory and execution time. It must store a flag indicating whether the mutex is currently locked, and its destructor must branch to check this flag before callingunlock(). While this cost is negligible in most applications, it represents measurable overhead in high-frequency, performance-critical code paths.
When to Use Which
- Use
std::lock_guardwhen you need simple, automatic locking for the entire duration of a code block. In C++17 and later,std::scoped_lockis often preferred overstd::lock_guardbecause it can lock multiple mutexes simultaneously without deadlocking, butstd::lock_guardremains the baseline for single-mutex scoped locking. - Use
std::unique_lockwhen working withstd::condition_variable, when critical sections must be unlocked early to minimize contention, when deferred or timed locking is necessary, or when mutex ownership must be transferred via move semantics.