5
\$\begingroup\$

Inspired by How to implement unique_lock for multiple mutexes? I've implemented a RAII mutex locker with the combined capabilities of std::unique_lock and std::scoped_lock and now wonder if it's good as-is or if there are improvements to be made.

  • Is it correct when it comes to the handling of the mutexes?
  • Implementation improvements? Could the spinning in try_lock_until be done more efficiently somehow for example.
  • API: try_lock returns int -1 for success just like std::try_lock. It's a bit ugly. Should I stick to that since it's in the standard or go for something else, like std::optional<std::size_t> which would contain the index of the mutex that failed in case locking fails?
  • Exception handling? Did I miss anything?
#include <chrono>
#include <concepts>
#include <cstdint>
#include <memory>
#include <mutex>
#include <system_error>
#include <utility>

namespace lyn {
template <class T>
concept BasicLockable = requires(T t) {
    t.lock();
    t.unlock();
};

template <class T>
concept Lockable = BasicLockable<T> && requires(T t) {
    { t.try_lock() } -> std::same_as<bool>;
};

template <class T>
concept TimedLockable = Lockable<T> && requires(T t) {
    {  // using an unusal ratio to avoid false positives for hardcoded duration
       // types
        t.try_lock_for(std::chrono::duration<std::int_least32_t,
                                             std::ratio<1, 999999999>>{})
    } -> std::same_as<bool>;
    { t.try_lock_until(std::chrono::steady_clock{}) } -> std::same_as<bool>;
};

template <class... Ms>
    requires((... && Lockable<Ms>) ||
             (sizeof...(Ms) == 1 && (... && BasicLockable<Ms>)))
class unique_multilock {
   public:
    // rule of 5 begin --------------------------------------------------------
    unique_multilock(const unique_multilock&) = delete;
    unique_multilock(unique_multilock&& other) noexcept
        : m_ms(std::exchange(other.m_ms, std::tuple<Ms*...>{})),
          m_locked(std::exchange(other.m_locked, false)) {}
    unique_multilock& operator=(const unique_multilock&) = delete;
    unique_multilock& operator=(unique_multilock&& other) noexcept {
        unique_multilock(std::move(other)).swap(*this);
        return *this;
    }
    ~unique_multilock() {
        if (m_locked) unlock();
    }
    // rule of 5 end ----------------------------------------------------------

    unique_multilock(Ms&... ms) : m_ms(std::addressof(ms)...) { lock(); }
    unique_multilock(std::defer_lock_t, Ms&... ms) noexcept
        : m_ms(std::addressof(ms)...) {}
    unique_multilock(std::adopt_lock_t, Ms&... ms) noexcept
        : m_ms(std::addressof(ms)...), m_locked(true) {}

    unique_multilock(std::try_to_lock_t, Ms&... ms)
        requires(... && Lockable<Ms>)
        : m_ms(std::addressof(ms)...), m_locked(try_lock() == -1) {}

    template <class Rep, class Period>
        requires(... && Lockable<Ms>)  // TimedLockable?
    unique_multilock(const std::chrono::duration<Rep, Period>& dur, Ms&... ms)
        : m_ms(std::addressof(ms)...), m_locked(try_lock_for(dur) == -1) {}

    template <class Clock, class Duration>
        requires(... && Lockable<Ms>)  // TimedLockable?
    unique_multilock(const std::chrono::time_point<Clock, Duration>& tp,
                     Ms&... ms)
        : m_ms(std::addressof(ms)...), m_locked(try_lock_until(tp) == -1) {}

    void swap(unique_multilock& other) noexcept {
        std::swap(m_ms, other.m_ms);
        std::swap(m_locked, other.m_locked);
    }
    std::tuple<Ms*...> release() noexcept {
        m_locked = false;
        return std::exchange(m_ms, std::tuple<Ms*...>{});
    }
    std::tuple<Ms*...> mutex() const noexcept { return m_ms; }
    bool owns_lock() const noexcept { return m_locked; }
    explicit operator bool() const noexcept { return m_locked; }

    void unlock() {
        if (not m_locked) {
            throw std::system_error(
                std::make_error_code(std::errc::operation_not_permitted));
        }
        std::apply([](auto... ms) { (..., ms->unlock()); }, m_ms);
        m_locked = false;
    }

   private:
    void lock_check() {
        if (m_locked) {
            throw std::system_error(
                std::make_error_code(std::errc::resource_deadlock_would_occur));
        }
        if constexpr (sizeof...(Ms) != 0) {
            if (std::get<0>(m_ms) == nullptr) {
                throw std::system_error(
                    std::make_error_code(std::errc::operation_not_permitted));
            }
        }
    }

   public:
    void lock() {
        lock_check();
        if constexpr (sizeof...(Ms) == 1) {
            std::get<sizeof...(Ms) - 1>(m_ms)->lock();
        } else if constexpr (sizeof...(Ms) > 1) {
            std::apply([](auto... ms) { std::lock(*ms...); }, m_ms);
        }

        m_locked = true;
    }

   private:
    int try_lock_unchecked()
        requires(... && Lockable<Ms>)
    {
        int res = -1;
        if constexpr (sizeof...(Ms) == 1) {
            res = std::get<sizeof...(Ms) - 1>(m_ms)->try_lock() ? -1 : 0;
        } else if constexpr (sizeof...(Ms) > 1) {
            res = std::apply([](auto... ms) { return std::try_lock(*ms...); },
                             m_ms);
        }
        if (res == -1) m_locked = true;
        return res;
    }

   public:
    int try_lock()  // note: returns -1 for success like std::try_lock
        requires(... && Lockable<Ms>)
    {
        lock_check();
        return try_lock_unchecked();
    }
    template <class Clock, class Duration>
        requires(... && Lockable<Ms>)  // TimedLockable?
    int try_lock_until(const std::chrono::time_point<Clock, Duration>& tp) {
        lock_check();
        int res;
        while ((res = try_lock_unchecked()) != -1 && tp < Clock::now()) {
        }
        return res;
    }
    template <class Rep, class Period>
        requires(... && Lockable<Ms>)  // TimedLockable?
    int try_lock_for(const std::chrono::duration<Rep, Period>& dur) {
        return try_lock_until(std::chrono::steady_clock::now() + dur);
    }

