what does unique_lock mean when a single thread acquire 2 unique_lock of the same mutex?

755 views Asked by At

I have the following code, which is from https://en.cppreference.com/w/cpp/thread/unique_lock. However, upon, printing the output, I see some unexpected result and would like some explaination.

The code is:

#include <mutex>
#include <thread>
#include <chrono>
#include <iostream>
 
struct Box {
    explicit Box(int num) : num_things{num} {}
 
    int num_things;
    std::mutex m;
};
 
void transfer(Box &from, Box &to, int anotherNumber)
{
    // don't actually take the locks yet
    std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);
    std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);
 
    // lock both unique_locks without deadlock
    std::lock(lock1, lock2);
 
    from.num_things += anotherNumber;
    to.num_things += anotherNumber;
    
    std::cout<<std::this_thread::get_id()<<" "<<from.num_things<<"\n";
    std::cout<<std::this_thread::get_id()<<" "<<to.num_things<<"\n";
    // 'from.m' and 'to.m' mutexes unlocked in 'unique_lock' dtors
}
 
int main()
{
    Box acc1(100);   //initialized acc1.num_things = 100
    Box acc2(50);    //initialized acc2.num_things = 50
 
    std::thread t1(transfer, std::ref(acc1), std::ref(acc2), 10);
    std::thread t2(transfer, std::ref(acc2), std::ref(acc1), 5);
 
    t1.join();
    t2.join();
}

My expectation:

  1. acc1 will be initialized with num_things=100, and acc2 with num_things=50.
  2. say thread t1 runs first, it acquire mutex m, with 2 locks. Once the locks are locked, and can assign num_things to num=10
  3. upon completion, it will print from.num_things = 110 and to.numthings = 60 in order. "from" first, then "to" later.
  4. thread1 finishes the critical section of the code, and wrapper unique_lock calls its destructor which basically unlock the mutex.

Here is what I don't understand.

I expected the lock1 fill be unlocked first, and lock2 later. Thread t2 then acquire the mutex in the same order and lock the lock1 first, then lock2. It will also runs the critical code sequentially up to cout.

Thread t2 will take the global acc1.num_things = 110 and acc2.num_things = 60 from t1.

I expect that t2 will print from.num_things = 115 first, then to.numthings = 65.

However, upon countless trial, I always get the reverse order. And that is my confusion.

enter image description here

1

There are 1 answers

0
Sam Varshavchik On BEST ANSWER

I expected the lock1 fill be unlocked first, and lock2 later.

No, the reverse is true. In your function lock1 gets constructed first, then lock2. Therefore, when the function returns lock2 gets destroyed first, then lock1, so lock2's destructor releases its lock before lock1's destructor.

The actual order in which std::lock manages to acquire the multiple locks has no bearing on how the locks gets destroyed, and release their ownership of their respective mutexes. That still follows normal C++ rules for doing so.

say thread t1 runs first,

You have no guarantee of that, whatsoever. In the above code it's entirely possible that t2 will enter the function first and acquire the locks on the mutexes. And, it is also entirely possible that each time you run this program you'll get different results, with both t1 and t2 winning the race, randomly.

Without getting into technical mumbo-jumbo, the only thing that C++ guarantees you is that std::thread gets fully constructed before the thread function gets invoked in a new execution thread. You have no guarantees whatsoever that, when creating two execution threads one after another, the first one will call its function and run some arbitrary part of the thread function before the second execution thread does the same.

So it's entirely possible that t2 will get the first dibs on the locks occasionally. Or, always. Attempting to control the relative sequence of events across execution threads is much harder than you think.