AlgoMaster Logo

std::lock_guard

Medium Priority28 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

std::lock_guard (since C++11, in <mutex>) is the simplest RAII wrapper around a mutex. Its constructor locks the mutex, its destructor unlocks it, and that's all. No other state, no other methods. This chapter walks through why manual lock/unlock is fragile, what lock_guard does about it, the rules it imposes (no early unlock, no moving, no copying), nested locking with multiple guards, and the deadlock risks that come with that.

The Problem with Manual lock/unlock

std::mutex::lock() and std::mutex::unlock() work fine on tiny examples, but every realistic critical section has multiple exit paths, and manual unlock has to handle each one.

The function locks the mutex, then throws when stock runs out. The exception propagates up the call stack, but stockMutex.unlock() never runs. From that moment on, every other thread that calls buy blocks forever on stockMutex.lock() because no one will release it. The program looks alive but stops making progress.

Early returns have the same problem:

So does any code path that calls a function which might throw:

If log.record throws, the unlock never runs and the mutex is held forever. The bug isn't visible at this call site, but the symptom (a future caller hanging on lock()) shows up later in code that looks unrelated.

The pattern that fixes all of these at once is RAII: tie the lock's lifetime to a stack object whose destructor releases the lock. When the function returns by any path, return, exception, or fall-through, the destructor runs and unlocks the mutex. C++ provides std::lock_guard for exactly this.

What lock_guard Does

std::lock_guard is a class template parameterized on a mutex type. Its constructor takes a mutex by reference and calls lock() on it. Its destructor calls unlock(). That's the entire interface relevant to most code.

Output:

The second call to buy throws after the guard is constructed. As the exception unwinds the stack, the guard's destructor runs and calls stockMutex.unlock(). The third call to buy succeeds because the mutex is free.

Since C++17, the template argument is deducible from the constructor:

Both forms compile to the same code. The C++17 form is slightly easier to read; older code uses the explicit version.

Picturing the Lifecycle

The diagram traces what happens from the moment a function enters to the moment it returns. Constructing the guard locks the mutex. The critical section runs. Whichever exit path the function takes (normal return, thrown exception, or hitting the closing brace), the guard's destructor runs and unlocks the mutex. There is no path through the function that leaves the mutex locked.

This is the key property of lock_guard: exception safety is automatic, because C++ guarantees stack-allocated destructors run during stack unwinding.

A Concrete Example: Shared Counter

A short multithreaded example that uses lock_guard to protect a shared counter. Five threads each increment the counter 1,000 times, and the final value should be 5,000.

Output:

The counter ends at exactly 5,000 every run. Each iteration constructs a lock_guard (locking the mutex), increments orderCount, then destroys the guard at the end of the loop body (unlocking the mutex). The next iteration waits on lock() if another thread happens to hold the mutex.

Without the guard, the loop produces a different (smaller) number on every run because orderCount += 1 is a read-modify-write that interleaves across threads.

Quick Check: Why does this version still race?

<details> <summary>Answer</summary>

It doesn't race; this version is correct. The guard is constructed once at the top of the function and the lock is held for the entire 1,000-iteration loop. The result is still 5,000. The difference is granularity: this version makes each thread run all 1,000 increments back-to-back without giving up the lock, which destroys concurrency. The earlier version locks per iteration, which lets threads interleave (slower per increment, but no thread monopolizes the mutex).

</details>

Limiting the Critical Section

A common pattern is to introduce a scope block so the lock is held only over the exact piece of code that needs it.

The tax calculation and the email send don't touch orders, so they don't need the lock. Wrapping just the push_back in a scope block keeps the critical section as small as possible. Other threads can run their tax calculations and emails in parallel; only the brief moment of writing to orders serializes.

Holding a lock during slow operations (I/O, network calls, expensive computation) prevents multithreaded code from scaling. Compute outside the lock, write inside, leave fast.

What lock_guard Cannot Do

lock_guard is deliberately minimal. The constraints are part of the design.

No early unlock. Once constructed, the lock is held until the guard goes out of scope. There's no unlock() member function on lock_guard.

If the critical section needs to release the mutex partway through (to do unsynchronized work, then re-lock), use std::unique_lock instead.

Not movable, not copyable. A lock_guard cannot be returned from a function, stored in a container, or passed by value. The copy and move constructors are explicitly deleted.

Use std::unique_lock when the lock's ownership needs to travel.

Constructor only takes a mutex. There's no way to ask lock_guard to skip the lock or to try-lock without blocking. It either locks the mutex or it doesn't exist.

The one tag form lock_guard accepts is std::adopt_lock, which assumes the mutex is already locked by the current thread and skips the lock() call. The destructor still calls unlock(). This shows up in multi-mutex idioms with std::lock:

Quick Check: Which of these is a valid use of lock_guard?

  • A) std::lock_guard<std::mutex> g(m); g.unlock();
  • B) std::lock_guard<std::mutex> g = std::lock_guard<std::mutex>(m);
  • C) std::lock_guard<std::mutex> g(m, std::adopt_lock); (when m is already locked)
  • D) auto g = makeLockGuard(); returning a lock_guard from a function