   private:
    std::tuple<Ms*...> m_ms;
    bool m_locked = false;
};

template <class... Ms>
void swap(unique_multilock<Ms...>& lhs, unique_multilock<Ms...>& rhs) {
    lhs.swap(rhs);
}
}  // namespace lyn

I've also put the same code at https://godbolt.org/z/KPqcTTa11 and https://github.com/TedLyngmo/unique_multilock if anyone wants to tinker with it.


Follow-up code review:

\$\endgroup\$
0

2 Answers 2

5
\$\begingroup\$

Design review

This is not a bad idea, but I question the need. Are there really any situations where you want to repeatedly lock and unlock multiple lockables in lockstep?

Note that I stress repeatedly, because if you just want to lock/unlock a set of lockables once, std::scoped_lock does that job. Even when you do want to lock a bunch of things repeatedly, using multiple scopes with scoped_lock probably makes more sense than hanging on to a potentially non-locked lock.

Indeed, even std::unique_lock with a single lockable is generally unwise. Almost every use of unique_lock would be better off using scoped_lock. The single use case I can think of for unique_lock is when using condition variables, because the point there is that you want to unlock to back off and let things happen then re-lock and see if the condition has been triggered.

In fact, I teach to never use unique_lockexcept for condition variable stuff.

The only other use cases for unique_lock are:

  • when you want to move locks around… which is generally unwise (and even more unwise with a lock that’s locking multiple things); or
  • when you want to lock multiple lockables, using the defer_lock strategy… which is done better in just about every situation using scoped_lock.

To be clear, I’m not dumping on the idea. It’s just that the way I see it, other than the condition variable case, any situation that is complicated enough to require unique_lock is already a mess. I don’t see how it could be improved by a multiple unique_lock.

But that’s just from my experience. If you have an actual use case where this kind of thing might be useful, then fine. And there is one interesting feature of unique_multilock… although with a caveat.

try_lock_for/until()

So the real meat of the unique_multilock API is these functions:

lock();
try_lock();
try_lock_for(duration);
try_lock_until(time_point);

The other stuff is all just observers or things like unlock() which really just follow from the above API.

Now the first two functions are basically just:

std::lock(...);
std::try_lock(...);

… using the lockables associated with the unique_multilock.

But the other two functions are novel. And, dare I say, potentially very useful!

However, there are two problems.

The first is that these functions are so useful, they should not be in the class. Just as unique_multiple<Ms...>::lock() is really just a thin wrapper around std::lock(ms...) and unique_multiple<Ms...>::try_lock() is really just a thin wrapper around std::try_lock(ms...), unique_multiple<Ms...>::try_lock_for(duration) and unique_multiple<Ms...>::try_lock_until(time_point) should really just be thin wrappers around general try_lock_for(duration, ms...) and try_lock_until(time_point, ms...) functions.

Those free functions are more generally useful than the member functions could ever be. Indeed, just as with std::lock(...) and std::try_lock(...), the member functions could be implemented in terms of the free functions.

So whether you want unique_multilock or not, it would probably make sense to pull those functions out of the class, and then, if desired, implementing the related member functions in terms of them.

But that brings us to the second problem.

So while there are standard specifications for how lock(lockables...) and try_lock(lockables...) should behave, there are no standard specification for how a hypothetical try_lock_for(duration, lockables...) or try_lock_until(time_point, lockables...) should be behave. That means that, theoretically, whatever you chose to make them do is “correct”.

However, while that may be theoretically true, I think there are practical reasons why some behaviours make more sense than others.

Consider lock(lockables...) and try_lock(lockables...). These functions are closely related, yet completely different even from a conceptual perspective.

  • lock(lockables...): “Do everything you can to lock all the lockables, including backing off and retrying in a different order if necessary. Keep trying until forever.”
  • try_lock(lockables...): “Make an attempt to lock all the lockables, in given order. Try only once.”

Now, when considering the behaviour of try_lock_until(time_point, lockables...), does it make more sense to base it on the behaviour of lock() or try_lock()? In other words, should it be:

  • “Do everything you can to lock all the lockables, including backing off and retrying in a different order if necessary. Keep trying until time_point.” … or…
  • “Make an attempt to lock all the lockables, in given order. Repeat until time_point.”

See what I’m getting at? Your current approach is the latter. But… does that feel correct?

In my mind, a try_lock_until() function shouldn’t just repeat std::try_lock() over and over until it either succeeds or time runs out. Nor should it just call .try_lock_until() on each lockable until one fails. It should do whatever it takes, even backing off and retrying in a different order. In other words, it should be std::lock() with a time limit.

It might look something like this:

auto try_lock_until(std::chrono::time_point<Clock, Duration> const& tp, lockable auto&... l)
{
    // For simplicity, assuming all the lockables are the same type,
    // and not caring about the possibility of zero lockables.
    auto lockables = std::array{std::addressof(l)...};

    do {
        // Lock the first lockable.
        auto lock_1 = std::unique_lock{*(lockables[0]), std::defer_lock};
        if (not lock_1.try_lock_until(tp));
        {
            // We couldn't even get one lock before time ran out!
            return false;
        }

        // Try to lock the remaining lockables.
        auto other_locks = std::array<std::unique_lock, lockables.size() - 1>{};
        auto success = true; // we live in hope!
        for (auto&& l : std::views::zip(lockables | std::views::drop(1), other_locks))
        {
            std::get<1>(l) = std::unique_lock{*std::get<0>(l), std::try_to_lock};
            if (not std::get<1>(l))
            {
                success = false;
                break;
            }
        }

        if (success)
        {
            // All lockables have been locked!
            lock_1.release();
            std::ranges::for_each(other_locks, [](auto&& l) { l.release(); });
            return true;
        }
        else
        {
            // Rotate the lockables, and (maybe) try again.
            std::ranges::rotate(lockables, std::ranges::next(std::ranges::begin(lockables)));
        }
    } while (tp < Clock::now());

    return false;
}

… and similar for try_lock_for() (or delegate). And of course, the member function try_lock_until() can now basically be based on this.

