Question

std::unique_lock vs std::lock_guard in C++ Mutex Synchronization

cppmultithreadingc++11mutual-exclusionstdmutex

Question

I have two C++ synchronization use cases:

  1. Two threads share a queue, and I need to synchronize access to that queue.
  2. Two threads share a queue, and one thread must wait with a condition variable until the other thread adds an item.

For the first case, examples often use std::lock_guard<std::mutex>. For the second, examples often use std::unique_lock<std::mutex>.

What is the difference between std::lock_guard and std::unique_lock, and which should be used in each situation?

Short Answer

std::lock_guard and std::unique_lock are RAII wrappers that ensure a mutex is released automatically. Use std::lock_guard for simple, always-locked critical sections. Use std::unique_lock when code needs more control over the lock, especially when waiting on a std::condition_variable.

Concept

Both std::lock_guard and std::unique_lock manage a mutex using RAII (Resource Acquisition Is Initialization).

RAII means:

  • The lock is acquired when the guard object is constructed.
  • The lock is released automatically when the object leaves scope, including when an exception is thrown.

This prevents a common and dangerous bug: locking a mutex and accidentally forgetting to unlock it.

std::mutex mutex;

void update() {
    std::lock_guard<std::mutex> lock(mutex);
    // Mutex is locked here.

    // Shared data can be accessed safely here.
} // Mutex is automatically unlocked here.

The key difference is flexibility:

  • std::lock_guard is a small, simple scoped lock. Once constructed, it owns the lock until it is destroyed.
  • std::unique_lock can be locked later, unlocked early, locked again, moved to another owner, or created without locking immediately.

A condition variable must temporarily release the mutex while a thread waits, then reacquire it before returning. That operation requires a std::unique_lock, so std::condition_variable::wait accepts std::unique_lock<std::mutex>&.

For a producer-consumer queue, this is important: the waiting consumer must not keep the queue mutex locked while it sleeps. Otherwise, the producer could never acquire the mutex to add an item and signal the consumer.

Mental Model

Think of a mutex as the key to a storage room containing shared data.

  • std::lock_guard is like checking out the key at the start of a short visit and returning it automatically when you leave the room. You cannot voluntarily hand it back and retrieve it again during that visit.
  • std::unique_lock is like a more flexible key holder. You can enter later, leave the room temporarily, come back in, or transfer responsibility for the key.

A condition variable is like waiting for a delivery bell. Before waiting, you must put the storage-room key back so the delivery person can enter and place an item inside. When the bell rings, you take the key again and inspect the room. std::unique_lock supports this hand-back-and-reacquire process.

Take Quiz

Syntax and Examples

For a simple critical section, use std::lock_guard:

#include <mutex>
#include <queue>

std::mutex queueMutex;
std::queue<int> queue;

void addItem(int item) {
    std::lock_guard<std::mutex> lock(queueMutex);
    queue.push(item);
} // lock is released automatically

The mutex is locked before queue.push(item) and unlocked at the end of addItem.

Use std::unique_lock when you need lock operations beyond simple scope-based ownership:

#include <mutex>

std::mutex mutex;

void doWork() {
    std::unique_lock<std::mutex> lock(mutex);

    // Access shared state while the mutex is locked.
    lock.unlock();

    // Do work that does not need the mutex.

    lock.lock();
    // Access shared state again.
}

For a condition variable, create a std::unique_lock and pass it to wait:

std::unique_lock<std::mutex> lock(queueMutex);
condition.wait(lock, [] {
    return !queue.empty();
});

The predicate !queue.empty() is essential. It tells wait when it may return and protects against spurious wakeups.

Step by Step Execution

Consider a consumer waiting for an item in a shared queue:

std::unique_lock<std::mutex> lock(queueMutex);
condition.wait(lock, [] {
    return !queue.empty();
});

int item = queue.front();
queue.pop();

