5

I would like to store a variable number of mutexes in a container like vector or deque.

In one of the use cases, I need to reliably and deadlock-free lock all of the mutexes. I would also like to have exception safety guarantee, that if an exception is thrown, all of the mutexes are as if no locking occured.

I am trying to do something like:

std::vector<std::mutex> locks_(n);
std::vector<std::lock_guard<std::mutex> > guards(n);
for(int i = 0; i < n; i++) {
    std::lock_guard<std::mutex> guard(locks_[i]);
    guards.emplace_back(std::move(guard));
}

but it doesn't compile, giving me:

/usr/include/c++/4.8/ext/new_allocator.h:120:4: error: use of deleted function ‘std::lock_guard<_Mutex>::lock_guard(const std::lock_guard<_Mutex>&) [with _Mutex = std::mutex]’

I guess there might also be a problem when lock_guards are destroyed, because the order must be reversed as compared to construction, but the standard saves us with:

The delete-expression will invoke the destructor (if any) for the object or the elements of the array being deleted. In the case of an array, the elements will be destroyed in order of decreasing address (that is, in reverse order of the completion of their constructor; see 12.6.2).

Are there any potential pitfalls with this approach and how can one make it work?

Edit

actually I am wrong, it seems vector does not guarantee a particular order of destruction. See this question: Order of destruction of elements of an std::vector

Edit2

Q: What if the use case is:

All the mutexes are locked/unlocked in any order by different threads (however each of those threads uses only 1 mutex at a time), but at some point I need to lock all of the mutexes in a safe manner in another thread.

Community
  • 1
  • 1
iggy
  • 1,613
  • 1
  • 18
  • 35
  • `lock_guard` cannot be moved (nor copied). `unique_lock` OTOH can be moved. – dyp May 24 '15 at 00:20
  • Doesn't the compile error complain that lock_guard isn't copy constructible? That doesn't have to do with order of deletion. lock_guard deletes its copy constructor and doesn't define a move constructor so you can't move it into the vector. See http://en.cppreference.com/w/cpp/thread/lock_guard – Appleshell May 24 '15 at 00:20
  • @Appleshell I don't quite understand why is the copy costructor invoked, I am explicitly telling to move the guard and not to copy – iggy May 24 '15 at 00:23
  • @Appleshell the statement about the order of deletion was to say that there might be a deadlock. I said "I guess there might also be a problem..." – iggy May 24 '15 at 00:27
  • @iggy When a copy constructor is defined (or deleted), no move constructor is generated. lock_guard deletes its copy constructor and thus has neither a move nor a copy constructor. – Appleshell May 24 '15 at 00:29
  • Is there a low bound on `n`? Like maybe 5 or 10? – Howard Hinnant May 24 '15 at 00:35
  • @HowardHinnant yes, its low, but has to be a non-constant – iggy May 24 '15 at 00:36
  • @HowardHinnant I was thinking of an array, and then somehow passing array as a variadic number of arguments to std::lock, but not sure how to do that – iggy May 24 '15 at 00:37
  • As much as I like guessing games, can you give me a hint on `n`'s upper bound? – Howard Hinnant May 24 '15 at 00:37
  • @HowardHinnant it is not more than 5, with my previous statement I thought I "gave a nod" – iggy May 24 '15 at 00:39
  • @iggy Maybe interesting to note, you're not telling your object to move. `std::move` is merely an rvalue cast, nothing more. What happens with an rvalue construction is that it tries to construct with the move constructor, then since there isn't one it tries to construct with the copy constructor. Since that one is deleted, that's the error you get. – Appleshell May 24 '15 at 00:40

2 Answers2

2

With a firm and low upper bound on n you could reasonably do something like this:

#include <iostream>
#include <mutex>
#include <vector>

int
main()
{
    constexpr unsigned n_max = 5;
    unsigned n;
    std::cout << "Enter n: ";
    std::cin >> n;
    if (std::cin.fail())
        throw "oops";
    if (n > n_max)
        throw "oops";
    std::vector<std::mutex> mutexes(n);
    std::vector<std::unique_lock<std::mutex>> locks;
    for (auto& m : mutexes)
        locks.emplace_back(m, std::defer_lock);
    switch (locks.size())
    {
    case 0:
        break;
    case 1:
        locks.front().lock();
        break;
    case 2:
        std::lock(locks[0], locks[1]);
        break;
    case 3:
        std::lock(locks[0], locks[1], locks[2]);
        break;
    case 4:
        std::lock(locks[0], locks[1], locks[2], locks[3]);
        break;
    case 5:
        std::lock(locks[0], locks[1], locks[2], locks[3], locks[4]);
        break;
    default:
        throw "oops";
    }
}

It isn't that pretty. But it is easy to reason about and thus reliable.

