A shared recursive mutex in standard C++
Asked Answered
M

8

32

There is a shared_mutex class planned for C++17. And shared_timed_mutex already in C++14. (Who knows why they came in that order, but whatever.) Then there is a recursive_mutex and a recursive_timed_mutex since C++11. What I need is a shared_recursive_mutex. Did I miss something in the standard or do I have to wait another three years for a standardized version of that?

If there is currently no such facility, what would be a simple (first priority) and efficient (2nd priority) implementation of such a feature using standard C++ only?

Moneybags answered 14/4, 2016 at 10:0 Comment(2)
I wouldn't try to write such thing. how about not making the lock recursive?Danielladanielle
Needing a recursive mutex is usually a sign that the code needs to be redesigned.Dinin
S
25

Recursive property of the mutex operates with the term "owner", which in case of a shared_mutex is not well-defined: several threads may have .lock_shared() called at the same time.

Assuming "owner" to be a thread which calls .lock() (not .lock_shared()!), an implementation of the recursive shared mutex can be simply derived from shared_mutex:

class shared_recursive_mutex: public shared_mutex
{
public:
    void lock(void) {
        std::thread::id this_id = std::this_thread::get_id();
        if(owner == this_id) {
            // recursive locking
            count++;
        }
        else {
            // normal locking
            shared_mutex::lock();
            owner = this_id;
            count = 1;
        }
    }
    void unlock(void) {
        if(count > 1) {
            // recursive unlocking
            count--;
        }
        else {
            // normal unlocking
            owner = std::thread::id();
            count = 0;
            shared_mutex::unlock();
        }
    }

private:
    std::atomic<std::thread::id> owner;
    int count;
};

The field .owner needs to be declared as atomic, because in the .lock() method this field is checked without a protection from the concurrent access.

If you want to recursively call .lock_shared() method, you need to maintain a map of owners, and accesses to that map should be protected with some additional mutex.

Allowing a thread with active .lock() to call .lock_shared() makes implementation to be more complex.

Finally, allowing a thread to advance locking from .lock_shared() to .lock() is no-no, as it leads to possible deadlock when two threads attempt to perform that advancing.


Again, semantic of recursive shared mutex would be very fragile, so it is better to not use it at all.

Selvage answered 14/4, 2016 at 13:18 Comment(3)
Hmm, "If lock_shared is called by a thread that already owns the mutex in any mode (exclusive or shared), the behavior is undefined. " -- that is annoying.Haslet
@Yakk: It is specified for normal shared_mutex. Recursive property will definitely change mutex semantic, so mutex will have different requirements.Selvage
you need to maintain a set of owners and check if the current caller is in the setBarbarity
B
9

If you are on Linux / POSIX platform, you are in luck because C++ mutexes are modelled after POSIX ones. The POSIX ones provide more features, including being recursive, process shared and more. And wrapping POSIX primitives into C++ classes is straight-forward.

Good entry point into POSIX threads documentation.

Both answered 14/4, 2016 at 10:3 Comment(6)
"of such a feature using standard C++ only?" you're missing the point. you can writ anything with the native API, but the OP clearly want somehting standardDanielladanielle
@DavidHaim Unfortunately mutexes require interaction with the OS to put the thread into mutex waiting queue, which is maintained by the kernel.Both
so that should have being your answer instead, "you can't"Danielladanielle
@DavidHaim That would be impractical to ignore OS features. I would rather have the same interface with implementations for different OSes.Both
@DavidHaim but the OP clearly want somehting standard POSIX is standard, admittedly not specific to C++.Guildsman
the same goes for youDanielladanielle
H
6

Here is a quick thread-safety wrapper around a type T:

template<class T, class Lock>
struct lock_guarded {
  Lock l;
  T* t;
  T* operator->()&&{ return t; }
  template<class Arg>
  auto operator[](Arg&&arg)&&
  -> decltype(std::declval<T&>()[std::declval<Arg>()])
  {
    return (*t)[std::forward<Arg>(arg)];
  }
  T& operator*()&&{ return *t; }
};
constexpr struct emplace_t {} emplace {};
template<class T>
struct mutex_guarded {
  lock_guarded<T, std::unique_lock<std::mutex>>
  get_locked() {
    return {{m},&t};
  }
  lock_guarded<T const, std::unique_lock<std::mutex>>
  get_locked() const {
    return {{m},&t};
  }
  lock_guarded<T, std::unique_lock<std::mutex>>
  operator->() {
    return get_locked();
  }
  lock_guarded<T const, std::unique_lock<std::mutex>>
  operator->() const {
    return get_locked();
  }
  template<class F>
  std::result_of_t<F(T&)>
  operator->*(F&& f) {
    return std::forward<F>(f)(*get_locked());
  }
  template<class F>
  std::result_of_t<F(T const&)>
  operator->*(F&& f) const {
    return std::forward<F>(f)(*get_locked());
  }
  template<class...Args>
  mutex_guarded(emplace_t, Args&&...args):
    t(std::forward<Args>(args)...)
  {}
  mutex_guarded(mutex_guarded&& o):
    t( std::move(*o.get_locked()) )
  {}
  mutex_guarded(mutex_guarded const& o):
    t( *o.get_locked() )
  {}
  mutex_guarded() = default;
  ~mutex_guarded() = default;
  mutex_guarded& operator=(mutex_guarded&& o)
  {
    T tmp = std::move(o.get_locked());
    *get_locked() = std::move(tmp);
    return *this;
  }
  mutex_guarded& operator=(mutex_guarded const& o):
  {
    T tmp = o.get_locked();
    *get_locked() = std::move(tmp);
    return *this;
  }

private:
  std::mutex m;
  T t;
};

You can use either:

mutex_guarded<std::vector<int>> guarded;
auto s0 = guarded->size();
auto s1 = guarded->*[](auto&&e){return e.size();};

both do roughly the same thing, and the object guarded is only accessed when the mutex is locked.

Stealing from @tsyvarev 's answer (with some minor changes) we get:

class shared_recursive_mutex
{
  std::shared_mutex m
public:
  void lock(void) {
    std::thread::id this_id = std::this_thread::get_id();
    if(owner == this_id) {
      // recursive locking
      ++count;
    } else {
      // normal locking
      m.lock();
      owner = this_id;
      count = 1;
    }
  }
  void unlock(void) {
    if(count > 1) {
      // recursive unlocking
      count--;
    } else {
      // normal unlocking
      owner = std::thread::id();
      count = 0;
      m.unlock();
    }
  }
  void lock_shared() {
    std::thread::id this_id = std::this_thread::get_id();
    if (shared_counts->count(this_id)) {
      ++(shared_count.get_locked()[this_id]);
    } else {
      m.lock_shared();
      shared_count.get_locked()[this_id] = 1;
    }
  }
  void unlock_shared() {
    std::thread::id this_id = std::this_thread::get_id();
    auto it = shared_count->find(this_id);
    if (it->second > 1) {
      --(it->second);
    } else {
      shared_count->erase(it);
      m.unlock_shared();
    }
  }
private:
  std::atomic<std::thread::id> owner;
  std::atomic<std::size_t> count;
  mutex_guarded<std::map<std::thread::id, std::size_t>> shared_counts;
};

try_lock and try_lock_shared left as an exercise.

Both lock and unlock shared lock the mutex twice (this is safe, as the branches are really about "is this thread in control of the mutex", and another thread cannot change that answer from "no" to "yes" or vice versa). You could do it with one lock with ->* instead of ->, which would make it faster (at the cost of some complexity in the logic).


The above does not support having an exclusive lock, then a shared lock. That is tricky. It cannot support having a shared lock, then upgrading to an unique lock, because that is basically impossible to stop it from deadlocking when 2 threads try that.