Execution proceeds as follows:

  1. lock locks queueMutex. The consumer now has exclusive access to queue.
  2. wait evaluates !queue.empty() while the mutex is locked.
  3. If the queue is empty, wait atomically releases queueMutex and puts the consumer thread to sleep.
  4. Because the mutex was released, a producer can lock it, add an item to the queue, and call condition.notify_one().
  5. The consumer wakes, but it first reacquires queueMutex.
  6. wait checks the predicate again. If the queue has an item, it returns.
  7. The consumer reads and removes the front item while still holding the mutex.
  8. When lock leaves scope, its destructor unlocks the mutex.

The predicate must be checked again because notifications do not guarantee that the condition is currently true when the waiting thread resumes.

Real World Use Cases

std::lock_guard is commonly used for short, straightforward protected operations:

  • Incrementing a shared counter.
  • Adding an entry to a shared log buffer.
  • Reading or updating a shared configuration value.
  • Inserting or removing an item when no thread needs to wait.
  • Protecting a small cache or map update.

std::unique_lock is useful when locking behavior is not a simple "lock now, unlock at scope end" operation:

  • Producer-consumer queues using std::condition_variable.
  • Worker threads waiting until tasks are available.
  • Waiting for a service to become ready.
  • Releasing a mutex before slow I/O, then locking it again to update shared state.
  • Deferring lock acquisition until after preparation work.
  • Using std::try_to_lock to attempt non-blocking acquisition.

The number of threads is not what determines the choice. The required lock behavior does. The same rules apply whether there are two threads or hundreds.

Take Quiz

Real Codebase Usage

In production C++ code, prefer the least powerful lock type that expresses the required behavior.

Simple state updates: std::lock_guard

void Metrics::recordRequest() {
    std::lock_guard<std::mutex> lock(mutex_);
    ++requestCount_;
}

This clearly communicates that the mutex protects the entire small operation.

Queue consumers: std::unique_lock with a predicate

Task TaskQueue::take() {
    std::unique_lock<std::mutex> lock(mutex_);

    available_.wait(lock, [this] {
        return stopped_ || !tasks_.empty();
    });

    if (stopped_ && tasks_.empty()) {
        throw QueueStopped{};
    }

    Task task = std::move(tasks_.front());
    tasks_.pop();
    return task;
}

The predicate handles both task availability and shutdown. This is a common worker-pool pattern.

Keep critical sections small

Do not hold a mutex during expensive work unless necessary:

std::string message;

{
    std::lock_guard<std::mutex> lock(mutex_);
    message = sharedMessage_;
}

sendOverNetwork(message); // No mutex held during slow I/O.

Notify after updating the protected condition

A producer typically updates the queue under the mutex, then notifies:

{
    std::lock_guard<std::mutex> lock(mutex_);
    tasks_.push(task);
}
available_.notify_one();

The important rule is that the state represented by the condition is changed while holding the same mutex used by the waiting code.

Common Mistakes

Using lock_guard with std::condition_variable::wait

This does not compile because wait needs a std::unique_lock<std::mutex>&:

std::lock_guard<std::mutex> lock(queueMutex);
condition.wait(lock); // Error

Use std::unique_lock instead:

std::unique_lock<std::mutex> lock(queueMutex);
condition.wait(lock, [] { return !queue.empty(); });

Waiting without a predicate

This is incomplete:

condition.wait(lock);
int item = queue.front();

A thread can wake without an item being available, called a spurious wakeup. It can also wake after another thread has already consumed the item.

Use the predicate overload:

condition.wait(lock, [] { return !queue.empty(); });

Accessing the queue without the mutex

if (!queue.empty()) { // Unsafe if another thread can modify queue.
    queue.pop();
}

Checking and modifying shared state must be protected by the same mutex.

Holding the lock during slow work

std::lock_guard<std::mutex> lock(mutex);
performSlowFileOperation();

This blocks every other thread that needs the mutex. Copy or move the needed shared data while locked, then do slow work after the guard is destroyed.

Manually locking and unlocking

mutex.lock();
queue.push(42);
mutex.unlock();

If queue.push or later code throws, unlock() may never run. Use RAII wrappers instead.

Calling wait with the wrong mutex

