During a recent code review, I came across something similar to the following two lines of code.
std::string string; // protected by string_mutex
std::mutex string_mutex;
There is, in principle, nothing wrong with these lines and if I came across those before experimenting with multi-threading in Rust I would probably have never raised an eyebrow.
However, after seeing how Rust deals with mutexes it bothered me, that a comment (or naming guidelines) is needed to express that access to the string
variable should be protected by the mutex string_mutex
.
Also, it is possible to use string
without locking the mutex by accident.
C++ can do better, so I wrote a tiny wrapper library around std::mutex
and std::shared_mutex
: cpp-sync.
The concept is simple: cpp-sync defines the class sync::mutex<T>
that synchronizes the access to data of type T
.
The constructor takes the initial value for the T
, e.g.,:
sync::mutex<std::string> string("")
After construction, to gain access to the value of string
you need to call sync::mutex<T>::lock()
.
This returns a lock guard (a thin wrapper around std::unique_lock<Mutex>
) which provides access to the std::string
similarly to smart pointers or std::optional
via overloading the *
and ->
operators:
auto value = string.lock();
value->length();
std::string copy = *value;
Like std::unique_lock
the mutex will get automatically unlocked when value gets out of scope.
This way you can never accidentally access the string without locking the corresponding mutex and the declaration makes the code a lot more expressive in my opinion.
It can also make the code using the variable look cleaner as
std::unique_lock<std::mutex>(string_mutex);
string.append("foo");
becomes a single line:
string.lock()->append("foo");
Also, did you notice that I forget to give the unique_lock
a name?
This is a very subtle and easy-to-overlook bug that causes the destructor of the lock to run immediately, so the access to string
is not actually synchronized.
When you have a single mutex that is responsible for syncing multiple variables you need to create a small struct (or tuple) for those variables. E.g.:
struct Foo {
std::string a_string;
std::vector<int> some_numbers;
};
sync::mutex<Foo> synced_foo({
.a_string = "foo",
.some_numbers = { 0, 5 },
});
In addition, to sync::mutex<T>
, the library also provides sync::read_write_lock<T>
which provides essentially the same convenience for a std::shared_mutex
.
For naming, I oriented myself more on Rust’s RwLock
as I, personally, find it more expressive.
However, I could also see that it is more confusing to mix terminologies here.
Similar to sync::mutex<T>::lock()
it provides two member functions: sync::read_write_lock<T>::read()
and sync::read_write_lock<T>::write()
which return a lock guard for reading and writing respectively.
There can be multiple readers simultaneously, but there can be only ever one writer.
The lock guard returned from read()
provides access to a const T&
while the lock guard returned from write provides access to a non-const T&
.
Here I make the assumption that it is fine to call all const methods on a type from multiple threads.
This is true for all types provided by the standard library, but you need to be careful with third-party types or your own types, e.g., when using mutable members.
If you are interested, take a look at the Github project, and feel free to give your opinion on naming or talk about additional features you want to see.