Thread-Safe Access Guards

Thread-Safe Access Guards

By Bjørn Reese

Overload, 19(104):10-12, August 2011


Ensuring safe access to shared data can be cumbersome and error-prone. Bjørn Reese presents a technique to help.

This article describes a C++ template solution for ensuring synchronized access to variables in multi-threaded applications.

When doing multi-threaded programming in C++, we frequently come across this pattern seen in Listing 1.

class Person
{
public:
  std::string GetName() const
  {
    std::unique_lock<std::mutex> lock(mutex);
    return name;
  }
  void SetName(const std::string& newName)
  {
    std::unique_lock<std::mutex> lock(mutex);
    name = newName;
  }
private:
  std::mutex mutex;
  std::string name;
};
			
Listing 1

Every time we want to access the member variable, we must make sure that we lock the mutex. Unfortunately this pattern is error-prone. The compiler cannot alert us when we have forgotten to lock the variable before we use it.

We really would like kind of some mechanism that made sure that access to the member variables always is synchronized, and where we can control the locking scope.

The basic idea is to have two classes. An accessor that automatically locks and unlocks the mutex, and a variable wrapper that contains the mutex and the variable we wish to protect with the mutex. The wrapper prevents any access to the variable except through the accessor.

We will start with the wrapper (Listing 2).

template<typename T>
class unique_access_guard : noncopyable
{
public:
  unique_access_guard()
    : value() {}
  unique_access_guard(const T& value) 
    : value(value) {}

  template<typename A1>
  unique_access_guard(const A1& arg1)
    : value(arg1) {}
  template<typename A1, typename A2>
  unique_access_guard(const A1& arg1,
                      const A2& arg2)
    : value(arg1, arg2) {}
  // Add constructors with more arguments,
  // or use C++0x variadic templates
private:
  friend class unique_access<T>;
  std::mutex mutex;
  T value;
};
			
Listing 2

Here we have a class that can be initialized with a value, but does not allow anybody to access its value. The templated constructors are used to avoid temporary objects during intialization if T is a struct.

The accessor is a very simple smart pointer and will then look like Listing 3.

template<typename T>
class unique_access : noncopyable
{
public:
  unique_access(unique_access_guard& guard)
    : lock(guard.mutex),
      valueRef(guard.value)
  {}

  T& operator* () { return valueRef; }
  T* operator-> () { return &valueRef; }

private:
  std::unique_lock<std::mutex> lock;
  T& valueRef;
};
			
Listing 3

Let us continue with an example of how this works. We will assume that we also have defined a const_unique_access class. This can be implemented using the slicing technique. (Listing 4.)

class SafePerson
{
public:
  std::string GetName() const
  {
    const_unique_access<std::string>
       name(nameGuard);
    return *name;
  }

  void SetName(const std::string& newName)
  {
    unique_access<std::string> name(nameGuard);
    *name = newName;
  }

private:
  unique_access_guard<std::string> nameGuard;
};
			
Listing 4

Compared to the Person class, we cannot access the name member variable without locking it, so we cannot forget the lock when we are extending the class with a new member function.

What happens if our class has many member variables? That depends on our needs. If the member variables are independent from each other, then we can declare more access guards and use separate accessors for them. A more common scenario is when we want to lock all member variables when we access one or more of them. In that case we can place them in a struct. This struct can be nested in the class, as shown in Listing 5.

class SafeMemberPerson
{
public:
  SafeMemberPerson(unsigned int age) 
     : memberGuard(age) {}

  std::string GetName() const
  {
    const_unique_access<Member> 
       member(memberGuard);
    return member->name;
  }

  void SetName(const std::string& newName)
  {
    unique_access<Member> member(memberGuard);
    member->name = newName;
  }

private:
  struct Member
  {
    Member(unsigned int age) : age(age) {}

    std::string name;
    unsigned int age;
  };
  unique_access_guard<Member> memberGuard;
};
			
Listing 5

Another common scenario is the use of private helper member functions. With the normal mutex and lock method we must make sure that the member variables are locked before we call the helper function. The usual way to ensure this is to document the precondition in a comment.

With the access guards we can make this precondition explicit. There are two solutions. The first, shown in the HelperPerson class, is to pass the const_unique_access object by reference to the helper function. Now it is not possible to call the helper function unless we have an accessor and hence a lock. (See Listing 6.)

class HelperPerson
{
public:
  HelperPerson(unsigned int age)
    : memberGuard(age)
    {}