<details> <summary>Answer</summary>

C. adopt_lock is the one tag form lock_guard supports, used when the mutex was locked by hand or by std::lock. A is invalid (no unlock() member). B and D are invalid because lock_guard is neither movable nor copyable.

</details>

A Preview of unique_lock

unique_lock is lock_guard's flexible counterpart. Use it when the critical section needs any of:

  • Early unlock and re-lock during the lock's lifetime
  • Deferred locking (construct the guard now, lock the mutex later)
  • Try-lock (attempt to lock without blocking)
  • Timed locking (try to lock for up to a duration)
  • Transferring lock ownership across scopes (move semantics)
  • Working with condition variables, which require unique_lock

A short comparison:

Featurelock_guardunique_lock
Locks on constructionYes (or via adopt_lock)Yes, deferred, or try
Unlocks on destructionYesYes (if currently owned)
Early unlock()NoYes
MovableNoYes
Works with condition variablesNoYes
Per-lock memory costJust a mutex pointerMutex pointer + ownership flag

For simple cases ("lock at the top of this scope, unlock at the bottom"), use lock_guard. It's smaller, simpler, and harder to misuse. Reserve unique_lock for cases that need its extra capabilities.

Nested Locking: Multiple Guards in One Scope

A function that needs to update two pieces of state protected by different mutexes acquires both guards in sequence.

Output:

Both threads acquire stockMutex first, then auditMutex. While a thread holds both, the other thread waits at lock(stockMutex). The destruction order of the guards is reverse construction order: auditLock is destroyed first (and auditMutex released), then stockLock (and stockMutex released). That's the same order as a normal C++ stack-unwinding.

This pattern is safe as long as every code path that needs both mutexes acquires them in the same order. Mixing orders across functions is where deadlocks come from.

The Deadlock Risk

Consider two functions that both touch stock and auditLog, but in different orders:

If thread A runs buyAndLog while thread B runs summarizeAndAdjust, here's a possible interleaving:

  1. Thread A locks stockMutex.
  2. Thread B locks auditMutex.
  3. Thread A tries to lock auditMutex. It blocks (B holds it).
  4. Thread B tries to lock stockMutex. It blocks (A holds it).

Both threads are now waiting on the other. Neither can make progress. This is a deadlock, and once it happens there is no automatic recovery in C++. The threads stay blocked until the process is killed.

The diagram shows the cyclic wait: each thread holds one mutex and waits for the one the other holds. As long as that cycle exists, the program is stuck.

The first defense against deadlock is discipline: always acquire mutexes in the same order across the entire codebase. Document the order. Enforce it in code review. Use a consistent naming convention if it helps (alphabetical, by declaration order, by some logical hierarchy).

The second defense is the standard library's deadlock-safe multi-mutex tools. std::lock(m1, m2, ...) locks several mutexes at once using an internal algorithm that won't deadlock regardless of acquisition order across threads. std::scoped_lock<M1, M2, ...> (since C++17) is the RAII wrapper around it: construct it with multiple mutexes and it locks all of them deadlock-free.

The takeaway is that lock_guard works for single-mutex scopes, but for two-or-more mutexes acquired together, scoped_lock (or std::lock + adopt_lock) is the safer choice.

Quick Check: Which of these is the safest way to lock two mutexes together in one function?

  • A) std::lock_guard<std::mutex> a(m1); std::lock_guard<std::mutex> b(m2);
  • B) std::scoped_lock lock(m1, m2);
  • C) m1.lock(); m2.lock(); ... m2.unlock(); m1.unlock();

<details> <summary>Answer</summary>

B. std::scoped_lock (C++17) uses an algorithm that avoids deadlock regardless of the order another thread acquires the same mutexes. Option A is safe only if every other function acquires m1 before m2. Option C is the most fragile, since any exception between the two locks leaks the first one.

</details>

When Not to Use lock_guard

lock_guard is the default, but it's not always the answer. Situations where it gets in the way:

  • The critical section needs to release the mutex partway through. Use unique_lock and call .unlock() / .lock() as needed.
  • The lock should be acquired lazily, after some setup. Use unique_lock with std::defer_lock.
  • Waiting on a condition variable. Condition variables require unique_lock; the wait mechanism needs to unlock and re-lock the mutex internally.
  • Returning a lock from a factory function. lock_guard can't be moved. unique_lock can.
  • Trying to lock without blocking. unique_lock with std::try_to_lock exposes owns_lock().

For every other situation, lock_guard is the smaller, simpler, harder-to-misuse choice.

A Worked Example: Per-Account Logging

A small program that uses one mutex per account to record transactions. Each account's mutex protects only that account, so transactions on different accounts run in parallel. lock_guard fits because each function locks one mutex for the duration of one short critical section.

Output:

