MutexProtected: A C++ Pattern for Easier Concurrency
In this post, we will discuss the challenges of programming with locks and how the C++ language offers some useful tools to make it easier. We will start with an example in C and then use C++ to improve upon it in steps. The example APIs are based on real-life APIs from the SerenityOS kernel.
Barebones example in C
Let’s imagine a struct Thing
with a field field
that will be accessed by multiple threads. We’ll use a mutex mutex
to ensure that only one thread can access it at a time.
struct Thing {
Mutex mutex;
Field field;
};
In C, accessing Thing
would typically look something like this:
mutex_lock(&thing->mutex);
use(&thing->field);
mutex_unlock(&thing->mutex);
An obvious problem in the C version is that the mutex must be manually unlocked. Forgetting to unlock a mutex tends to have unpleasant consequences.
Improving it with a C++ RAII class
The “forgot to unlock” problem is easily solved in C++ with a RAII class for automatic locking & unlocking:
{
MutexLocker locker(thing->mutex);
use(thing->field);
}
The MutexLocker
locks the mutex when constructed and unlocks it when destroyed. No need for a manual call to mutex_unlock()
anymore.
That’s already pretty good! However, the MutexLocker
approach still has some major shortcomings:
You can still forget to lock the mutex and access
field
anyway.Developers who are unfamiliar with this code may not realize that
mutex
andfield
have this important relationship.
Even so, the MutexLocker
was our favored pattern in SerenityOS up until 2021, when we introduced a new pattern to the codebase.
Adding lambdas to the mix: introducing MutexProtected
MutexProtected
is a powerful C++ construct that addresses the main issues with MutexLocker
and makes it significantly easier to use mutexes correctly:
struct Thing {
MutexProtected<Field> field;
};
thing->field.with([&](Field& field) {
use(field);
});
Essentially, MutexProtected<T>
is a bundled mutex and T
. However, you can’t access the T
directly! The only way we’ll let you access the T
is by calling with()
and passing it a callback that takes a T&
parameter.
When called, with()
locks the mutex, then invokes the callback, and finally unlocks the mutex again before returning.
As you can see, we’ve now also solved the issue of someone forgetting to lock the mutex before accessing the field. And not only that, but since the mutex and field have been combined into a single variable, you no longer have to be aware of the relationship between the two. It’s been encoded into the type system!
When multiple fields are protected by a single mutex, we can simply combine them into a struct:
struct ManyFields {
Field1 field1;
Field2 field2;
Field3 field3;
};
struct Thing {
MutexProtected<ManyFields> fields;
};
Note that it’s still possible to make mistakes with MutexProtected
, such as deadlocking the program by using multiple MutexProtected
simultaneously in inconsistent order. Thankfully such bugs are generally trivial to diagnose compared to data races.
That concludes our look at MutexProtected
. If you’re currently working on a C++ project using mainly the MutexLocker
approach, consider adding something like our MutexProtected
to further reduce the chances of using locks incorrectly.
You can find the SerenityOS implementation on GitHub. (Note that it’s a little more sophisticated than the imaginary MutexProtected
I’ve used for examples above.)
Final notes
Although MutexProtected
was introduced to SerenityOS in 2021, we do still have a lot of code using the old MutexLocker
pattern. There are still cases where MutexLocker
works better, for example when a mutex is used to synchronize something other than data.
And yes, it is possible to make life difficult by persisting the T&
to an outside location while inside the callback. This is C++ after all, so the programmer does have great freedom. However, doing so is definitely not recommended, and we have yet to encounter anyone trying to do this in our codebase.