C++ / CONCURRENCY AND PARALLELISM
lock_guard, unique_lock, and scoped locking
Choose between lock_guard, unique_lock, and scoped_lock, and shape critical sections so every exit path releases the mutex automatically.
What you will learn
- Use a named scoped guard so every return and exception path releases the mutex
- Default to lock_guard; use unique_lock only when a lock must move, pause, or wait
- Take two mutexes with one std::scoped_lock instead of two independent guards
- Shrink a critical section with a nested block or an explicit unique_lock::unlock()
Understanding lock_guard, unique_lock, and scoped locking
A guard is not a helper function, it is an object whose lifetime is the critical section. std::lock_guard's constructor calls m.lock() and its destructor calls m.unlock(), so the compiler is what inserts the unlock: at the closing brace, at every return, and while an exception unwinds the frame. That is why hand-written lock/unlock pairs rot the moment somebody adds an early return or calls something that throws, because there is one lock call and now several exits. With a guard, the number of exits stops mattering.
The three wrappers differ only in what they let you do with that ownership. lock_guard holds a mutex reference and nothing else: it cannot be moved, has no unlock(), and always releases at the end of its scope, and that rigidity is what makes it hard to misuse. unique_lock adds a boolean "do I own it" flag, so it can be constructed with defer_lock, adopt_lock, or try_to_lock, be unlocked and relocked in the middle of a scope, be moved out to a caller, and its destructor tests the flag before unlocking. std::scoped_lock (C++17) is variadic: with one mutex it behaves like lock_guard, and with several it acquires them all through std::lock's back-off algorithm instead of taking them one after another.
Because the scope is the critical section, you tune contention by tuning scope. Open a nested block around only the statements that touch shared state, copy out the values you need, and let the block close before doing I/O, allocation, or calling a callback you do not control, since a lock held across user code can be re-entered and locking a non-recursive std::mutex twice from one thread is undefined behaviour with any wrapper. Picking the weakest wrapper that works also documents intent: a lock_guard tells the reader that this scope and nothing else is protected, while a unique_lock warns that ownership may pause or leave.
<iostream>
<mutex>
<stdexcept>
<string>
<vector>
std::mutex m;
std::vector<std::string> log_lines;
void append(const std::string& line) {
std::lock_guard<std::mutex> guard(m); // constructor locks
if (line.empty())
throw std::invalid_argument("empty line"); // guard is still destroyed
log_lines.push_back(line);
} // destructor unlocks
int main() {
append("first");
try {
append("");
} catch (const std::invalid_argument& e) {
std::cout << "caught: " << e.what() << '\n';
}
// If the throw had leaked the lock, this try_lock would fail.
if (m.try_lock()) {
std::cout << "mutex free after the throw\n";
m.unlock();
}
std::lock_guard<std::mutex> guard(m);
std::cout << "lines: " << log_lines.size() << '\n';
for (const std::string& line : log_lines)
std::cout << " " << line << '\n';
}
A lock is an object whose lifetime is the critical section, so acquisition is construction and release is destruction on every exit path.
Worked examples
unique_lock: deferred, released early, moved out
Shows the ownership flag inside unique_lock by deferring the lock, dropping it before the scope ends, and returning a still-held lock from a function.
<iostream>
<mutex>
std::mutex m;
int shared_total = 0;
std::unique_lock<std::mutex> acquire() {
std::unique_lock<std::mutex> lk(m);
std::cout << "acquire: owns = " << lk.owns_lock() << '\n';
return lk; // mutex is still locked on the way out
}
int main() {
std::unique_lock<std::mutex> lk(m, std::defer_lock);
std::cout << "deferred: owns = " << lk.owns_lock() << '\n';
lk.lock();
shared_total += 10;
lk.unlock(); // critical section ends here, not at the brace
std::cout << "after unlock: owns = " << lk.owns_lock() << '\n';
{
std::unique_lock<std::mutex> held = acquire();
shared_total += 5;
std::cout << "in caller: owns = " << held.owns_lock() << '\n';
} // released here
std::cout << "total = " << shared_total << '\n';
}
Example explained
Line 1std::defer_lock builds the wrapper without locking, so owns_lock() prints 0 and the mutex is untouched.
Line 2lk.unlock() releases the mutex and clears the flag, so the destructor at the end of main does nothing at all.
Line 3acquire() returns the unique_lock by value and the mutex stays locked for the caller; lock_guard cannot do this because it is not movable.
Line 4held is destroyed at the closing brace of the inner block, which is the point where the mutex is finally released.
scoped_lock over two mutexes
Two threads transfer between the same pair of accounts in opposite directions, each taking both mutexes with a single std::scoped_lock.
<iostream>
<mutex>
<thread>
struct Account {
std::mutex m;
int balance = 100;
};
void transfer(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.m, to.m); // both mutexes, acquired via std::lock
from.balance -= amount;
to.balance += amount;
}
int main() {
Account a, b;
std::thread t1([&] { for (int i = 0; i < 1000; ++i) transfer(a, b, 1); });
std::thread t2([&] { for (int i = 0; i < 1000; ++i) transfer(b, a, 1); });
t1.join();
t2.join();
std::cout << "a = " << a.balance << ", b = " << b.balance << '\n';
std::cout << "sum = " << a.balance + b.balance << '\n';
}
Example explained
Line 1std::scoped_lock lock(from.m, to.m); locks both through std::lock, which backs off and retries instead of holding the first mutex while blocking on the second.
Line 2t1 and t2 pass the accounts in opposite orders, so two separate lock_guards written in argument order would be an ordering hazard here.
Line 3One destructor releases both mutexes together at the end of transfer.
Line 4If from and to were the same account the call would lock one mutex twice, which is undefined behaviour, so a real transfer() needs an early return for self-transfers.
Line 5The class template argument deduction in std::scoped_lock lock(...) and the type itself both require C++17.
Important notes
lock_guard and scoped_lock have no unlock() and cannot be moved on purpose, so a function that returns a mutex it is still holding must return std::unique_lock<std::mutex>; condition_variable::wait also accepts only that type.
std::scoped_lock with a single mutex is equivalent to lock_guard, but written with no arguments it locks nothing and still compiles, so check the argument list when refactoring.
Common mistakes
Forgetting to name the guard: std::lock_guard<std::mutex>{m}; creates a temporary that is destroyed at the semicolon, so the code below runs unlocked and the data race is back, while the parenthesised std::lock_guard<std::mutex>(m); is parsed as declaring a variable called m and fails to compile because lock_guard has no default constructor.
Calling m.lock() by hand and then constructing a guard on the same mutex to "be safe": that is a second lock on a non-recursive std::mutex from the same thread, which is undefined behaviour and usually hangs. Use std::lock_guard<std::mutex> g(m, std::adopt_lock) when the mutex is already held.
Calling lk.unlock() on a unique_lock and then still reading or writing the guarded data further down the same scope; it compiles without a warning and races, and a second unlock() on a lock that no longer owns the mutex throws std::system_error.
Try it yourself
Change, predict, then run
Extend the append() example with a function that returns a std::unique_lock<std::mutex> for the global mutex, use it in main to push two lines while the caller holds the lock, and print owns_lock() before and after calling unlock() on it. Finish main with m.try_lock() to prove no path left the mutex held.
Open the C++ workspaceCheck your understanding
A function locks a mutex with std::unique_lock, calls lk.unlock() halfway through, then returns. What does the unique_lock destructor do?
- Nothing: it checks its ownership flag, sees the lock was already released, and skips the unlock
- Unlocks the mutex a second time, which is undefined behaviour
- Throws std::system_error because the mutex is no longer owned
- Leaves the mutex locked until some other thread unlocks it
Show answer
unique_lock stores a mutex pointer plus a boolean that unlock() clears, so its destructor is a no-op once ownership is gone. Option 1 is tempting if you picture unique_lock as a lock_guard, whose destructor really does unlock unconditionally; that unconditional release is exactly why lock_guard offers no unlock() member in the first place.