Two short critical sections live inside deposit. The first locks accountsMutex only long enough to look up (or create) the account in the map. That lock is released before the per-account lock is acquired, which keeps the global map's mutex free for other lookups while a thread is busy modifying one account. The second lock is per-account, so deposits to account 1 don't block deposits to account 2.

The choice of lock_guard for both locks fits because each scope is simple: lock at the top, work, unlock at the bottom, no need to release early. A unique_lock would work too but adds capabilities the code doesn't use, with a small overhead in object size.

Interview Questions

Q1: What does the `lock_guard` destructor do, and why does that make it exception-safe?

The destructor calls unlock() on the wrapped mutex. Because C++ guarantees that destructors of stack-allocated objects run during stack unwinding (whether the scope exits normally or via an exception), the mutex is released on every exit path. There is no way for a function that uses lock_guard to leave the mutex locked after returning, so exceptions in the critical section don't cause deadlock the way they would with manual lock/unlock.

Q2: Why can't `std::lock_guard` be unlocked partway through its scope, and what should you use instead?

lock_guard doesn't expose an unlock() member. The design is intentional: it's the minimal RAII wrapper, "lock here, unlock at end of scope, nothing else". For critical sections that need to release the mutex before the scope ends (and possibly re-lock later), use std::unique_lock, which exposes lock(), unlock(), and try_lock() members and tracks whether it currently owns the lock.

Q3: A thread calls a function that locks `lock_guard A`, then `lock_guard B`. Another thread calls a different function that locks `B`, then `A`. What's the failure mode?

Classic deadlock. If thread 1 acquires A, thread 2 acquires B, then thread 1 tries to acquire B and thread 2 tries to acquire A, both threads block on each other's held mutex. The program hangs. The fix is to enforce a consistent acquisition order across the whole codebase, or to use std::scoped_lock(A, B) (or std::lock + adopt_lock), which locks multiple mutexes safely regardless of the order another thread tries.

Q4: What's the difference in object size and runtime cost between `lock_guard` and `unique_lock`?

lock_guard typically stores just a pointer or reference to the mutex (one pointer-sized field). unique_lock stores that plus a boolean tracking whether it currently owns the lock, so it's slightly larger. At runtime, unique_lock's destructor checks the ownership flag before calling unlock(), which adds one branch per destruction. For most code the difference is negligible; for hot loops with millions of lock/unlock pairs per second, lock_guard is the cheaper choice.

Q5: When should you introduce an explicit `{ }` scope around a `lock_guard` rather than letting it live for the whole function body?

When part of the function does work that doesn't need the lock, the scope block keeps the critical section short. Holding a lock during I/O, network calls, or expensive computation makes other threads queue up behind it. Pre-compute outside the lock, then enter a { ... lock_guard ... } block to do the brief shared-state update, then continue with non-locked work after. Shrinking the critical section is a common concurrency tuning technique.

Exercises

Exercise 1: Take this buggy code and fix it with lock_guard:

<details> <summary>Solution</summary>

</details>

Exercise 2: Write a program where 10 threads each push 100 strings into a shared std::vector<std::string>, protected by a mutex with lock_guard. After joining all threads, print the total size.

Expected Output:

<details> <summary>Solution</summary>

</details>

Exercise 3: What does this code print, and why?

<details> <summary>Solution</summary>

The program deadlocks (or invokes undefined behavior) and never prints anything. std::mutex is non-recursive, so the second lock_guard calls m.lock() on a mutex already held by the same thread. The thread blocks on itself forever. To allow the same thread to lock multiple times, use std::recursive_mutex (and std::lock_guard<std::recursive_mutex>), or restructure the code so only one lock is acquired.

</details>

Exercise 4: Modify the per-account example so that a transfer(from, to, amount) function moves money between two accounts. Use std::scoped_lock (not two separate lock_guards) so the function is deadlock-safe even if two threads call transfer(1, 2, ...) and transfer(2, 1, ...) at the same time.

Expected Output:

<details> <summary>Solution</summary>

</details>

Exercise 5: What's wrong with this code?

<details> <summary>Solution</summary>

The line creates a temporary lock_guard that is destroyed at the end of the full expression, that is, immediately. The mutex is locked and unlocked on the same line, before shared += 1 runs. The critical section is unprotected.

Fix: give the guard a name so it lives for the rest of the scope.

</details>

Exercise 6: Write a Logger class that wraps a std::vector<std::string> of log lines and a std::mutex. Expose two methods: log(const std::string&) and dump(). Both should use lock_guard to protect the vector. Spawn three threads that each log 5 messages, then print all messages.

Expected Output (order may vary, 15 messages total):

<details> <summary>Solution</summary>

</details>

Exercise 7: Predict the output of this code:

Expected Output:

<details> <summary>Solution</summary>

The first print runs inside the scope block while the lock is held; count was just incremented to 1. The scope block ends, the guard's destructor releases the mutex, and the second print reads count again (no other thread is modifying it here, so it's still 1).

</details>

Quiz

lock_guard Quiz

10 quizzes