Question
std::unique_lock vs std::lock_guard in C++ Mutex Synchronization
Question
I have two C++ synchronization use cases:
- Two threads share a queue, and I need to synchronize access to that queue.
- 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_guardis a small, simple scoped lock. Once constructed, it owns the lock until it is destroyed.std::unique_lockcan 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_guardis 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_lockis 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.
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:
locklocksqueueMutex. The consumer now has exclusive access toqueue.waitevaluates!queue.empty()while the mutex is locked.- If the queue is empty,
waitatomically releasesqueueMutexand puts the consumer thread to sleep. - Because the mutex was released, a producer can lock it, add an item to the queue, and call
condition.notify_one(). - The consumer wakes, but it first reacquires
queueMutex. waitchecks the predicate again. If the queue has an item, it returns.- The consumer reads and removes the front item while still holding the mutex.
- When
lockleaves 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_lockto 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.
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
| Feature | std::lock_guard | std::unique_lock |
|---|---|---|
| Locks in constructor by default | Yes | Yes |
| Unlocks in destructor | Yes | Yes |
Can call unlock() manually | No | Yes |
| Can lock again after unlocking | No | Yes |
| Can defer locking | No | Yes |
Can use try_to_lock | No | Yes |
| Can be moved | No | Yes |
Works with std::condition_variable::wait | No | Yes |
| Best default for a simple scoped lock | Yes | Usually unnecessary |
Both types provide exception-safe mutex release. Therefore, the difference is not basic safety; it is the amount of control provided.
| Situation | Recommended choice |
|---|---|
| Protect a short block of shared-state access | std::lock_guard<std::mutex> |
Wait on std::condition_variable | std::unique_lock<std::mutex> |
| Unlock before the end of the current scope | std::unique_lock<std::mutex> |
| Need delayed or conditional locking | std::unique_lock<std::mutex> |
| No special locking behavior needed | std::lock_guard<std::mutex> |
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_guardby default for uncomplicated critical sections. - Use
std::unique_lockwhenwait,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.
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.
Keep learning
Related questions
Advantages of Brace Initialization in C++
Learn why C++ brace initialization is often clearer and safer than other object initialization styles, with examples and common pitfalls.
Basic Rules and Idioms for Operator Overloading in C++
Learn the core rules, syntax, and common idioms for operator overloading in C++, including member vs non-member operators.
C++ Aggregates, Trivial Types, Trivially Copyable Types, and PODs Explained
Learn what aggregates, trivial types, trivially copyable types, and PODs mean in C++, how they differ, and why they matter.