That last issue may be why recursive shared mutexes are a bad idea.

Haslet answered 20/4, 2016 at 18:6 Comment(0)
H
3

It is possible to construct a shared recursive mutex using existing primitives. I don't recommend doing it, though.

It isn't simple, and wrapping the existing POSIX implementation (or whatever is native to your platform) is very likely to be more efficient.

If you do decide to write your own implementation, making it efficient still depends on platform-specific details, so you're either writing an interface with a different implementation for each platform, or you're selecting a platform and could just as easily use the native (POSIX or whatever) facilities instead.

I'm certainly not going to provide a sample recursive read/write lock implementation, because it's a wholly unreasonable amount of work for an Stack Overflow answer.

Hippy answered 14/4, 2016 at 10:42 Comment(0)
B
2

Sharing my implementation, no promises

recursive_shared_mutex.h

#ifndef _RECURSIVE_SHARED_MUTEX_H
#define _RECURSIVE_SHARED_MUTEX_H

#include <thread>
#include <mutex>
#include <map>

struct recursive_shared_mutex
{
public:

    recursive_shared_mutex() :
        m_mtx{}, m_exclusive_thread_id{}, m_exclusive_count{ 0 }, m_shared_locks{}
    {}

    void lock();
    bool try_lock();
    void unlock();

    void lock_shared();
    bool try_lock_shared();
    void unlock_shared();

    recursive_shared_mutex(const recursive_shared_mutex&) = delete;
    recursive_shared_mutex& operator=(const recursive_shared_mutex&) = delete;

private:

    inline bool is_exclusive_locked()
    {
        return m_exclusive_count > 0;
    }

    inline bool is_shared_locked()
    {
        return m_shared_locks.size() > 0;
    }

    inline bool can_exclusively_lock()
    {
        return can_start_exclusive_lock() || can_increment_exclusive_lock();
    }

    inline bool can_start_exclusive_lock()
    {
        return !is_exclusive_locked() && (!is_shared_locked() || is_shared_locked_only_on_this_thread());
    }

    inline bool can_increment_exclusive_lock()
    {
        return is_exclusive_locked_on_this_thread();
    }

    inline bool can_lock_shared()
    {
        return !is_exclusive_locked() || is_exclusive_locked_on_this_thread();
    }

    inline bool is_shared_locked_only_on_this_thread()
    {
        return is_shared_locked_only_on_thread(std::this_thread::get_id());
    }

    inline bool is_shared_locked_only_on_thread(std::thread::id id)
    {
        return m_shared_locks.size() == 1 && m_shared_locks.find(id) != m_shared_locks.end();
    }

    inline bool is_exclusive_locked_on_this_thread()
    {
        return is_exclusive_locked_on_thread(std::this_thread::get_id());
    }

    inline bool is_exclusive_locked_on_thread(std::thread::id id)
    {
        return m_exclusive_count > 0 && m_exclusive_thread_id == id;
    }

    inline void start_exclusive_lock()
    {
        m_exclusive_thread_id = std::this_thread::get_id();
        m_exclusive_count++;
    }

    inline void increment_exclusive_lock()
    {
        m_exclusive_count++;
    }

    inline void decrement_exclusive_lock()
    {
        if (m_exclusive_count == 0)
        {
            throw std::logic_error("Not exclusively locked, cannot exclusively unlock");
        }
        if (m_exclusive_thread_id == std::this_thread::get_id())
        {
            m_exclusive_count--;
        }
        else
        {
            throw std::logic_error("Calling exclusively unlock from the wrong thread");
        }
    }

    inline void increment_shared_lock()
    {
        increment_shared_lock(std::this_thread::get_id());
    }

    inline void increment_shared_lock(std::thread::id id)
    {
        if (m_shared_locks.find(id) == m_shared_locks.end())
        {
            m_shared_locks[id] = 1;
        }
        else
        {
            m_shared_locks[id] += 1;
        }
    }

