std::unique_lock (since C++11, in <mutex>) is the flexible RAII wrapper for mutexes. It does everything std::lock_guard does, plus a list of extras that the simpler wrapper omits: early unlock and re-lock, deferred locking, try-locking, timed locking, transfer of ownership across scopes, and integration with condition variables. The cost is a slightly larger object and a small per-operation overhead. This chapter covers when to use unique_lock over lock_guard, the tag constructors that change its behavior, the timed variants, the condition-variable connection, and a comparison table at the end.
lock_guard is intentionally minimal. Its design says "lock here, unlock at the end of the scope, no other state." That covers most critical sections, but several common patterns don't fit:
unique_lock was designed to handle all of these. It carries one extra piece of state compared to lock_guard: a boolean that tracks whether it currently owns the mutex. That boolean enables the rest of the interface.
The simplest use looks identical to lock_guard:
Output:
If that's all a critical section needs, prefer lock_guard. The rest of this chapter is about the cases where lock_guard isn't enough.
The most useful unique_lock feature is the ability to release the mutex partway through the critical section and re-acquire it later. The wrapper exposes lock(), unlock(), and try_lock() as member functions, mirroring the underlying mutex.
Calling lock.unlock() releases queueMutex and flips the wrapper's ownership flag to false. Calling lock.lock() re-acquires the same mutex and flips the flag back to true. The destructor checks the flag: if the wrapper currently owns the lock, it unlocks; otherwise it does nothing. This is the mechanism that makes both unique_lock's flexibility and its safety work together.
The pattern of "grab the shared queue, take one item, release, do slow work, re-grab" is a common reason to use unique_lock over lock_guard. The same shape appears in producer-consumer code, in batch processors that yield the lock to other workers, and in any function that needs to do I/O after a small shared-state update.
Calling unlock() twice on the same unique_lock (or lock() when it already owns the mutex) throws std::system_error. The ownership flag prevents the underlying mutex from being double-unlocked, which would be undefined behavior.
unique_lock has three tag-dispatched constructors that change what the wrapper does when it's built. The tags are empty structs that exist purely to pick between overloads.
| Tag | Behavior |
|---|---|
std::defer_lock | Construct the wrapper without locking. Call lock() later. |
std::try_to_lock | Try to lock without blocking. Check owns_lock() to see if it worked. |
std::adopt_lock | Assume the mutex is already locked by the current thread. Take ownership without locking again. |
std::defer_lock: Lock LaterDeferred locking is useful when the lock object should exist before the lock is acquired, often because something else needs to happen first or because multiple mutexes will be locked together.
Output:
std::lock(invLock, cartLock) runs an internal algorithm that locks both mutexes without risking deadlock, even if another thread is trying to lock them in the opposite order. Without defer_lock, the wrappers would already hold their mutexes by the time std::lock ran, which would defeat the purpose.
Since C++17, std::scoped_lock(m1, m2) is the simpler way to lock multiple mutexes deadlock-free. defer_lock is still useful when more flexibility is needed (for example, when locks are acquired conditionally or in a loop).
std::try_to_lock: Don't Blocktry_to_lock mirrors the underlying mutex's try_lock(): the constructor attempts to acquire the lock and returns immediately whether it succeeded or not. The result is queried via owns_lock().
Output:
The first call sees the mutex held by the slow thread and skips. The second call (after slow.join()) acquires the mutex normally. try_to_lock is the standard way to "do the work if you can, otherwise do something else", common in throttling code and back-pressure handling.
Quick Check: What does owns_lock() return after this sequence?
<details> <summary>Answer</summary>
false. After defer_lock, the wrapper doesn't own the mutex. lock.lock() acquires it (owns = true). lock.unlock() releases it (owns = false). The destructor will check the flag and do nothing.
</details>
std::adopt_lock: Take Over an Already-Locked Mutexadopt_lock is the inverse of defer_lock: it says "the mutex is already locked by this thread; just take ownership of it and unlock it when destroyed." Don't lock again.
This is mostly used with std::lock(m1, m2), which locks multiple mutexes deadlock-free but returns nothing. After std::lock, the mutexes are held but no RAII wrapper owns them. adopt_lock is how those raw locks are handed to RAII for cleanup:
std::scoped_lock makes this pattern unnecessary in C++17 code, but adopt_lock still appears in older codebases and in cases where the multi-mutex locking interleaves with other operations.
unique_lock supports the timed-mutex interface when wrapped around a std::timed_mutex (or std::recursive_timed_mutex). Two member functions are exposed:
| Function | Behavior |
|---|---|
try_lock_for(duration) | Tries to lock for up to the duration. Returns true on success. |
try_lock_until(time_point) | Tries to lock until an absolute time point. Returns true on success. |
A constructor overload also accepts a duration or time point directly:
A worked example: a request handler that gives up on a contended lock after 50 milliseconds rather than blocking indefinitely.
Output:
The first call sets a 50 ms deadline. The slow thread holds the mutex for 200 ms, so the deadline expires and owns_lock() returns false. The second call finds the mutex free and acquires it immediately.
Timed locking is useful when responsiveness matters more than completing the work. A request handler facing a contended lock might rather return a 503 to the client than hang for seconds.
A timed_mutex is slightly slower than a plain std::mutex because the underlying implementation consults a clock and handles timeouts. Use it where deadlines are needed, not by default.
std::condition_variable::wait() is one of the places unique_lock is mandatory. Condition variables let a thread sleep until another thread signals that some shared state has changed, and the wait operation has to atomically release the mutex (so other threads can update the state) and reacquire it (so the waker's notification is observed).
The interface is:
The lock parameter is non-const, because the wait code calls lock.unlock() and lock.lock() on it internally. lock_guard doesn't expose those, so it can't be used here.
A producer-consumer example:
Output:
Inside consumer, the wait call passes the unique_lock by reference. Internally, wait calls lock.unlock(), suspends the thread, and once woken, calls lock.lock() again before checking the predicate. None of that is possible with lock_guard. The takeaway is that any code using wait needs unique_lock.
unique_lock has a move constructor and move assignment operator. The moved-from wrapper releases ownership (without unlocking the mutex); the moved-to wrapper takes ownership. This lets a lock be returned from a function, stored in a container, or transferred between scopes.
Output:
This pattern lets a factory function return a "lock token". Callers receive ownership of the lock, do their work, and the lock is released when their scope ends. lock_guard cannot do this because its move constructor is deleted.
Move support also makes unique_lock usable in containers, including the multi-mutex idioms that store lock wrappers and acquire them in a loop. That's a rare need but the option is there.
Quick Check: What's wrong with this code, and how would you fix it?
<details> <summary>Answer</summary>
lock_guard has its copy and move constructors explicitly deleted, so returning it from a function fails to compile. Use std::unique_lock<std::mutex> instead; it's movable, so the same pattern works.
</details>
A side-by-side reference:
| Feature | std::lock_guard | std::unique_lock |
|---|---|---|
| Lock on construction | Yes (or via adopt_lock) | Yes, deferred, try, adopt, or timed |
| Unlock on destruction | Always | Only if currently owned |
Early unlock() member | No | Yes |
Early lock() member | No | Yes |
try_lock() member | No | Yes |
try_lock_for() / try_lock_until() | No | Yes (with timed mutex) |
| Movable | No | Yes |
| Copyable | No | No |
owns_lock() query | No | Yes |
| Required for condition variables | No | Yes |
| Memory cost | One mutex reference | Reference + ownership flag |
| Runtime cost | Lock + unlock | Lock + unlock + ownership branch on destruct |
The flowchart distills the choice. If the critical section is a simple "lock, work, unlock" with no extras, lock_guard is the lighter and harder-to-misuse pick. Anything more complicated (deferred locking, conditional locking, condition-variable waits, moving the lock between scopes, timed acquisition) pushes the choice to unique_lock.
unique_lock is slightly larger and slightly slower than lock_guard. The extra cost comes from two sources:
unlock().In absolute terms the difference is small. A typical lock_guard is one pointer (8 bytes on 64-bit). A typical unique_lock is one pointer plus a boolean, often padded to 16 bytes. The destruction branch is one comparison; the CPU branch predictor handles it well, so it's effectively free in steady-state code.
For most use cases the cost is irrelevant. Where it matters is tight inner-loop locking with millions of lock/unlock pairs per second. In benchmarks, replacing unique_lock with lock_guard in such loops shows a measurable but small speedup, typically a few percent. Outside of those hot paths, the choice should be driven by readability and capability, not by performance.
The rule of thumb: use lock_guard when the scope only ever does "lock here, unlock there". Use unique_lock when the scope needs any of the extras. The byte difference rarely matters.
A small style note: many code bases standardize on unique_lock everywhere for consistency, accepting the minor overhead in exchange for a single wrapper to think about. Other code bases prefer the lighter lock_guard everywhere it works. Both are reasonable. Pick a convention and stick with it.
A small program that uses unique_lock for its full feature set. A worker pulls orders from a queue, but if the queue is empty, it waits for a producer to notify it. The processing step is slow, so the worker releases the lock before doing the work. If the queue is contended for too long when the worker tries to push results back, it times out and logs a warning instead of blocking.
Output:
The worker uses unique_lock three times for three different reasons:
incoming lock is passed to condition_variable::wait, which requires unique_lock.lock.unlock() to release the queue mutex before doing slow processing. lock_guard can't do that.results lock uses the timed constructor, which is also exclusive to unique_lock.The producer side uses lock_guard for the short critical sections that push items and set done. Each wrapper is matched to the task at hand; neither is used everywhere by default.
Q1: What's the minimum extra state `std::unique_lock` carries compared to `std::lock_guard`, and what does it enable?
unique_lock carries a boolean flag that tracks whether it currently owns the mutex. The flag enables every feature that distinguishes it from lock_guard: early unlock()/lock() (they flip the flag), try_to_lock/defer_lock/adopt_lock constructors (they set the initial flag value), move semantics (move sets the source's flag to false), owns_lock() (returns the flag), and the destructor check that avoids double-unlock when the wrapper doesn't currently own the lock.
Q2: Why does `std::condition_variable::wait` require `unique_lock` instead of `lock_guard`?
The wait operation atomically unlocks the mutex (so other threads can change shared state and signal the condition variable) and then re-locks it before returning. That requires the wrapper to expose lock() and unlock() member functions, and lock_guard doesn't. unique_lock does, and it also tracks ownership state so the destructor handles the case correctly whether the wait returned with the lock held or not.
Q3: When would you use `std::defer_lock`, and what problem does it solve?
defer_lock constructs a unique_lock without acquiring the mutex, so the lock can be obtained later. The most common use is locking multiple mutexes deadlock-free with std::lock(a, b), which requires the wrappers to exist but not yet own the locks. Another use is conditional locking ("construct the wrapper, then decide whether to lock based on runtime state"). Without defer_lock, the constructor would immediately call lock(), which would defeat the pattern.
Q4: A function locks two mutexes by hand using `std::lock(m1, m2)`. How do you get RAII cleanup for them?
After std::lock(m1, m2), both mutexes are locked but no RAII wrapper owns them. Wrap each one with unique_lock (or lock_guard) using the std::adopt_lock tag, which tells the wrapper "the mutex is already locked, just take ownership and unlock on destruction." The cleaner C++17 alternative is std::scoped_lock<Mutex, Mutex> lock(m1, m2);, which combines the deadlock-free locking and the RAII wrapping in one step.
Q5: What happens if you call `unlock()` twice on the same `unique_lock`?
The second call throws std::system_error with errc::operation_not_permitted because the ownership flag is already false. The wrapper deliberately doesn't double-unlock the underlying mutex (that would be undefined behavior); it raises an error instead. This is a benefit of the ownership flag: the wrapper knows whether it currently owns the lock and refuses to do something invalid.
Exercise 1: Write a function that locks a mutex, prints "locked", releases the lock with unlock(), prints "unlocked", re-acquires it with lock(), prints "locked again", and lets the destructor release it. Use a unique_lock.
Expected Output:
<details> <summary>Solution</summary>
</details>
Exercise 2: Use std::try_to_lock to write a function tryBuy(int amount) that decrements stock if the mutex is free, or prints "busy" if not. Demonstrate both cases by spawning a thread that holds the mutex for 100 ms.
Expected Output:
<details> <summary>Solution</summary>
</details>
Exercise 3: Write a function acquireWithTimeout(std::timed_mutex& m, int ms) that returns a unique_lock<timed_mutex>. The lock should be acquired within ms milliseconds; if it times out, the returned lock should not own the mutex. Test it with a contended mutex.
Expected Output:
<details> <summary>Solution</summary>
</details>
Exercise 4: Predict the output:
Expected Output:
<details> <summary>Solution</summary>
defer_lock constructs the wrapper without locking, so owns_lock() returns false (printed as 0). After lock.lock(), the mutex is held and owns_lock() returns true (printed as 1). After lock.unlock(), ownership returns to false. The destructor then checks the flag and does nothing extra.
</details>
Exercise 5: Fix this code so unique_lock is moved out of the function correctly:
<details> <summary>Solution</summary>
lock_guard is not movable, so the return won't compile. Switch to unique_lock.
</details>
Exercise 6: Write a producer-consumer setup with one consumer thread that uses std::condition_variable::wait (and therefore unique_lock) to wait for items in a queue. The main thread should push 5 integers and then set a done flag. The consumer should print each integer.
Expected Output:
<details> <summary>Solution</summary>
</details>
Exercise 7: What's wrong with this code, and what's the fix?
<details> <summary>Solution</summary>
The second lock.unlock() throws std::system_error because the wrapper no longer owns the mutex (the first unlock() released it). The ownership flag is already false, so unlocking again is an error rather than undefined behavior. Fix: remove the second call, or guard it with if (lock.owns_lock()) lock.unlock();.
</details>
Exercise 8: Modify the producer-consumer code from Exercise 6 so the consumer uses try_lock_for with a 100 ms timeout when accessing the queue, instead of wait. Print "Timed out" if the queue isn't ready in time. Test by sending only 2 items before exiting.
Expected Output:
<details> <summary>Solution</summary>
</details>
10 quizzes