Notes:

  1. You need to use std::lock(m1, m2, ...) to reliably lock more than one mutex, or re-invent an algorithm such as std::lock to avoid deadlock. One such alternative algorithm is if you can guarantee that everyone always locks the mutexes in mutexes in the same order (say by index), then you don't need std::lock at all, just loop thru and lock `em.

  2. lock_guard is problematic to put in vector one at a time as vector<T>::emplace_back requires T to be move constructible. That is one of the reasons why unique_lock works here and lock_guard doesn't. mutexes gets away with holding non-movable mutexes because it constructs the vector all at once instead of adding to it with emplace_back.

  3. In this example locks holds references into mutexes. Make sure you don't have lifetime issues between these two containers (mutexes must outlive locks).

  4. If you need to add non-movable items to the end of a sequence, switch to deque, that will work where vector won't.

  5. Unlocking order doesn't matter, don't worry about it. Locking order matters only if different threads might lock in different orders. If all threads always lock in the same order, don't worry about it. But if all threads always lock in the same order, consider replacing the n mutexes with a single mutex, as that sounds equivalent.

  6. The code above assumes that somehow different threads might be locking in a different order, and perhaps a subset of mutexes. And obviously it won't scale to large n.

With Edit2 in the question, I believe this code to be viable. It will reliably work with different threads locking mutexes in different orders. Each thread should form its own local copy of locks and send that through the switch. If a thread for some reason needs its locks to be a subset of mutexes, or to build it in a different order, no problem. That is what this solution is designed for.

Plug

If you are interested in the algorithm behind std::lock, here are performance tests for a variety of potential implementations of it, including test code that you can run on your own platform:

Dining Philosophers Rebooted

If you find that your implementation of std::lock is suboptimal, have a talk with your implementor. :-)

Howard Hinnant
  • 206,506
  • 52
  • 449
  • 577
  • Howard, thanks for your explanation! I added more details to the question, could you please comment on that? – iggy May 24 '15 at 01:00
  • your suggestion about the upper bound made me think of std::array of unique_locks and passing them to std::lock as a variable number of arguments using this trick: http://stackoverflow.com/questions/16834851/passing-stdarray-as-arguments-of-template-variadic-function – iggy May 24 '15 at 01:05
  • For **Edit2** its a but suboptimal somehow, each thread really needs to work on one mutex at a time, but when it locks, threads with lower indices of mutexes are basically blocked. – iggy May 24 '15 at 01:14
  • I'm not sure I understand. Every thread will block no matter what if the subset of the mutexes it needs to lock are not unlocked when it needs them (regardless of the ordering of the mutexes, or the order references to mutexes are sent to `std::lock`). – Howard Hinnant May 24 '15 at 01:18
  • let us say we have threads t1, t2 which use m1, m2 correspondingly (and separately) and there is a t3 which requires locking both m1 and m2 at the same time. So for t2 to work, it would need to block t1 using your approach, although ideally it doesn't have to (I am not sure if ideal case is implementable). – iggy May 24 '15 at 01:22
  • ok, I am talking nonsense. I can use your approach to lock in t3 and locking inside t1 and t2 by simply using a lock_guard would work. – iggy May 24 '15 at 01:25
0

It is ok to construct the lock_guards with new and putting them into unique_ptrs?

Then the vector would hold std::unique_ptr<std::lock_guard<std::mutex>> instead of just std::lock_guard<std::mutex>:

std::vector<std::mutex> locks_(n);
std::vector<std::unique_ptr<std::lock_guard<std::mutex>>> guards(n);
for (int i = 0; i < n; i++) {
  typedef std::lock_guard<std::mutex> LockGuardType;
  std::unique_ptr<LockGuardType> guard(new LockGuardType(locks_[i]));
  guards.emplace_back(std::move(guard));
}

That should compile fine.

stj
  • 9,037
  • 19
  • 33
  • as I said, compilation is not the only problem. your code might result in a deadlock – iggy May 24 '15 at 00:57
  • but actually changing vector to std::array in your code solves the problem – iggy May 24 '15 at 01:10
  • If locks are locked/unlocked by other threads in any order, then this won't work. You will need to establish a defined lock order then and follow it in all threads. – stj May 24 '15 at 01:31
  • `std::lock` provides dead-lock free locking functionality, but I think it cannot work on a vector/range. Boost seems to have a version that can do this: http://www.boost.org/doc/libs/1_58_0/doc/html/thread/synchronization.html#thread.synchronization.lock_functions.lock_range – stj May 24 '15 at 01:36
  • sorry, even my Edit2 didn't make the problem clear. If every thread that doesn't lock all of the locks at the same time uses a single lock, then your approach + array would work, otherwise - no – iggy May 24 '15 at 01:44