Search code examples
c++multithreadingdeadlockunique-lock

How does std::lock work with std::unique_lock objects instead of directly with std::mutex?


I'm working with a piece of multithreading code that involves bank account transfers. The goal is to safely transfer money between accounts without running into race conditions. I'm using std::mutex to protect the bank account balances during transfers:

My question centers around the use of std::unique_lock with std::lock. Instead of passing the std::mutex objects directly to std::lock, I'm wrapping them with std::unique_lock and passing those to std::lock.

How does std::lock work with std::unique_lock objects?

Is std::lock responsible for actually locking the from and to mutexes, while the std::unique_lock objects merely manage the locks (i.e., release them when they go out of scope)?

Does std::lock call the lock() method of std::unique_lock?

What is the advantage of using std::unique_lock with std::lock over directly passing std::mutex objects to std::lock?

struct bank_account
{
    bank_account(int balance) :
        mtx(), balance{ balance }

    {}
    std::mutex mtx;
    int balance;
};

void transfer(bank_account& from, bank_account& to, int amount)
{
    std::unique_lock<std::mutex> from_Lock(from.mtx, std::defer_lock);
    std::unique_lock<std::mutex> to_Lock(to.mtx, std::defer_lock);
    std::lock(from_Lock, to_Lock);
    
    if (amount <= from.balance)
    {
        std::cout << "Before:    " << amount << " from: " << from.balance << " to: " << to.balance << '\n';
        from.balance -= amount;
        to.balance += amount;
        std::cout << "After:     " << amount << " from: " << from.balance << " to: " << to.balance << '\n';
    }
    else
    {
        std::cout << amount << " is greater than " << from.balance << '\n';
    }
}

int main()
{
    bank_account A(200);
    bank_account B(100);
    std::vector<std::jthread> workers;
    workers.reserve(20);
    for (int i = 0; i < 10; ++i)
    {
        workers.emplace_back(transfer, std::ref(A), std::ref(B), 20);
        workers.emplace_back(transfer, std::ref(B), std::ref(A), 10);
    }
}

Solution

  • The purpose of std::lock is to provide a deadlock free locking (see libc++ implementation) of multiple Lockable objects. The classic problem is that if you have two locks L1 and L2, and

    • one thread locks L1 and then L2, and
    • another thread locks L2 and then L1,

    then there may be a deadlock because each thread could hold one lock and require the other from another thread. This issue applies when you're locking from.mtx and to.mtx in:

    std::unique_lock<std::mutex> from_Lock(from.mtx, std::defer_lock);
    std::unique_lock<std::mutex> to_Lock(to.mtx, std::defer_lock);
    std::lock(from_Lock, to_Lock);
    

    std::lock does the deadlock-free locking of from_Lock and to_Lock, and std::unique_lock does the rest (i.e. RAII stuff).

    Q&A

    How does std::lock work with std::unique_lock objects?
    Does std::lock call the lock() method of std::unique_lock?

    std::unique_lock is Lockable, and std::lock will call lock() on it, which then lock()s the mutex.

    Is std::lock responsible for actually locking the from and to mutexes, while the std::unique_lock objects merely manage the locks (i.e., release them when they go out of scope)?

    std::unique_lock is perfectly capable of doing locking and unlocking a mutex on its own. The only thing it can't do is implement a deadlock free locking when multiple locks are involved.

    What is the advantage of using std::unique_lock with std::lock over directly passing std::mutex objects to std::lock?

    You would have to manually unlock both mutexes afterwards, and this is bug-prone. It's a similar problem as std::unique_ptr vs. new/delete. It would be fine if you immediately wrapped both mutexes in a std::lock_guard though.

    Further Improvements

    For use with std::lock, you could use a simpler lock than std::unique_lock:

    std::lock(from.mtx, to.mtx);
    std::lock_guard<std::mutex> from_lock(from.mtx, std::adopt_lock);
    std::lock_guard<std::mutex> to_lock(to.mtx, std::adopt_lock);
    

    You only need std::unique_lock if you want to transfer ownership; otherwise you can use std::lock_guard (which is a slightly simpler type).

    If you're using C++17, things get even simpler with std::scoped_lock:

    // CTAD, equivalent to std::scoped_lock<std::mutex, std::mutex> lock(...)
    std::scoped_lock lock(from.mtx, to.mtx);
    

    std::scoped_lock is a replacement for std::lock_guard and has deadlock free locking built into the constructor, similar to using std::lock.


    See also What's the best way to lock multiple std::mutex'es?