How bad is the Potential deadlock mentioned in RwLock's document?

I use shared_mutex in C++ a lot and never found there's a potential deadlock mentioned in it's doc, but when I comes to Rust, I found this in RwLock:

The priority policy of the lock is dependent on the underlying operating system’s implementation, and this type does not guarantee that any particular policy will be used. In particular, a writer which is waiting to acquire the lock in write might or might not block concurrent calls to read , e.g.:

Potential deadlock example

// Thread 1             |  // Thread 2
let _rg = lock.read();  |
                        |  // will block
                        |  let _wg = lock.write();
// may deadlock         |
let _rg = lock.read();  |

I wondered how bad is that, and are there any practices in coding to avoid this?

2 Likes

The way to avoid it is to not lock it twice in the same thread.

4 Likes

Whether your code deadlocks depends on the conditions your threads release a lock again. If releasing a lock won't wait on another thread doing something, you should be fine. If not, then you need to be careful.

Apart from deadlocks, I think there is the problem that if you have many readers, a writer might wait for an indefinite time. Consider a database that has 1000 of readers per second, and sometimes you want to update the database. If the database uses a single std::sync::RwLock in this thought experiment, then an updating task might not be able to aquire the lock until no users access the database for read operations (e.g. at night).

Before I moved to locks that have better guarantees for properties (e.g. parking_lot), I used the following trick to circumvent that issue:

use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard};

type Data = i32;

struct Database {
    storage: Arc<RwLock<Data>>,
    prelock: Mutex<()>,
}

impl Database {
    fn read(&self) -> RwLockReadGuard<'_, Data> {
        let prelock = self.prelock.lock().unwrap();
        let result = self.storage.read().unwrap();
        drop(prelock);
        result
    }
    fn write(&mut self) -> RwLockWriteGuard<'_, Data> {
        let prelock = self.prelock.lock().unwrap();
        let result = self.storage.write().unwrap();
        drop(prelock);
        result
    }
}

fn main() {
    let mut db = Database {
        storage: Arc::new(RwLock::new(0)),
        prelock: Mutex::new(()),
    };
    // Example write operation:
    {
        let mut handle = db.write();
        *handle += 1;
    }
    // Example read operation:
    {
        let handle = db.read();
        println!("Current value is {}", &*handle);
    }
}

(Playground)

Depending on the particular implementation of Mutex, this might still cause problems, I think, but at least both the readers and the writers will have to aquire the lock on a Mutex<()> first. If there are no writers, then multiple readers may still concurrently access the database, as the prelock (Mutex) is only held while aquiring the RwLock in read mode and released quickly. However, if a writer needs to access the database, it may lock the prelock (Mutex) and keep it locked while waiting for a write-lock on the other RwLock. Then, new readers may not concurrently aquire a read-lock on the RwLock anymore, and after all readers that started their work previously have finished, the writer will be granted the write-lock.

Note that if you do async programming, it may also be necessary to use an async-aware lock. In that case, you might be interested in the note "Which kind of mutex should I use" in tokio::sync::Mutex's documentation.

1 Like

I found that in normal case rwlocks has priories, write operation may has a higher priorities, won't this help in the case you mentioned?

It should indeed help. But note that std::sync::RwLock's documentation explicitly states that:

The priority policy of the lock is dependent on the underlying operating system’s implementation, and this type does not guarantee that any particular policy will be used.

1 Like

In this case, no more than one write in a thread once a time, right?

Well, the part of the documentation you pointed out says that you should not have more than one read lock either, because that can deadlock.

1 Like

I found that the guards are not dropped in the example, which should be rare in real practice, I may misunderstand what the example trying to illustrate...

To be honest, I also didn't understand what the example tried to demonstrate. If it's really like @alice says and the example is about locking the lock twice (without unlocking it first?) in the same thread, then a priority policy where a pending write blocks another read lock would indeed cause a deadlock. But I don't think acquiring two read guards on the same lock in the same thread is something you'd normally do (but might still be good to include that warning in the docs).

1 Like

Would you mind to tell if you really come across this problem on certain platforms, or just write protected code just in case?

I used the prelock in my example above because of the warning in the documentation that the priority policy is unspecified. It might not be a problem in practice, but if the doc says it's unspecified, I don't feel good about it.

Meanwhile I use parking_lot though.

1 Like

The example in the docs is illustrating exactly the case where the first read guard is not dropped before trying to obtain a second read guard on the same thread. What the example is trying to say is that you should not do that.

5 Likes

I wasn't sure if there was anything ommitted in the code, but apparently it wasn't. Adding two explicit drops at the end of Thread 1's code might help to clarify the documentation.


Like this:

// Thread 1              |  // Thread 2
let _rg1 = lock.read();  |
                         |  // will block
                         |  let _wg = lock.write();
// may deadlock          |
let _rg2 = lock.read();  |
drop(_rg2);
drop(_rg1);
3 Likes

I don't think that helps at all. By the time you try to obtain the second read lock, your program would already be deadlocked.

1 Like

I'm just learning Rust, and happen to read about RefCell. Why isn't it (or is it), that second read could cause panic, like second borrow_mut does on RefCell?

There is no problem for having multiple calls to borrow with a RefCell because problem you ran in to with the RwLock can only happen if multiple threads are involved, and a RefCell cannot be used from more than one thread at the time.

The two explicit drops don't change anything about the code at all. They would just help (me) to understand that _rg1 is really not dropped in the blank space between the line let _rg1 = lock.read(); and // may deadlock. The problem here is that it's not clear what the blank space means. Apparently it means that nothing happens but Thread 2 is executed. This has to be understood by the reader, which may be clear for most readers, but it hasn't been clear to me. Perhaps for other readers, my change makes the code less readable.

You are misunderstanding, @jbe is trying to make the deadlock example more understandable, not solving the deadlock problem.

A lock will block (and wait) when it cannot obtain access. A RefCell will panic when there is an attempt to mutably borrow while there is another mutable borrow (RefMut) around that hasn't been dropped yet. Another difference is that RefCell cannot be shared between threads (RefCell is not Sync, and &RefCell is not Send).


Note: Of course, the RefCell will also panic if you mutably borrow when there is another immutable borrow (Ref) around that hasn't been dropped yet.

Actually, thinking about your post again, you are right that an rw-lock could be designed in a way that an attempt to read-lock the RwLock from the same thread could cause a panic.

P.S.: Maybe it isn't done because that might be an extra penalty on performance, and deadlocks are always possible (if you do something wrong) even in non-unsafe Rust code.

In this context, note that sometimes you want to move a lock-guard from one thread to the other. std::sync::RwLockReadGuard cannot be sent to a different thread (it is !Send), but some other locks' guards are Send, like tokio::sync::RwLockReadGuard. Both work differently though, as Tokio's locks interact with the task scheduler (hope I phrased that right).


Deadlocks are also an issue in DBMSs. I have experienced issues with deadlocks using PostgreSQL's implicit and explicit locks. PostgreSQL, for example, offers a deadlock detection configuration option that is set to 1 second by default. That can avoid an indefinite locking situation, but is still no complete solution. You always need to be sure to lock things in the right (or same) order, and even then it's easy to miss something when a lot of resources to be locked are involved. A difficult issue!