    inline void decrement_shared_lock()
    {
        decrement_shared_lock(std::this_thread::get_id());
    }

    inline void decrement_shared_lock(std::thread::id id)
    {
        if (m_shared_locks.size() == 0)
        {
            throw std::logic_error("Not shared locked, cannot shared unlock");
        }
        if (m_shared_locks.find(id) == m_shared_locks.end())
        {
            throw std::logic_error("Calling shared unlock from the wrong thread");
        }
        else
        {
            if (m_shared_locks[id] == 1)
            {
                m_shared_locks.erase(id);
            }
            else
            {
                m_shared_locks[id] -= 1;
            }
        }
    }

    std::mutex m_mtx;
    std::thread::id m_exclusive_thread_id;
    size_t m_exclusive_count;
    std::map<std::thread::id, size_t> m_shared_locks;
    std::condition_variable m_cond_var;
};

#endif

recursive_shared_mutex.cpp

#include "recursive_shared_mutex.h"
#include <condition_variable>

void recursive_shared_mutex::lock()
{
    std::unique_lock sync_lock(m_mtx);
    m_cond_var.wait(sync_lock, [this] { return can_exclusively_lock(); });
    if (is_exclusive_locked_on_this_thread())
    {
        increment_exclusive_lock();
    }
    else
    {
        start_exclusive_lock();
    }
}

bool recursive_shared_mutex::try_lock()
{
    std::unique_lock sync_lock(m_mtx);
    if (can_increment_exclusive_lock())
    {
        increment_exclusive_lock();
        return true;
    }
    if (can_start_exclusive_lock())
    {
        start_exclusive_lock();
        return true;
    }
    return false;
}

void recursive_shared_mutex::unlock()
{
    {
        std::unique_lock sync_lock(m_mtx);
        decrement_exclusive_lock();
    }
    m_cond_var.notify_all();
}

void recursive_shared_mutex::lock_shared()
{
    std::unique_lock sync_lock(m_mtx);
    m_cond_var.wait(sync_lock, [this] { return can_lock_shared(); });
    increment_shared_lock();
}

bool recursive_shared_mutex::try_lock_shared()
{
    std::unique_lock sync_lock(m_mtx);
    if (can_lock_shared())
    {
        increment_shared_lock();
        return true;
    }
    return false;
}

void recursive_shared_mutex::unlock_shared()
{
    {
        std::unique_lock sync_lock(m_mtx);
        decrement_shared_lock();
    }
    m_cond_var.notify_all();
}

If a thread owns a shared lock, it may also obtain an exclusive lock without giving up it's shared lock. (This of course requires no other thread currently has a shared or exclusive lock)

Vice-versa, a thread which owns an exclusive lock may obtain a shared lock.

Interestingly, these properties also allow locks to be upgradable/downgradable.

Temporarily upgrading lock:

recusrive_shared_mutex mtx;
foo bar;

mtx.lock_shared();
if (bar.read() == x)
{
    mtx.lock();
    bar.write(y);
    mtx.unlock();
}
mtx.unlock_shared();

Downgrading from an exclusive lock to a shared lock

recusrive_shared_mutex mtx;
foo bar;

