Skip to Content
Course content

108: Lock Guards and Unique Locks

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this bug more times than I care to admit in production code: a mutex is locked at the start of a function, but because of a poorly placed return statement or an unexpected exception, the unlock() call is skipped. The result is a deadlock that doesn't happen during your quick local tests, but triggers only under heavy load in a live environment, freezing the entire system. When you're managing locks manually, you're essentially betting that you'll never make a logic error in your control flow. That's a bet I'm not willing to take.

The Danger of Manual Unlocking

Let's look at a classic example. Imagine we're writing a simple thread-safe bank account. You might be tempted to do something like this:

void Account::withdraw(double amount) {
    mtx.lock();
    if (amount > balance) {
        mtx.unlock(); // You have to remember this!
        throw std::runtime_error("Insufficient funds");
    }
    balance -= amount;
    mtx.unlock(); // And this!
}

On the surface, this looks correct. But it's fragile. If you add a new validation check and forget the unlock() call before the return or throw, you've just locked that account forever. Even worse, if balance -= amount somehow throws an exception (unlikely here, but common in more complex objects), the mutex stays locked. You're forcing yourself to manage the state of the lock manually across every possible exit path of the function. It's tedious and error-prone.

Letting the Destructor Do the Heavy Lifting

The better way is to use RAII (Resource Acquisition Is Initialization). In C++, we have std::lock_guard for exactly this purpose. Instead of calling lock and unlock, you create a local object that manages the mutex for you. When that object goes out of scope—whether via a return, an exception, or just reaching the end of the block—its destructor automatically releases the mutex.

void Account::withdraw(double amount) {
    std::lock_guard<std::mutex> lock(mtx); 
    if (amount > balance) {
        throw std::runtime_error("Insufficient funds"); // Mutex unlocks automatically!
    }
    balance -= amount;
} // Mutex unlocks automatically here too!

I love lock_guard because it's "set it and forget it." It's incredibly lightweight—essentially just a wrapper around the lock/unlock calls—and it guarantees that you can't leak a lock. However, lock_guard is intentionally rigid. Once it's locked, it stays locked until the end of the scope. You can't manually unlock it early to perform a time-consuming I/O operation that doesn't actually need the lock.

When You Need More Control than a Guard Provides

This is where std::unique_lock comes in. Think of it as the "power user" version of lock_guard. It provides the same RAII safety—it will still unlock in the destructor—but it gives you the flexibility to call .unlock()` and .lock()` manually whenever you want.

Consider a scenario where you need to lock a mutex, update some data, and then perform a slow network call. You don't want to hold the lock during the network call because you'd be blocking every other thread from accessing the account.

void Account::processComplexTransaction(double amount) {
    std::unique_lock<std::mutex> lock(mtx);
    balance -= amount;
    
    // We've updated the state, now we can release the lock 
    // while we do something slow and unrelated to the mutex.
    lock.unlock(); 
    
    networkClient.sendTransactionLog(amount); 
    
    // If we need to update the status afterward, we can relock.
    lock.lock();
    lastTransactionTime = std::time(nullptr);
}

The trade-off here is a tiny bit of performance. unique_lock has to store a flag internally to track whether it currently owns the mutex (since it can be unlocked and relocked), whereas lock_guard doesn't. In 99% of application code, this overhead is negligible. I generally tell my colleagues: use lock_guard by default for its simplicity and speed, but reach for unique_lock the moment you need manual control or if you're working with std::condition_variable, which requires it.




📋 Practical Task

Refactoring a Deadlocking Transaction Manager

You have been handed a legacy TransactionManager class that is suffering from intermittent deadlocks. The current implementation uses manual .lock()` and .unlock()` calls, and there is a bug where an exception during a transaction prevents the mutex from ever being released.

Your Task: Refactor the following code to eliminate the possibility of deadlocks using std::lock_guard or std::unique_lock. You must ensure that the mutex is released regardless of whether the transaction succeeds or throws an exception.

#include <mutex>
#include <stdexcept>
#include <vector>

class TransactionManager {
    std::mutex mtx;
    std::vector<int> ledger;

public:
    void addEntry(int value) {
        mtx.lock();
        if (value < 0) {
            // BUG: The mutex is not unlocked before throwing!
            throw std::invalid_argument("Negative values not allowed");
        }
        ledger.push_back(value);
        mtx.unlock();
    }

    void clearLedger() {
        mtx.lock();
        ledger.clear();
        mtx.unlock();
    }
};

Requirements:

  • Replace the manual locking mechanism with the most appropriate RAII wrapper.
  • Ensure the code remains thread-safe.
  • The solution should handle the exception case without leaving the mutex in a locked state.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.