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:
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:
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.
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 astd::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.
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.
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).
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.
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:
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.