  std::string GetName() const
  {
    const_unique_access<Member>
       member(memberGuard);
    Invariant(member);
    return member->name;
  }

  void SetName(const std::string& newName)
  {

    unique_access<Member> member(memberGuard);
    Invariant(member);
    member->name = newName;
  }

private:
  void Invariant(
     const_unique_access<Member>& member) const
  {
    if (member->age < 0)
       throw std::runtime_error(
       "Age cannot be negative");
  }

  struct Member
  {
    Member(unsigned int age) : age(age) {}

    std::string name;
    unsigned int age;
  };
  unique_access_guard<Member> memberGuard;
};
			
Listing 6

The other solution, which is shown in the MemberHelperPerson class, is to let the helper be a member function of the Member struct. As we only can deference the Member struct when we have an accessor, we are guaranteed that any member function in the Member struct only runs when we have a lock. (Listing 7)

class MemberHelperPerson
{
public:
  MemberHelperPerson(unsigned int age)
    : memberGuard(age) {}

  std::string GetName() const
  {
    const_unique_access<std::string>
       member(memberGuard);
    member->Invariant();
    return member->name;
  }

  void SetName(const std::string& newName)
  {
    unique_access<std::string>
       member(memberGuard);
    member->Invariant();
    member->name = newName;
  }

private:
  struct Member
  {
    Member(unsigned int age) : age(age) {}

    void Invariant() const
    {
      // We always have unique access to the member
      // variables here
      if (age < 0)
        throw std::runtime_error(
           "Age cannot be negative");
    }

    std::string name;
    unsigned int age;
  };
  unique_access_guard<Member> memberGuard;
};
			
Listing 7

All of the above has been illustrated with unique access to member variables. The framework can be easily extended to encompass shared access as well. We need a shared_access_guard that embeds a std::shared_mutex , and a shared_access accessor to gain shared access to the member variables. If we want unique access to these sharable member variables, then we can use the unique_access accessor on the shared_access_guard .

Const correctness is used to ensure that const_unique_access and shared_access can only read member variables, and only unique_access can write them.

The overhead of using accessor guards is minimal. The construction of an accessor is the same as a std::unique_lock plus the assignment of a reference. The subsequent use of an accessor is the extra level of indirection from operator-> and operator* . Some of this will be removed by the optimizer though.

The advantages of using access guards is:

  • Guarded member variables can only be accessed when they are locked. The compiler will alert us if we attempt to do otherwise.
  • The precondition on helper functions becomes explicit, and the compiler will alert us if we use it incorrectly.
  • The mental model is simple – if we have the accessor then we have the lock – so developers are less likely to make errors with it.
  • The guards also serves as documentation for which member variables are protected by what mutex. If a class has more than one access guard then that could indicate that the class has too many responsibilities and thus is a good candidate for refactoring.

The access guards have been designed to be simple. This means that there are several of the less common use cases for locking that they do not support.

Their limitations are:

  • A variable can only be protected by at most one guard. It is not possible to have one guard to directly protect, say, the variables alpha and bravo, and another guard to protect bravo and charlie.
  • The accessors are not full smart pointers, and should be made non-copyable, so we cannot pass them around, except by reference as shown in the HelperPerson class. This omission is a deliberate trade-off to avoid the added complexity needed to ensure that the accessor does not live longer than the guard it is accessing.
  • There is no support for deferred locking ( std::defer_lock_t ) so we cannot use the std::lock() algorithm to avoid potential deadlocks. Hence if we have two or more access guards in a class, then we must make sure that they are always locked in the same order to avoid deadlocks.
  • There is no support for early unlocking (like std::unique_lock<T>::unlock() ) so we cannot have partially overlapping locks. For instance, if we have a class that contains a callback function, then we may want to lock the member variables with one mutex, and the execution of the callback with another mutex to allow that the callback can call functions that accesses the member variables. In this case we need to (1) lock the member variables, (2) use the member variables, (3) lock the callback, (4) unlock the member variables, (5) execute the callback, (6) unlock the callback, and (7) exit from the member function.
  • There is no support for a couple of more advanced locking mechanisms, such as timed mutexes, recursive mutexes, tentative locking ( try_lock() ), or upgrading ownership.

The technique described here has some commonality with the SynchronizedValue::Updater [ Dobbs ].

The main differences are that the guards do not allow access to the variables, as SynchronizedValue does, and the accessors can used with different guards, thus separating the type of access (exclusive or shared) which is a property of the accessor, not the guard.

Reference

[Dobbs] http://www.drdobbs.com/cpp/225200269






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.