The mutex protecting the queue must also be the mutex used by wait. Otherwise, checking the condition and beginning to wait are not coordinated correctly.

Comparisons

Featurestd::lock_guardstd::unique_lock
Locks in constructor by defaultYesYes
Unlocks in destructorYesYes
Can call unlock() manuallyNoYes
Can lock again after unlockingNoYes
Can defer lockingNoYes
Can use try_to_lockNoYes
Can be movedNoYes
Works with std::condition_variable::waitNoYes
Best default for a simple scoped lockYesUsually unnecessary

Both types provide exception-safe mutex release. Therefore, the difference is not basic safety; it is the amount of control provided.

SituationRecommended choice
Protect a short block of shared-state accessstd::lock_guard<std::mutex>
Wait on std::condition_variablestd::unique_lock<std::mutex>
Unlock before the end of the current scopestd::unique_lock<std::mutex>
Need delayed or conditional lockingstd::unique_lock<std::mutex>
No special locking behavior neededstd::lock_guard<std::mutex>
Take Quiz

Cheat Sheet

// Simple scoped locking
std::lock_guard<std::mutex> lock(mutex);

// Flexible locking
std::unique_lock<std::mutex> lock(mutex);
lock.unlock();
lock.lock();

// Construct now, lock later
std::unique_lock<std::mutex> lock(mutex, std::defer_lock);
lock.lock();

// Try to lock without blocking
std::unique_lock<std::mutex> lock(mutex, std::try_to_lock);
if (lock.owns_lock()) {
    // Lock acquired.
}

// Correct condition-variable waiting
std::unique_lock<std::mutex> lock(mutex);
condition.wait(lock, [] {
    return conditionIsTrue;
});

Rules:

  • Use std::lock_guard by default for uncomplicated critical sections.
  • Use std::unique_lock when wait, unlock, relocking, deferred locking, or movable ownership is required.
  • Protect a shared condition and the data it describes with the same mutex.
  • Always use a predicate with condition_variable::wait.
  • Do not use a mutex as a replacement for designing clear ownership of shared data.

FAQ

Why does condition_variable::wait require std::unique_lock?

wait must unlock the mutex while the thread sleeps and lock it again before returning. std::unique_lock provides these operations; std::lock_guard does not.

Is std::unique_lock always better than std::lock_guard?

No. It provides features that may not be needed. For a simple scoped critical section, std::lock_guard is clearer and expresses the intent more precisely.

Is std::lock_guard faster than std::unique_lock?

std::lock_guard has a simpler interface and can have less bookkeeping. In most programs, choosing the correct synchronization design and keeping lock duration short matters far more than this small difference.

Can I use std::unique_lock without a condition variable?

Yes. Use it whenever you need to unlock early, relock, defer locking, attempt a lock, or move lock ownership.

Should notify_one() be called while holding the mutex?

The shared state must be updated while holding the mutex. Calling notify_one() after releasing the lock is a common pattern and can reduce unnecessary immediate blocking by the awakened thread.

Can multiple consumer threads wait for the same queue?

Yes. Use notify_one() when adding one item typically enables one consumer, and notify_all() when all waiting threads may need to reevaluate the condition, such as during shutdown.

Does a condition-variable notification store a message for later?

No. A notification is not queued as data. The actual state, such as items in the queue or a shutdown flag, must be stored and checked by the wait predicate.

Take Quiz

Mini Project

Description

Build a small thread-safe message queue with one producer and one consumer. The consumer waits efficiently when the queue is empty instead of repeatedly checking it in a loop. This demonstrates why queue modifications use a mutex and why waiting uses std::unique_lock.

Goal

Implement a producer-consumer queue in which the consumer receives three messages and exits cleanly.

Requirements

  • Protect all queue access with one std::mutex.
  • Add three integer messages from a producer thread.
  • Make the consumer wait when the queue is empty.
  • Use a condition-variable predicate to avoid consuming from an empty queue.
  • End the consumer after it receives a sentinel value.
Take Quiz

Keep learning

Related questions

View more