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 thestd::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
Overload Journal #104 - August 2011 + Programming Topics
Browse in : |
All
> Journals
> Overload
> o104
(6)
All > Topics > Programming (877) Any of these categories - All of these categories |