Abstracting a collection of mutexes

Hello

I have some code that writes to files, and I want to ensure the write operations are sequential by using a collection of mutexes, one per path. I'm using a HashMap<String, Mutex<()>> for that, but to avoid too much contention, instead of putting this HashMap inside a Mutex, I have a vector of HashMaps, and each file path belongs to the HashMap at the vector position given by the result of a hash function on the path.

In other words,

struct FileWriter {
    locks: Vec<Mutex<HashMap<String, Mutex<()>>>>,
}

And to acquire the lock:

{
    let i = hash(&path) % VEC_LEN;
    let locks_mutex = &self.locks[i];
    let mut locks = locks_mutex.lock().await;
    let mutex = locks.entry(path.clone()).or_insert(Mutex::new(()))  ;
 
    let _guard = mutex.lock().await;
    drop(locks_mutex);

    // lock is acquired here, write to the file.
}

This is working fine, but I'd like to abstract this locking into its own data type. However, I'm having a hard time with lifetimes:

pub struct Locks(Vec<Mutex<HashMap<String, Mutex<()>>>>);

pub struct LocksGuard<'a> {
    guard: MutexGuard<'a, ()>,
}

impl Locks {
    fn lock<'a>(&'a mut self, path: String) -> LocksGuard<'a> {
        let i = hash(&path) % VEC_LEN;
        let locks_mutex = &self.0[i];
        let mut locks = locks_mutex.lock().await;
        let mutex = locks.entry(path.clone()).or_insert(Mutex::new(()));
        let guard = mutex.lock().await;
        LocksGuard { guard }
    }
}

This of course gives me "borrowed value does not live long enough" on locks, as it goes out of scope at the end of the function and guard outlives it. I've tried adding a number of other things to LockGuard to try to keep the lifetimes compatible, but I haven't been able to make this work.

Is what I'm trying to do here even possible?

No direct answer, but still some input:

let mut locks = locks_mutex.lock().await;
let mutex = locks.entry(path.clone()).or_insert(Mutex::new(()))  ; 
let _guard = mutex.lock().await;

This will degrade your map of Mutex to a single Mutex. Whever the inner Mutex is locked, the outer would be locked too. So you could just leave the inner Mutexes out and would get the same or even better performance. If you want to fix that you either need to get rid of the outer Mutex - which could be possible if that Map is immutable and shared. Or you could convert it into a HashMap<String, Arc<Mutex<()>> and copy a reference to a mutex out whenever you need it. You can then derive the lock guard from that one.

Btw: I guess you already know that what you are trying to do would end up in some unsafe code since the language could not prove anymore that your data is actually guarded by the correct Mutex. That's not the end of the world if you really need it. But maybe rather check if there exists a sharded/concurrent datastructure in a crate that already fulfills your requirements.

But once I acquire the inner mutex, I explicitly drop the outer one:

let _guard = mutex.lock().await;
drop(locks_mutex);

As I understand it, this would free the hash map to be used for paths other than the currently locked one, no? In other words, the outer mutex is only held while the inner one is being locked.

I think the code above works as intended without the Arc. Could you give more details on why it would be needed?

Thanks!

Having a lock on a Mutex is a borrow of that mutex, and using anything inside the lock is a borrow on the lock. This means that locking the inner Mutex provides a guard that borrows from the outer guard, thus the outer guard cannot be dropped while the inner guard is still exists.

1 Like

If the inner mutex didn't hold a borrow on the outer mutex, then the outer would be allowed to do something destructive to the inner, like move it or even remove and drop it entirely. The Arc suggestion provides indirection and shared ownership so that wouldn't be a problem.

1 Like

I see, though I don’t understand why the explicit drop call doesn’t cause an error in that case.

You only dropped the reference locks_mutex, but immutable references implement Copy. So when you actually take the lock, that reference is copied such that your locks guard is also borrowing from directly from self.locks[i]. Then locks_mutex is out of the picture, and dropping it doesn't matter. You would have a problem if you tried to drop locks though.

Oh, I see now! I thought I was dropping the outer guard, but I was dropping the mutex instead. Switching to HashMap<String, Arc<Mutex<()>>> and then doing it as below works:

{
    let i = hash(&path) % VEC_LEN;
    let locks_mutex = &self.locks[i];
    let mut locks = locks_mutex.lock().await;
    let mutex = locks
        .entry(path.clone())
        .or_insert(Arc::new(Mutex::new(())))
        .clone();
 
    let _guard = mutex.lock().await;
    drop(locks);

    // lock is acquired here, write to the file.
}
1 Like

So back to the original question, is it possible to implement an abstraction similar to what I mentioned in the first post (using safe Rust)?

Your inner Guard needs to borrow from something that's currently temporary, whether that's from the outer Guard or a newly cloned Arc, which makes it hard to encapsulate that. You essentially need a self-referential struct, containing that intermediate value and the final inner Guard. You might be able to use something like rental for that, but I didn't try it.

Makes sense. Thanks for the help Josh!

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.