And this seems like a more realistic idea of what you’d expect from try_lock_until. It doesn’t restrict itself to repeatedly trying to lock in the same order and immediately giving up on the first failure—even if time remains—to unlock everything and try again. It just does whatever it can until success or time running out.

So while I’m a little cool on the idea of unique_multilock, I’m quite keen on try_lock_until(time_point, lockables...) and try_lock_for(duration, lockables...) free functions. And since unique_multilock is basically free once you have those… why not, I guess?

Questions

“Is it correct when it comes to the handling of the mutexes?”

That depends on what you mean by “correct”.

If you mean it doesn’t trigger UB or have race conditions or such, then it seems correct to me.

If you mean “correct” in the sense “it satisfies the contract”… well that depends on what the contract is. That is what that big section about try_lock_until(time_point)/try_lock_for(duration) was about. Is the contract for those functions to keep trying a single lock strategy over and over until time runs out… or is it to try any lock strategy that might work, until time runs out?

“Implementation improvements? Could the spinning in try_lock_until be done more efficiently somehow for example.”

I can’t see much that could be improved, except in the implementation of try_lock_until().

Yes, you do have a busy-wait loop in try_lock_until() with the current implementation… which is part of the reason I think the current implementation is misguided. I don’t think it makes a lot of sense to repeatedly try locking the lockables in the same order, immediately giving up when one won’t immediately lock, then immediately trying the exact same thing again over and over. That’s both wasteful, and pretty close to the proverbial “definition of insanity”.

The very rough implementation I made up above does not busy wait (well… not unless the lockable’s .try_lock_until() busy-waits). It uses a timed wait for the first lockable, then immediately tries all the others and bails if any don’t immediately lock. If any don’t lock, unlock everything, shuffle the order, and retry. That avoids both busy-waiting, and potential lock order problems.

That is not the only strategy. Perhaps every one of the locks should be try_lock_until()-ed. The problem with that strategy is you get exactly one attempt; if you don’t get all the locks in the time limit, the time limit will be up, so you can’t retry in a different order.

And there is good reason to want to try locking the locks in a different order. It may be that locking one lock prevents one of the other locks from being unlocked elsewhere, even though it theoretically could be. By retrying in a different order, you increase the odds of a successful lock. That’s how std::lock() works. It’s just unfortunate that it doesn’t have a timeout.

“API: try_lock returns int -1 for success just like std::try_lock. It's a bit ugly.”

Eh.

The thing about std::try_lock() is that it is a multi-faceted function. It is not just a fail-fast std::lock(). Yes, sure, that is one of the ways it could be used. But there are others.

std::try_lock() is very specific about the fact that it tries to lock all the lockables in order. That is important: in order. That means there is more information you get from the success/failure if std::try_lock() beyond merely just “they locked or did not lock”.

Among other things, you could use std::try_lock() to test:

  • if any of the given locks is already locked
  • which of the given locks is already locked
  • that the given locks can be locked in that order
  • … etc.

Because the function is so multifaceted, it is not clear what a “desired” result looks like, or what an “error” result is.

That is why I would say optional<size_t> is not really a good return type for this function. If the function’s specific purpose was “find the first lockable that can’t be locked (without waiting, of course), if any”, then, sure, std::optional<std::size_t> would be the perfect result type: it is pretty much exactly what you want: (the index of) the first lockable that couldn’t be locked… if any.

But how often have you used std::try_lock() to find a lockable that won’t lock? I’ve never. I’ve only used it to try to lock ’em all, and if any won’t, quickly take advantage of that information to do something else. For that purpose, the ideal return type is bool. I don’t care which lockable couldn’t lock.

What about std::expected? Well, that would be the perfect return type if the purpose of the function was “lock all of these, and if you can’t there’s a problem/error” (as opposed to the previous case, which was “lock ’em all, and if you can’t, that’s fine (not a problem or error), just let me know”)… which is yet another perfectly valid use of the function. For that usage, the ideal return type might be std::expected<void, std::size_t>.

Because std::try_lock() is such a low-level and multi-faceted operation, what you should do is make a higher-level wrapper function for whichever of the specific things you actually mean (or all of them), and that function should return the logical result type for that conceptual operation.

But for a generic, low-level, context-free operation like naked try_lock()… what’s wrong with a result as simple as “the failing index or -1”?

Okay, okay, I hate magic numbers, so let’s get crazy and imagine what the perfect result type for try_lock() should be.

No, it should definitely not be optional<std::size_t>. Or expected<void, std::size_t>. Not only because those types represent concepts that may not be relevant to the particular use of the function, but also because those types are a lot more expensive than a bare int. Both require at least an int (or std::size_t) and a boolean-like discriminator, and both have costs to access the int due to checks to make sure there is an int present.

Let’s start with the basics:

struct try_lock_result_t
{
    int index = -1;
};

This seems like a step backward from a bare int… but then we can do:

struct try_lock_result_t
{
    int index = -1;

    constexpr auto all_locked() const noexcept { return index == -1; }
    constexpr explicit operator bool() const noexcept { return all_locked(); }
};

And voilà, no more magic numbers:

// I want to confirm that all locked:
if (try_lock(lockables...).all_locked()) ...

// I want to do something with the one that didn't lock:
if (auto const result = try_lock(lockables...); not result.all_locked())
    std::println("the one that didn't lock was {}", result.index);

// Or perhaps:
try_lock(lockables...) or do_alternative_work();

And there is zero additional cost over a bare int.

I suppose you could also add a set of comparisons with std::size_t, so you could do if (result == 2) rather than if (result.index == 2).

The main point of this bespoke result type is that it doesn’t privilege any particular usage of the function. Whatever you mean by using try_lock(), the result reflects that.

Exception handling? Did I miss anything?

Looks fine to me.

Code review

template <class T>
concept Lockable = BasicLockable<T> && requires(T t) {
    { t.try_lock() } -> std::same_as<bool>;
};

std::same_as<bool> seems a bit restrictive. Surely all you need here is convertible_to<bool>, or std::equality_comparable_with<bool>. (Ideally, what you want is boolean-testable, but that’s expo-only.)

