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_guard is 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_lock is 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:

  1. Deferred Locking: By passing std::defer_lock to the constructor, you can instantiate a std::unique_lock without immediately locking the underlying mutex, allowing you to lock it explicitly at a later point via .lock().
  2. 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.
  3. Timed and Non-blocking Locking: It supports std::try_to_lock to avoid blocking, as well as timed locking operations (try_lock_for and try_lock_until) when paired with a std::timed_mutex.

Move Semantics and Ownership Transfer

  • std::lock_guard is neither copyable nor movable. Its ownership is permanently tied to the scope in which it is instantiated.
  • std::unique_lock is 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_guard has zero runtime overhead compared to calling lock() and unlock() directly on a bare mutex. The compiler can easily inline its constructor and destructor.
  • std::unique_lock has 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 calling unlock(). 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_guard when you need simple, automatic locking for the entire duration of a code block. In C++17 and later, std::scoped_lock is often preferred over std::lock_guard because it can lock multiple mutexes simultaneously without deadlocking, but std::lock_guard remains the baseline for single-mutex scoped locking.
  • Use std::unique_lock when working with std::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.