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:


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);

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 and field 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) {

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.

Written on April 6, 2023