How-To use lock policies

One of C++’s strengths is that you can write very generic code with templates – but sometimes that also turns into a weakness. Each template does make some assumptions about how it is going to be used.

Problem

When you’re building relatively simple things like containers, etc. that may not matter too much. But when you write algorithms, sometimes they might be used in a multi-threaded context, and sometimes single-threaded.

In order to remain thread-safe, it’s best the algorithm is prepared for this, but it shouldn’t incur a cost in single-threaded usage. So why not parametrize your code with how it’s intended to be used? That’s what lock policies are.

They’re based on the understanding that thread safety is usually built upon two primitives: a mutex and a lock for the mutex. In order to parametrize your code, you need both of them. Naively, this would work something like this:

Naive lock policies #1
 1template <typename mutexT, typename lockT>
 2inline void
 3my_algorithm()
 4{
 5  mutexT mutex;
 6
 7  // enter critical section
 8  {
 9    lockT lock{mutex};
10
11    // critical section
12  }
13  // exit critical section; lock unlocks mutex at end of scope.
14}

As shown here, the example makes little sense. The only reason to lock a mutex is if there is data to be accessed that could be shared in a separate thread. Let’s improve this a bit:

Naive lock policies #2
 1template <typename mutexT, typename lockT, typename dataT>
 2inline void
 3my_algorithm(dataT & data)
 4{
 5  mutexT mutex;
 6
 7  // enter critical section
 8  {
 9    lockT lock{mutex};
10
11    // critical section - do something with "data"
12  }
13  // exit critical section; lock unlocks mutex at end of scope.
14}

Unfortunately, we’ll have to work on this a little more. Sure, we’ve now got some reference to some data that we’re working on – but the mutual exclusion exists to, well, exclude some other party from working on that same data at the same time. Which means that party needs access to the same mutex for locking to work.

Note

As a general rule many of you already know, data and its mutex “live” together; algorithms must lock the mutex that belongs to its data.

So let’s work on this more.

Naive lock policies #3
 1template <typename mutexT, typename lockT, typename dataT>
 2inline void
 3my_algorithm(dataT & data, mutexT & mutex)
 4{
 5  // enter critical section
 6  lockT lock{mutex};
 7
 8  // critical section - do something with "data"
 9
10  // exit critical section; lock unlocks mutex at end of scope.
11}

Now there are only a few problems remaining with this code.

  • First off, the lock and mutex types must match. It’s not going to work to use a std::lock with some hand-rolled mutex type.

  • Second, this code hardcodes that the mutex is available for use via a reference. It could also be a pointer!

  • Lastly, while this code works when you hand it a std::mutex and a std::unique_lock, what about when you want to use it lock free in a single threaded context?

How do policies help here?

Solution with Policies

The first thing we should do is pass the muxex and lock types together, as a policy.

Policy preparation
 1template <typename lock_policyT, typename dataT>
 2inline void
 3my_algorithm(dataT & data, typename lock_policyT::mutex_type & mutex)
 4{
 5  // enter critical section
 6  typename lock_policyT::lock_type lock{mutex};
 7
 8  // critical section - do something with "data"
 9
10  // exit critical section; lock unlocks mutex at end of scope.
11}

Now this doesn’t change the code a lot, but it does make clear that the mutex and lock type get selected together.

You can now instanciate this algorithm with a lock policy that does… well, nothing. This gets rid of overhead in your single-threaded use case.

Policy usage (null_lock_policy)
1std::vector<char> data; // or whatever
2
3using namespace liberate::concurrency;
4typename null_lock_policy::mutex_type mutex;
5my_algorithm<null_lock_policy>(data, mutex);

The mutex of the liberate::concurrency::null_lock_policy struct is empty, and the policy itself does nothing with it.

What about when you do want mutual exclusion? You can construct a lock policy out of any mutex/lock combination you’d like (at least the ones from the standard library).

Policy usage (lock_policy)
1using namespace liberate::concurrency;
2
3using my_policy = lock_policy<std::mutex, std::unique_lock<std::mutex>>;
4std::mutex mutex;
5
6// someplace else
7
8my_algorithm<my_policy>(data, mutex);

Now in this example, we’ve defined the policy type and created the mutex just before using them. In real code, remember that the mutex and data will be shared between threads (or potentially so). That usually implies that the policy is also decided upon further away from this call to your algorithm. By simply passing on the policy type through your code, you can write completely generic code that can work with or without locking.

Pointers

Now we do have references in C++, but sometimes we still need to use pointers – sometimes raw, smart preferred. If your code is complex enough that the ownership of the mutex is not so easily defined, fear not, the policy approach still works.

Policy usage with pointers
 1using namespace liberate::concurrency;
 2
 3using my_policy = lock_policy<
 4  std::smart_ptr<std::mutex>,
 5  std::unique_lock<std::mutex>
 6>;
 7auto mutex = std::make_shared<std::mutex>();
 8
 9// someplace else
10
11my_algorithm<my_policy>(data, mutex);

See? No code change.

This works, because liberate::concurrency::lock_policy is fairly clever, and does not use std::unique_lock here. Instead, it provides a very simply proxy object that accepts the pointer in its constructor, then dereferences it, and passes that on to a lock instance it holds.

This approach works for simple usages, but it doesn’t give access to any of the lock’s member functions. You can use the liberate::concurrency::lock_ref() function for this:

Usage of lock_ref
 1template <typename lock_policyT, typename dataT>
 2inline void
 3my_algorithm(dataT & data, typename lock_policyT::mutex_type & mutex)
 4{
 5  // enter critical section
 6  typename lock_policyT::lock_type lock{mutex};
 7
 8  // critical section - do something with "data"
 9
10  // Call a lock function
11  liberate::concurrency::lock_ref(lock).unlock();
12}

Warning

Not all lock implementations have member functions, let alone the same ones. Using lock_ref is not the way to write generic code, but it may nonetheless prove useful for your case.