template <class T>
concept TimedLockable = Lockable<T> && requires(T t) {
    {  // using an unusal ratio to avoid false positives for hardcoded duration
       // types
        t.try_lock_for(std::chrono::duration<std::int_least32_t,
                                             std::ratio<1, 999999999>>{})
    } -> std::same_as<bool>;

There is no point worrying about shenanigans when writing a concept. Remember that concepts are supposed to not only be testing syntactic conformance, but semantic conformance as well. I mean, just consider:

template <class T>
concept BasicLockable = requires(T t) {
    t.lock();
    t.unlock();
};

I could make a type that “satisfies” this concept where t.lock() puts a document on the printer spool and t.unlock() erases the hard drive. Or I could make a type where t.lock() does indeed lock something, and t.unlock() unlocks it… but you have to call t.lock() three times for every t.unlock().

So there’s no need to get too “clever”. You could just as well just check a few basic types:

template <class T>
concept TimedLockable = Lockable<T> && requires(T t) {
    { t.try_lock_for(std::chrono::nanoseconds{}) } -> std::convertible_to<bool>;
    { t.try_lock_for(std::chrono::weeks{}) } -> std::convertible_to<bool>;

    { t.try_lock_until(std::chrono::steady_clock::now()) } -> std::convertible_to<bool>;
    { t.try_lock_until(std::chrono::high_resolution_clock::now()) } -> std::convertible_to<bool>;
    { t.try_lock_until(std::chrono::local_seconds{}) } -> std::convertible_to<bool>;
};

If anything satisfies that much of the concept while failing to account for any clock or time point type… well… I mean… that’s hardly a problem with the concept.

However, I’ll point out later that all of this is probably a non-issue.

    { t.try_lock_until(std::chrono::steady_clock{}) } -> std::same_as<bool>;

🤨

template <class... Ms>
    requires((... && Lockable<Ms>) ||
             (sizeof...(Ms) == 1 && (... && BasicLockable<Ms>)))

I’m guessing you lifted these requirements from std::scoped_lock. Well, while they make sense there, they make no sense here.

These are the requirements for a function that tries to lock multiple lockables with a deadlock-avoidance strategy. They are, in effect, the requirements for std::lock(), except that that function doesn’t take less than 2 arguments. They are the requirements for std::scoped_lock because that can take less than 2 arguments, and when it does take 1, it can lock without a deadlock-avoidance strategy.

But unique_lockscoped_lock, and not just because it doesn’t take multiple lockables. Even in just the case of a single lockable, unique_lockscoped_lock. unique_lock has facilities and capabilities that scoped_lock can’t even dream about.

For example, have you considered that you might want to unique_multilock multiple “things” that can’t even be locked?

What? Did Indi just say something crazy?

Observe:

// An emergency hatch is *created* "locked", but blows open when there
// is an emergency, and can never be "re-locked".
struct emergency_hatch
{
    emergency_hatch();
    void unlock();
};

auto hatch_1 = emergency_hatch{};
auto hatch_2 = emergency_hatch{};
auto hatch_3 = emergency_hatch{};

// We're about to do a dangerous operation!
{
    auto hatches = unique_multilock{std::adopt_lock, hatch_1, hatch_2, hatch_3};

    do_something_dangerous_that_might_throw();

    // We're safe! No need to be ready to blow the emergency hatches anymore.
    hatches.release();
}

You see? We never call .lock() or .try_lock() or anything related. We might call .unlock(). But we need the .release() mechanism (which std::scoped_lock does not have) when there is no emergency.

If you require Lockable for all the lockables, then the above can’t work. Hell, even if you require just BasicLockable, it still won’t work.

Does that make sense? Is the example I provided above wrong? The type doesn’t meet the requirements. So is it wrong to use unique_multilock like this? Or are the requirements wrong?

I think the only sensible requirement for unique_multilock is actually UNlockable. As in:

template<typename T>
concept unlockable = requires(T& t) { t.unlock(); };

… because, as I illustrated above, you don’t need the types to be lockable to constructor or use them with unique_multilock. But you do need them to be unlockable, or the destructor can’t compile (so you can’t create the unique_multilock in the first place). Even if you don’t actually unlock anything in the destructor—the example above will not if there is no emergency—you need to be able to.

That actually makes the requires clause much simpler:

template<typename... Ts>
    requires (unlockable<Ts> && ...)
class unique_multilock
{

… or even just:

template<unlockable... Ts>
class unique_multilock
{

I can’t think of any other requirements on the template parameters.

    unique_multilock(const unique_multilock&) = delete;
    unique_multilock(unique_multilock&& other) noexcept
        : m_ms(std::exchange(other.m_ms, std::tuple<Ms*...>{})),
          m_locked(std::exchange(other.m_locked, false)) {}
    unique_multilock& operator=(const unique_multilock&) = delete;
    unique_multilock& operator=(unique_multilock&& other) noexcept {
        unique_multilock(std::move(other)).swap(*this);
        return *this;
    }
    ~unique_multilock() {
        if (m_locked) unlock();
    }

Does all this really need to be that squished together? Even if I were printing this and worried about wasting paper, I would spread this out more. It’s nigh unreadable, and it’s hard to spot that there are some places where multiple things are happening on a single line (like in the move constructor, where the final {} is very well hidden).

    unique_multilock(Ms&... ms) : m_ms(std::addressof(ms)...) { lock(); }

This constructor needs to be explicit. Indeed, this is true for all of the non-copy/move constructors. Right now, the lock is implicitly convertible from a single lockable argument. And in the case of the other constructors, you have locks that implicitly convert from things like time points and std::defer_lock.

Note that you really only need the constructor to be explicit when it is being used with a single argument. It’s not really a problem with multiple arguments. So you could do:

    explicit(sizeof...(Ms) == 1) unique_multilock(Ms&... ms)

And similarly for the other constructors:

    explicit(sizeof...(Ms) == 0) unique_multilock(std::defer_lock_t, Ms&... ms) noexcept

Now, there is a very deep and important design decision you have to make. unique_multilock, like std::scoped_lock, allows for zero lockables… even though, while harmless, it doesn’t really make a lot of sense to lock “nothing”. The reason why std::scoped_lock allows it is for ease of use in generic contexts; it always “works”, no matter what the number of template arguments is. std::scoped_lock would be much more of a pain to use in generic contexts if you had to check for and special-case the zero-argument case.

However, some people consider this to be a design flaw, and even recommend against using std::scoped_lock for single-argument cases (and they say instead you should use std::lock_guard).

Why? Well consider the example below:

{
    std::scoped_lock lock;

    // ...
}

Whoops! We have locked nothing! We made a typo! We meant to do:

{
    std::scoped_lock lock{mutex};
//-----------------------^^^^^^^

    // ...
}

But the former code compiles without complaint, and “works”. You may not notice for a long, long time that you never actually lock the mutex. Race conditions often only trigger visible problems rarely or sporadically. This is a nightmare bug.

This is not a problem with std::lock_guard:

{
    std::lock_guard lock;   // compile error!
                            //
                            // std::lock_guard has no default constructor

    // ...
}

But it is a problem with unique_multilock (and std::unique_lock, coincidentally), because it does have a “default constructor”. That is, when you do unique_multilock{}, you are using the locking constructor with a zero-length template parameter pack.

If you think this is a problem you could forbid empty template argument lists.

(Full disclosure: I am not one of those people that thinks any of this is a problem. From where I stand, the problem in the “bad” std::scoped_lock example above is not with std::scoped_lock, but rather because of not using the “always auto” (formerly “almost always auto”) initialization style. In other words, if you always wrote auto lock = std::scoped_lock{...};, you can’t possibly forget the mutex. auto lock = std::scoped_lock; will simply not compile, and if you write auto lock = std::scoped_lock{};… well, I mean, that’s not a case of simply “forgetting”, because you had to explicitly type the empty braces to say you wanted no arguments.)

(Another solution that I’ve considered proposing is another constructor for std::scoped_lock with a tag, that forbids the empty pack case, like so scoped_lock(with_lockables_t, Ts&...) requires (sizeof...(Ts) > 0). Then if you are worried about the “typo”, you would always write scoped_lock lock{with_lockables, ...};, and if the ... is empty, you get a compile error, and lock_guard now becomes completely obsolete. But, frankly, I don’t take this “concern” seriously enough to care that much. Especially when simply using a better coding style fixes it automatically.)

Back to the constructor at hand:

    unique_multilock(Ms&... ms) : m_ms(std::addressof(ms)...) { lock(); }

Now this constructor needs the requires clause that is currently applied to the whole class. So does the member .lock() function, by the by. This constructor requires that all the arguments are at least basic-lockable, and to do deadlock-avoidance for multiple arguments, it needs try_lock() support.

    unique_multilock(std::adopt_lock_t, Ms&... ms) noexcept
        : m_ms(std::addressof(ms)...), m_locked(true) {}

I’m not sure this constructor should be noexcept.

noexcept does not mean “I don’t throw any exceptions”. It means “this operation cannot possibly fail”. If you argue that adopting a bunch of locks cannot possibly fail, then I have to counter with… but what if they are not already locked?

If the locks are not already locked, and you call unique_multilock{adopt_lock, ...}, then the lock object is immediately in a broken state. Unless you intervene either by calling .release(), or you actually lock those lockables externally to the class, then when the lock gets cleaned up… boom.

You may wonder why I didn’t bring this up as a problem with the defer_lock case: what if you do unique_multilock{defer_lock, ...} with a bunch of lockables that are already locked.

Well, my answer is: 🤷🏽.

Unlike the adopt_lock case, in the defer_lock case, the lock isn’t actually broken right off the bat. You would have to call .lock() or similar to actually break it… and that is where the breakage would be occurring, which is why those functions cannot be noexcept. (Didja notice that neither std::try_lock() nor std::mutex::try_lock() is marked noexcept, even though the standard promises the latter does not throw? This is why.)

Put another way:

  • If you do unique_multilock{adopt_lock, ...} with non-locked objects, the lock is immediately broken; you have to take extra steps to un-break it.
  • If you do unique_multilock{defer_lock, ...} with already-locked objects, the lock is fine… but it will break if you take certain extra steps.

That is why I think the adopt_lock constructor should not be marked noexcept, but it’s fine if the defer_lock constructor is.

    template <class Rep, class Period>
        requires(... && Lockable<Ms>)  // TimedLockable?
    unique_multilock(const std::chrono::duration<Rep, Period>& dur, Ms&... ms)
        : m_ms(std::addressof(ms)...), m_locked(try_lock_for(dur) == -1) {}

    template <class Clock, class Duration>
        requires(... && Lockable<Ms>)  // TimedLockable?
    unique_multilock(const std::chrono::time_point<Clock, Duration>& tp,
                     Ms&... ms)
        : m_ms(std::addressof(ms)...), m_locked(try_lock_until(tp) == -1) {}

Let’s think about the requirements on the lockables for these constructors.

Presumably the class requirements handle the unlockable constraint, so all we need to worry about is the locking side of things. At bare minimum, we want the lockables for the first constructor to satisfy:

template<typename T, typename Rep, typename Period>
concept ??? =
    requires(T& t, std::chrono::duration<Rep, Period> const& dur)
    {
        { t.try_lock_for(dur) } -> std::convertible_to<bool>;
    }
;

… and for the second constructor:

template<typename T, typename Clock, typename Duration>
concept ??? =
    requires(T& t, std::chrono::time_point<Clock, Duration> const& tp)
    {
        { t.try_lock_until(tp) } -> std::convertible_to<bool>;
    }
;

You can see how writing the concepts this way, we no longer need to worry about shenanigans to cover all timed lockable cases.

So we could just use the concepts outlined above, suitably named, but hark back to the design discussion about whether try_lock_for/until should do deadlock avoidance. I think it should, and if so, then we should also require the lockables to have try_lock support.

And not only that, we should allow for delegating try_lock_for() to try_lock_until(), and vice versa. This is not a particularly onerous requirement; if a lockable supports try_lock_for(), there is no sensible reason it should not also be able to support try_lock_until(), and vice versa.

However, codifying that requirement in the general case is challenging, if not practically impossible. It’s also not really necessary, in practice, because we will never want to delegate a call to try_lock_for() to try_lock_until() using, say, the GPS clock. We will only ever use a steady clock for such delegation.

Which means, at least for this use case, this should suffice:

template<typename T>
concept timed_lockable =
    lockable
    and requires(
        T& t,
        std::chrono::steady_clock::duration const& dur,
        std::chrono::steady_clock::time_point const& tp
    )
    {
        { t.try_lock_for(dur) } -> std::convertible_to<bool>;
        { t.try_lock_until(tp) } -> std::convertible_to<bool>;
    }
;

But I’m not sure that this concept, nor any of the other ones you already have, really make sense as general-use concepts. They seem more like implementation details for this specific class (and the specific functions). It is the standard that defines the lockable, basic lockable, etc. concepts, so it should be up to the standard to actually provide the concept concepts for them. It’s too bad it does not. The concepts you make to cover for that deficiency should really be details, not part of the public API.

That’s about all I can think for the class itself, except to mention that the swap function should probably be implemented as a hidden friend.

So I guess that about covers it. Hope it helped!

\$\endgroup\$
2
  • \$\begingroup\$ Wow! You really put in some work in this and you pointed out a lot of areas for improvement. Many thanks! I'll leave it open for another day before accepting. \$\endgroup\$ Commented Aug 8 at 6:47
  • 1
    \$\begingroup\$ After talking with some other people, I now think the concept of a “multi-lock” is not practical. The reasoning is complicated, so I’ve added another answer to explain it. \$\endgroup\$ Commented Aug 16 at 21:23
2
\$\begingroup\$

I was talking with my partners about this idea, and one of them pointed something out with just three words that made me realize this class may not be possible to implement safely. Her three words were: “Unlocking can fail.”

To understand why this may be the death knell for unique_multilock, let’s start with the simpler case of std::unique_lock first.

Let’s assume a principled user: one who perfectly satisfies the contracts for every use of std::unique_lock.

The reason std::unique_lock is well-designed is because if the above assumption is true, then no matter what happens, even the unexpected—anything but straight-up undefined behaviour (where anything goes)—the behaviour of std::unique_lock is well-defined and predictable. The class is never broken.

Let’s consider the case where locking fails. And to be clear, I don’t mean fail as in “couldn’t acquire the lock because something else holds it”; I mean fail as in “attempting to acquire the lock raised an error (that is, threw an exception)”.

std::unique_lock has eight functions that lock or attempt to lock:

// Constructors that lock:
unique_lock(mutex_type&)
unique_lock(mutex_type&, std::try_to_lock_t)
template<typename Rep, typename Period> unique_lock(mutex_type&, std::chrono::duration<Rep, Period> const&)
template<typename Clock, typename Duration> unique_lock(mutex_type&, std::chrono::time_point<Clock, Duration> const&)

// Member functions that lock:
lock()
try_lock()
template<typename Rep, typename Period> try_lock_for(std::chrono::duration<Rep, Period> const&)
template<typename Clock, typename Duration> try_lock_until(std::chrono::time_point<Clock, Duration> const&)

The four constructors can be trivially implemented in terms of the four member functions (logically; in practice they probably shouldn’t because they don’t need to check for the “already owns” case), so let’s focus on the member functions.

If we imagine std::unique_lock is internally made up of a pointer to a lockable and a flag to keep track of whether this owns the lock, then it is actually pretty trivial to implement all of the member functions safely and efficiently. The only “catch” is that you have to set the flag after acquiring the lock… which is hardly rocket science; that’s how you should always code this kind of thing: never set a flag before something that the flag is tracking.

If the locking attempt throws, then the exception can simply be allowed to propagate out of the function. The flag is never set, so this still thinks it doesn’t own the lock… which is true. The object is still in a perfectly valid state. Even if it were destroyed immediately, that would be fine; because the flag isn’t set, the destructor should not try to release the lock. Which is correct, because it never had the lock.

In other words, even if locking fails, as far as std::unique_lock is concerned, everything Just Works™.

In fact, you could theoretically fix whatever caused the locking to fail, then re-try, and… everything would still Just Work™.

So even if locking fails, std::unique_lock never breaks. It always works.

But what if unlocking fails?

Well, things get a little trickier there. There are three functions that unlock:

// The destructor:
~unique_lock()

// The move assignment operator:
auto operator=(unique_lock&&) noexcept -> unique_lock&

// The actual unlock function:
unlock()

Two of those functions are noexcept: the destructor and the move-assignment operator. So if unlocking fails in either of those functions… the program terminates.

That sounds bad, and sure, it’s not great, but here’s the thing: it’s not UB. It is, in fact, very well-defined behaviour. And since the program is done, this will not exist anymore, so it cannot be in a broken state (or any state, for that matter). So even in the event of an unlocking failure in the destructor or move-assignment operator, the object never becomes broken. The program is dead, but at least it isn’t launching nasal demon nukes.

And here’s the other thing: that not-great outcome is entirely avoidable, if you’re really concerned about it. All you need to do is always manually unlock before destruction, or before moving-over the object. That’s not even hard to do. (I mean, it’s annoying, but if you’re in a really safety-critical domain, this is hardly the most annoying thing you have to do for safety’s sake.)

So what about .unlock()?

Well, if unlocking fails inside .unlock(), all we need to do is… not unset the flag. Which is obvious to the point of being tautological; again, you just never set (or unset) a flag until the thing it represents is true.

If unlocking fails, this will still think it owns the lock… which… is true. If unlocking failed, the unlockable is not unlocked… assuming the strong exception guarantee, which, anything else for a lockable would be batshit.

So the object is not broken. It is still in a correct and valid state.

Mind you, if you then tried to destroy the std::unique_lock, it will try to unlock the lockable again, as it should. If it succeeds, then all is good… but if it fails (again), then… terminate. But, again, that is not UB. That is well-defined, and predictable behaviour, and, again, the termination can be avoided if you fix whatever is causing the unlock to fail (or give up and .release()).

So all of that means that no matter what happens… even if locking or unlocking fails… the std::unique_lock is always in a well-defined and correct state. It will trigger termination in some situations, but if you are cautious and diligent you can both predict and avoid those situations.

So if you adhere to its contracts, std::unique_lock is always in a valid state, always in the correct state, and always✱ safe to use… where the asterisk there is because if you don’t want termination (which is correct, but drastic), you have to be a little diligent.

Okay! So std::unique_lock is good! What about unique_multilock?

Well….

Let’s start with the same assumptions: a diligent user who honours the contract, and locking/unlocking operations that might fail, but do so with the strong exception guarantee.

unique_multilock’s interface is basically the same as unique_lock’s, except for the fact that it involves multiple lockables. That means that, like unique_lock, it has eight functions that lock:

// Constructors that lock:
unique_multilock(mutex_type&)
unique_multilock(mutex_type&, std::try_to_lock_t)
template<typename Rep, typename Period> unique_multilock(mutex_type&, std::chrono::duration<Rep, Period> const&)
template<typename Clock, typename Duration> unique_multilock(mutex_type&, std::chrono::time_point<Clock, Duration> const&)

// Member functions that lock:
lock()
try_lock()
template<typename Rep, typename Period> try_lock_for(std::chrono::duration<Rep, Period> const&)
template<typename Clock, typename Duration> try_lock_until(std::chrono::time_point<Clock, Duration> const&)

And again, the constructors, for our purposes, just do what the member functions do.

Now comes the bad news: every one of those member functions is unavoidably dangerous.

Let’s look at the simplest case: .try_lock(). Let’s understand what it tries to do. It’s specification is simple: try to lock each of the lockables (in order), and if any cannot be locked OR if locking fails (throws an exception), then unlock all of the ones that were successfully locked (and return, yadda, yadda, but that part’s not important here).

If unlocking can’t fail, then the above algorithm is safe. But if unlocking can fail… then imagine what happens if you have successfully locked one lockable, then trying to lock the second fails (throws), then before letting the exception escape you dutifully try to unlock the first lockable, and its unlock fails. You have a double exception. That’s a terminate. No warning. No possibility of avoiding it.

(Note that it is theoretically possible to avoid termination. You could catch any unlock failures and bundle them with the original lock failure exception, for example. I’m going to ignore this potential solution because: 1) that is not how std::try_lock() is specified; and 2) there are far bigger issues coming up.)

You may object that std::try_lock() has the same problem. Yes. Yes it does. In fact, you could (and did, and should) implement unique_multilock::try_lock() as a thin wrapper around std::try_lock(). This is not a problem with our try-lock algorithm. It is an inherent problem of dealing with multiple locks.

Similar problems exist for all the other locking functions, whether you implement them in terms of std::lock() and your own free functions, or not. In all cases, if even a single unlock fails, you either get terminated, or you are in a bizarre situation where the lock function failed, yet some lockables are locked and some are not. (It is theoretically possible to suss out which lockables are in which state, but again, I’m ignoring this, because things will shortly get much, much worse.)

You may object that this isn’t technically a problem with unique_multilock. It’s inherent to the use of multiple locks. std::lock() has the same issues, as does std::try_lock() (and std::scoped_lock, incidentally). So you could just argue that this is just a “multiple lock problem”, and unique_multilock has it along with anything else that deals with multiple locks.

Except… unique_multilock is unlike any of the other things that deal with multiple locks.

Let’s imagine that std::lock() or std::try_lock() or the constructor for std::scoped_lock fails. Let’s also imagine that they don’t trigger a termination. Lucky you! Except now you are in a situation where you have a bunch of lockables, and you don’t know which are locked and which are not. This is not a good situation to be in, but it is not necessarily catastrophic. In theory, you could check each of the lockables, find out which one(s) are still locked, and then take some remedial action. In other words, you are in a bad state… but it is, at least theoretically, fixable.

Enter unique_multilock.

Let’s imagine you have created a unique_multilock with ten lockables, using the defer_lock constructor. Then you call .lock(). An exception is thrown. Here is the million dollar riddle: what state is that unique_multilock in?

Remember, with std::unique_lock, if .lock() failed, there was only a single lockable, and it was only trying to be locked. So, assuming the strong exception guarantee, we know exactly what state everything is in, even in the event of an error.

With unique_multilock, you have multiple locks. And when doing .lock() it’s not just locking operations happening; unlocking ops are happening, too (and try-lock ops!). On a failure of .lock(), without checking, you can’t know whether it was a locking attempt that failed, or an unlocking attempt… or both. You also won’t know which lockables may be locked, if any. It’s a mess. The unique_multilock is now potentially in a broken state… only you don’t even know if it is or not.

Why isn’t this a problem with std::scoped_lock? Because std::scoped_lock only locks in the constructor. If there is any failure… you don’t have a std::scoped_lock. Thus it cannot be in a broken state. Yes, you still have a situation on your hands; you don’t know which, if any, of the lockables are locked. However… you don’t have a broken std::scoped_lock. Because you don’t have a std::scoped_lock.

Okay, this is not quite a catastrophic situation. You could, in theory, fix things. The unique_multilock is broken, but you could .release() all the lockables. That would at least fix the unique_lockable. You would then have to check all the lockables (and/or inspect the exception) to see what’s locked, if anything.

Still, this is not good. Remember, std::unique_lock can never be broken, if the contract is honoured. unique_multilock can. You won’t immediately know if it is broken; if there is an error, it might still be okay. And it can potentially be fixed. But the fact that it can be broken even if you follow all the rules is really, really not good.

It can also be broken when UNlocking. When doing .unlock(), if locking any of the lockables fails, you are in a dangerous position. You could try to continue unlocking the others, but that risks a second failure. If you don’t want a termination, you would have to collect all the exceptions, or something like that. But no matter what you do, the unique_multilock will probably be broken: whatever state it is left thinking itself in, some lockables may still be locked, and some may be unlocked. You can’t seriously think to roll-back any unlocks that succeeded, so if any did succeed before the error occurred, you have a mixed-bag state.

Again, it’s “fixable”. But again, objects should never become broken if you use their interface correctly. Not objects of well-designed classes at least.

Now, at this point you may argue that the problems I’ve pointed out are all part-and-parcel of working with multiple lockables. So maybe, despite everything, unique_multilock is still a good idea. I mean, it may break, but… that’s the game we’re playing. Potential breakage is just a risk of the domain. Users of unique_multilock just have to accept that that’s the reality of using it.

But that’s wrong.

In fact, it is possible to safely work with multiple locks.

You can’t use std::lock() or std::try_lock(), because of the way they are specified. But it is possible to write versions of these functions that are guaranteed to never terminate, even in the event of multiple errors.

And with a safe lock() (or try_lock(), or try_lock_until(), or try_lock_for()), you can do this:

// Let’s assume we have 3 lockables: m1, m2, m3.
auto lock_1 = std::unique_lock{std::defer_lock, m1};
auto lock_2 = std::unique_lock{std::defer_lock, m2};
auto lock_3 = std::unique_lock{std::defer_lock, m3};

try
{
    // lock() is a version of std::lock() that will not terminate in the
    // even of multiple failures. It could collect the exceptions and
    // bundle them, or just throw the first, or whatever; doesn't matter
    // for the discussion here.
    lock(lock_1, lock_2, lock_3);

    // If we got here, all three lockables are successfully locked.
}
catch (...)
{
    // If we got here:
    //  *   at least one lockable failed to lock; and/or
    //  *   at least one lockable failed to UNlock.
    //
    // But we can trivially figure out which!
    std::println("lock_1 is locked: {}", lock_1.owns_lock());
    std::println("lock_2 is locked: {}", lock_2.owns_lock());
    std::println("lock_3 is locked: {}", lock_3.owns_lock());

    // And we can take remedial action. We can try to fix the locakble
    // that failed to lock/unlock. Or we can give up, release() it, and
    // and call it a write-off, and move on.
}

// No matter what happened above, ALL THREE LOCKS ARE VALID HERE.
//
// Now, if the scope ends, and one of those unique_locks owns its lock,
// and unlocking fails... well, then you get a termination. But that can
// *still* be checked for, and avoided.

Because std::unique_lock is always okay, even in the event of locking or unlocking failures, those three locks are always in a valid state… no matter what happens in lock(), and no matter what errors occur.

If you still think unique_multilock can maybe somehow be salvaged in that situation… well, consider this:

auto l = unique_multilock{std::defer_lock, m1, m2, m3};

try
{
    l.lock();

    // If we got here, all three lockables are successfully locked.
}
catch (...)
{
    // If we got here:
    //  *   at least one lockable failed to lock; and/or
    //  *   at least one lockable failed to UNlock.
    //
    // AND WE HAVE NO WAY TO KNOW WHICH!!!
    //
    // What? You were thinking that this was trivial with
    // std::unique_lock, so it should be trivial here, too? Couldn't
    // you just check `m?.owns_lock()`?
    //
    // Take a look at the lockable requirements. Do you see
    // `.owns_lock()`, or any function like it?
    //
    // In fact, a function like `.owns_lock()` would be *impossible* for
    // a mutex. By the time it returns, whatever it reports may have
    // become a lie due to something happening in another thread.
    //
    // You could "save" the unique_multilock by doing `.release()`...
    // but that hardly fixes any of the real problems.
}

// At this point, the unique_multilock may or may not be broken... and
// at *this* point you have no way of knowing.

Now, again, it is theoretically possible to keep track—within the multi-lock—of which of the lockables is actually locked or not. As one of my partners commented (I’m paraphrasing her): “the only sensible implementation of unique_multilock would be as a tuple of unique_locks… for a very strained (strange?) definition of ‘sensible’.” You would need a truly ghastly interface to actually leverage this, though, and a complete re-imagining of what the object is/means in order to avoid it being broken when some lockables are locked and others are not. It can’t just be a “wrapper for multiple lockables” that treats them as one.

So, in summary:

  • unique_multilock can be in a “broken” state.
  • It can be in a “broken” state even if you use its interface correctly.
  • You cannot predict when it will become “broken”; some operations are just dangerous.
  • The operations that are dangerous are… well, essentially “all” (other than the observers).
  • You cannot detect when it is “broken”; when a failure occurs, it may or may not be “broken”.
  • You can, technically, “fix” the unique_multilock (by releasing the lockables)…
  • … but that won’t actually fix the underlying problem, and…
  • … you can’t fix the underlying problem in the general case (because lockables are not required to, themselves, keep track of whether they are locked).

It is technically not completely impossible to fix the problems, but it would require a completely different abstraction. You simply cannot treat multiple lockables as a single lock with a single state of “locked” or “unlocked”, because you cannot reliably get them all to be the same state (you cannot lock them all, and you cannot UNlock them all, because either operation may fail on one or more). And, frankly, the whole quagmire is moot, because there is a better way: just use multiple locks; there is never a good reason to lump multiple locks as one.

Yeah, my initial intuition was that this was not a great idea. I am now quite convinced that it is a really bad idea.

\$\endgroup\$
7
  • \$\begingroup\$ Good point. However "the only sensible implementation of unique_multilock would be as a tuple of unique_locks" - I guess a tuple with lightweight lockable wrappers with void unlock() noexcept { try { m_lockable->unlock(); } catch(...) { std::terminate(); } } would work too. In header only implementations where unlock() doesn't throw, it'll be optimized away by most implementations. \$\endgroup\$ Commented Aug 16 at 21:50
  • \$\begingroup\$ I mean, if you want to just unconditionally terminate on any unlocking failure, you won’t even need that. Just mark unique_multilock::unlock() as noexcept. But if you want to allow for safe handling of unlocking failures, including when locking, you will need something like a tuple of unique_lock under the hood. \$\endgroup\$ Commented Aug 16 at 23:47
  • \$\begingroup\$ Oh, but if you’re thinking that you want to avoid/optimize-away the complexity when the lockables’s .unlock() is noexcept…. that can never happen for any non-trivial lockables. Even if unlocking itself was no-fail, so long as the possibility exists of calling .unlock() when the lockable isn’t locked—which will always be a possibility—you can’t mark .unlock() as noexcept. \$\endgroup\$ Commented Aug 17 at 1:11
  • \$\begingroup\$ So, basically, no unlockable will ever have a noexcept .unlock() operation. \$\endgroup\$ Commented Aug 17 at 1:13
  • \$\begingroup\$ BasicLockable says unlock() doesn't throw (but it's a narrow contract so it's not marked noexcept). gcc, clang and icx all seem to have non-throwing unlock()s for std::mutex and std::timed_mutex. The only one of the big four not being able to optimize away the try/catch seems to be MSVC. \$\endgroup\$ Commented Aug 17 at 2:19

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.