mtx.lock();
bar.write(x);
mtx.lock_shared();
mtx.unlock();
while (bar.read() != y)
{
     // Something
}
mtx.unlock_shared();
Bacterin answered 3/2, 2020 at 20:3 Comment(5)
ran a bunch of tests under tsan, seems to work fine. this is basically the simplest/best answer i've seenBarbarity
Also using it, works great, haven't seen a lockup since (and makes coding a lot easier if you can bear the overhead).Papa
There is a different implementation mentioned in this Q, KonanM/shared_recursive_mutex, which afaik only changes that shared_lock -> lock releases shared_lock first, to avoid deadlocks of two threads trying exactly that, especially since threads might keep the shared_lock for a long while before trying to upgrade it. Might be preferred for you when you have two threads that can do thatPapa
I like your implementation! One note, though: lock_shared() should block, instead of succeed, if the recursive_shared_mutex is already locked in shared mode by a second thread AND a third thread is waiting for an exclusive lock AND the current thread does not already hold a shared lock. Otherwise, an issue called "writer starvation" may happen.Heteropterous
Unfortunately, the upgrade to an exclusive lock is not as neatly possible, as you show in your example: If two threads hold a shared lock and then both threads try to upgrade to an exclusive lock, both threads will deadlock each other! I recommend that you either disallow calling lock() for threads, that already hold a shared lock, or that you throw an exception to the second (or any further) thread, that calls lock(), while already holding a shared lock.Heteropterous
P
0

I searched for a C++ read-write-lock and came across this related question. We did need exactly such a shared_recursive_mutex to control access to our "database" class from multiple threads. So, for completeness: If you are looking for another implementation example (like I was), you may want to consider this link too: shared_recursive_mutex implementation using C++17 (on github).

Features

  • C++17
  • Single Header
  • Dependency-free

It has a disadvantage though: static thread_local members specialized for a PhantomType class via template. So, you can't really use this shared_recursive_mutex in multiple separate instances of the same (PhantomType) class. Try it, if that is no restriction for you.

Phylissphyll answered 25/8, 2021 at 13:30 Comment(0)
C
0

The following implementation supports first having a unique_lock and then acquiring an additional shared_lock in the same thread:

#include <shared_mutex>
#include <thread>

class recursive_shared_mutex: public std::shared_mutex {
public:
    void lock() {
        if (owner_ != std::this_thread::get_id()) {
            std::shared_mutex::lock();
            owner_ = std::this_thread::get_id();
        }
        ++count_;
    }
    void unlock() {
        --count_;
        if (count_ == 0) {
            owner_ = std::thread::id();
            std::shared_mutex::unlock();
        }
    }
    void lock_shared() {
        if (owner_ != std::this_thread::get_id()) {
            std::shared_mutex::lock_shared();
        }
    }
    void unlock_shared() {
        if (owner_ != std::this_thread::get_id()) {
            std::shared_mutex::unlock_shared();
        }
    }
private:
    std::atomic<std::thread::id> owner_;
    std::atomic_uint32_t count_ = 0;
};
Cespitose answered 2/9, 2022 at 10:23 Comment(0)
C
0

Minimal C++17 version matching the POSIX one (pthread_rwlock_t), where shared locks can be recursive while exclusive ones cannot, and there's no up/downgrading.

#include <shared_mutex>
#include <unordered_map>

class recursive_shared_mutex : std::shared_mutex {
  using base = std::shared_mutex;
  using locks_map =
      std::unordered_map<const recursive_shared_mutex *, std::size_t>;

  locks_map &thread_shared_locks() {
    thread_local locks_map shared_locks;
    return shared_locks;
  }

public:
  void lock() { base::lock(); }
  bool try_lock() { return base::try_lock(); }
  void unlock() { base::unlock(); }

  void lock_shared() {
    if (const auto [it, inserted] = thread_shared_locks().emplace(this, 1);
        inserted)
      base::lock_shared();
    else
      ++(it->second);
  }

  bool try_lock_shared() {
    auto &locks = thread_shared_locks();
    if (const auto [it, inserted] = locks.emplace(this, 1); inserted) {
      if (base::try_lock_shared())
        return true;
      else {
        locks.erase(it);
        return false;
      }
    } else {
      ++(it->second);
      return true;
    }
  }

  void unlock_shared() {
    auto &locks = thread_shared_locks();
    const auto it = locks.find(this);
    if (0 == --(it->second)) {
      base::unlock_shared();
      locks.erase(it);
    }
  }
};
Chokeberry answered 3/7, 2023 at 2:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.