MutexGuard in C++

By gracefu on

Rust has a nifty thing called a MutexGuard, which is similar to a std::unique_lock in C++, but also protects accesses of the underlying data at the same time. Combined with the Rust borrow checker, this basically enforces the common practice of protecting memory with a mutex by only accessing it when it is locked.

Crash course in MutexGuard

If you're already good with this, feel free to skip to the next section.

use std::ops::AddAssign;
use std::sync::Mutex;

fn main() {
    let x = Mutex::<i32>::new(0);             // : Mutex<i32>
    {
        let mut x_handle = x.lock().unwrap(); // : MutexGuard<i32>

        // MutexGuard implements Deref and DerefMut.
        // In this case, we're using DerefMut to modify the i32 contained within.
        *x_handle = 1;

        // Here, the handle is Drop'd (Rust-speak for destructed).
        // This unlocks the mutex automatically.
    }

    // For simple operations, we can just keep the MutexGuard as a temporary.
    // There are complex rules for how long the temporary stays around:
    // https://doc.rust-lang.org/reference/destructors.html#drop-scopes
    // But you don't have to worry about it in most cases.
    // Generally, temporaries are destructed quickly, unless a borrow occurs.

    // . in Rust implicitly does Deref/DerefMut, so this is allowed.
    x.lock().unwrap().add_assign(1);

    // Scopes are still necessary when you want to do multiple things in one transaction.
    {
        let mut x_handle = x.lock().unwrap();   // : MutexGuard<i32>
        // Here, the lock is held throughout the print, and the subsequent increment.
        std::println!("{}", *x_handle);
        *x_handle += 1;
        {
            // We can use try_lock as well, which may or may not give us a MutexGuard.
            // This try_lock fails because x_handle is still alive.
            let another_handle = x.try_lock();  // : Result<MutexGuard<i32>, TryLockError<…>>
            match another_handle {
                Ok(mut handle) => *handle += 1, // : MutexGuard<i32>
                _ => {}
            }
        }
    }
    std::println!("{}", *x.lock().unwrap());
}

// Output:
// 2
// 3

Implementing MutexGuard in C++

Since C++ is the best language*, it is unthinkable that Rust has such a good feature that C++ does not. Let's implement it**!
* /s
** Well, minus the borrow checker part. So while slightly harder to misuse, it can still be abused.

First, let's work through what we want to be able to do with the MutexGuard. Porting the Rust code to C++, we get:

#include <iostream>
#include "mutexguard.hpp"

int main() {
  Mutex<int> x{0};
  {
    auto x_handle = x.lock();
    *x_handle = 1;
  }
  *x.lock() += 1;
  {
    auto x_handle = x.lock();
    std::cout << *x_handle << std::endl;
    *x_handle += 1;
    if (auto another_handle = x.try_lock(); another_handle) {
      **another_handle += 1;
    }
  }
  std::cout << *x.lock() << std::endl;
}

// Expected output:
// 2
// 3

Now we just have to implemnet the functionality. We can see that we need:

  1. Mutex should contain an int and a std::mutex
  2. Mutex::lock() should return a MutexGuard handle
  3. MutexGuard::operator*() should let us reference the int&
  4. MutexGuard::operator->()
  5. MutexGuard should act like a std::unique_lock

Let's go ahead and implement it!

#include <mutex>
#include <optional>

template <typename ValueT, typename MutexT = std::mutex>
class Mutex {
  // We store the data right beside the mutex *privately*.
  // We ask the user to only access this data through the
  // MutexGuard handle returned by lock() or try_lock().
  ValueT val;
  MutexT mut;

 public:
  class MutexGuard;
  // We construct a new MutexGuard by passing in a ref to val and a lock.
  MutexGuard lock() { return MutexGuard{val, mut}; }

  class MutexGuard {
    ValueT& ref;
    // By simply embedding a unique_lock we don't need a destructor.
    std::unique_lock<MutexT> lock;

   public:
    template <typename Lock>
    MutexGuard(ValueT& ref, Lock&& lock)
        : ref(ref), lock(std::forward<Lock>(lock)) {}

    // The equivalent of DerefMut.
    ValueT& operator*() { return ref; };
    ValueT* operator->() { return &ref; };
  };

  // Some bonus things: we can implement try_lock with std::try_to_lock.
  std::optional<MutexGuard> try_lock() {
    if (std::unique_lock lock(mut, std::try_to_lock); lock.owns_lock()) {
      return MutexGuard{val, std::move(lock)};
    } else {
      return std::nullopt;
    }
  }

  // and also have a nice forwarding constructor for initialising val.
  template <typename... Args>
  explicit Mutex(Args&&... args)
      : val(std::forward<Args>(args)...), mut() {}
};

Pretty nifty. The MutexGuard was pretty basic when it came to the Mutex, but this pattern can also be used to encapsulate larger or complex locking patterns such as hand-over-hand locking [lecture notes from csail.mit.edu]. In this case, a LinkedList would return a GuardedIterator, hopefully written while fulfilling the C++ named requirements for iterators, so algorithms may be used on the LinkedList.

I think this would be a good teaching tool for students to learn RAII concepts.

Back to home page