Is it possible to share RwLock strictly as read-only between threads?

I have worker threads communicating via shared states and channels for signalling. I want one worker to be able to write to an RwLock and another worker should only be able to read from it.

use std::{
    sync::{mpsc, Arc, RwLock}, thread, time::Duration
};

pub struct State {
    some_number: u8,
}

pub struct ImmutableWorker {}

impl ImmutableWorker {
    pub fn new() -> Self { Self {} }

    // I don't want to pass the RwLock as mutable here
    pub fn run(&self, state: Arc<RwLock<State>>, event_receiver: mpsc::Receiver<()>) -> thread::JoinHandle<()> {
        std::thread::spawn(move ||
            loop {
                thread::sleep(Duration::from_millis(1000));
                if event_receiver.try_recv().is_ok() && state.read().unwrap().some_number > 0 {
                    println!("State number is above 0 so let's decrease it by one.");
                    state.write().unwrap().some_number -= 1;
                    println!("Contrary to popular belief, I was able to change the state some_number: {} So I'm not that immutable now eh?", state.read().unwrap().some_number);
                } else {
                    println!("I can't receive any signals.");
                }
            }
        )
    }
}

pub struct MutatorWorker {
    state: Arc<RwLock<State>>,
    immutable_worker: ImmutableWorker,
}

impl MutatorWorker {
    pub fn new() -> Self {
        let state = Arc::new(RwLock::new(State { some_number: 0 }));

        Self {
            state,
            immutable_worker: ImmutableWorker::new(),
        }
    }

    pub fn run(&self) {
        let (event_sender, event_receiver) = std::sync::mpsc::channel();
        let _immut_handle = self.immutable_worker.run(Arc::clone(&self.state), event_receiver);
        let state_clone = self.state.clone();

        std::thread::spawn(move || {
            loop {
                thread::sleep(Duration::from_millis(500));
                let added = state_clone.read().unwrap().some_number.saturating_add(1);
                state_clone.write().unwrap().some_number = added;
                println!("Here I increased the state some_number by one: {added} Noone else can change this number now (Hopefully).");
                event_sender.send(()).unwrap();
            }
        });
    }
}

fn main() {
    let mut_worker = MutatorWorker::new();
    mut_worker.run();

    // I'm looping aimlessly to let other threads do their work.
    loop {}
}

Running this code results in an output like this:

Here I increased the state some_number by one: 1 Noone else can change this number now (Hopefully).
State number is above 0 so let's decrease it by one.
Contrary to popular belief, I was able to change the state some_number: 0 So I'm not that immutable now eh?
Here I increased the state some_number by one: 1 Noone else can change this number now (Hopefully).
Here I increased the state some_number by one: 2 Noone else can change this number now (Hopefully).
State number is above 0 so let's decrease it by one.
Contrary to popular belief, I was able to change the state some_number: 1 So I'm not that immutable now eh?
Here I increased the state some_number by one: 2 Noone else can change this number now (Hopefully).
Here I increased the state some_number by one: 3 Noone else can change this number now (Hopefully).
State number is above 0 so let's decrease it by one.
Contrary to popular belief, I was able to change the state some_number: 2 So I'm not that immutable now eh?
Here I increased the state some_number by one: 3 Noone else can change this number now (Hopefully).
Here I increased the state some_number by one: 4 Noone else can change this number now (Hopefully).
State number is above 0 so let's decrease it by one.
Contrary to popular belief, I was able to change the state some_number: 3 So I'm not that immutable now eh?
Here I increased the state some_number by one: 4 Noone else can change this number now (Hopefully).
Here I increased the state some_number by one: 5 Noone else can change this number now (Hopefully).

What I want ideally is to pass an immutable reference to the RwLock's underlying data to the ImmutableWorker. Is there a way to achieve this?

Here is a playground link: Rust Playground

Why don't you use a channel instead of an RwLock? One thread would be holding a Sender, the other - a Receiver. If you need to update some persistent state, you could use a separate thread to manage it. Thread A would be using its Sender to send updates to the state thread, which would perform the update and send updated info to thread B.

You could also write your own wrappers around RwLock, which would enforce read-only or write-only access. E.g.

struct ReadOnlyLock<'a, T>(&'a RwLock<T>);

impl<'a, T> ReadOnlyLock<'a, T> {
    pub fn borrow(&self) -> RwLockReadGuard<'a, T> { self.0.borrow() }
}

struct WriteOnlyLock<'a, T>(&'a RwLock<T>);

impl<'a, T> WriteOnlyLock<'a, T> {
    pub fn borrow_mut(&self) -> RwLockWriteGuard<'a, T> { self.0.borrow_mut() }
}
2 Likes

You can define a trait that has a restricted interface, and then use an impl Trait parameter to force the function body to use only that interface:

pub trait HasReadGuard<T:?Sized> {
    fn read_guard(&self)->impl std::ops::Deref<Target=T>;
}

impl<T:?Sized> HasReadGuard<T> for RwLock<T> {
    fn read_guard(&self)->impl std::ops::Deref<Target=T> {
        self.read().unwrap()
    }
}

impl ImmutableWorker {
    // I don't want to pass the RwLock as mutable here
    pub fn run(&self, state: Arc<impl HasReadGuard<State>+Send+Sync+'static>, event_receiver: mpsc::Receiver<()>) -> thread::JoinHandle<()> {


// ...
1 Like

In the original code for this example, state is a struct with around 3kb of data and it's shared between 4 different threads. State updates happen with 2 milliseconds intervals. I've been reading that channels, while being more precise in what they're doing, are inevitably slower. Docs about channels show that mpmc or multi producer, multi consumer channels are still experimental. Which discourages their use for carrying payloads to multiple consumers.

I tried this, but I couldn't mark ReadOnlyLock as thread safe: Rust Playground

This is a very good solution. It avoids wrapping around the RwLock<T> by limiting the type using trait bounds and exposing a thin API on top of RwLock. One problem I'm trying to solve with it though is can I limit the trait bound on the calling side ie. MutatorWorker sets the type as read-only but ImmutableWorker has no option but what is provided to it by the caller. In current solution, ImmutableWorker can modify its code to receive the RwLock as mutable.

Thanks

Seems to work with:

struct ReadOnlyLock<T>(Arc<RwLock<T>>);

playground

PS. Make the struct pub to get rid of the warning.

2 Likes

2ms is more than enough time for channel overhead, which should be in the ones to tens of microseconds.

That experimental status only applies to including them in the standard library; the crossbeam crate has fully usable MP-MC channels available for several years now.

3 Likes

I don't understand what you're asking. The type determines whether mutation is allowed. If you use a read-only type in the interface to a function, then that function won't be able to mutate it at runtime.

If you change the type in the interface, then it is you deciding to allow mutation. This is the same as changing a parameter type from &Xxx to &mut Xxx. It is up to you to decide whether to make that change or not.

I believe OP is saying that with a trait they can modify the callee to get a RwLock they can mutate through, and that would compile without changes to the caller. They want something requiring a change on the caller side too.

So the ReadOnlyLock wrapper does meet that requirement, since the caller adds the wrapper, correct?

I should explain it better. Let's say the calling function is some independent crate calling another crate's ImmutableWorker to run. Changes to the callee crate would affect how mutability is handled in the whole program. But a stricter contract like a ReadOnlyLock ensures this would be a breaking change rather than a silent, sneaky one.

Exactly.

1 Like

The issue with that is that it wraps a reference to a RwLock, not the RwLock itself. This means it doesn't compose as well, e.g. Arc<ReadOnlyLock> is not the same as a read only Arc<RwLock>.

A more composable approach would be something like #[repr(transparent) struct ReadOnlyLock<T>(RwLock<T>);, which allows both &ReadOnlyLock and Arc<ReadOnlyLock> to work as expected, however creating them becomes a bit tricky and generally requires unsafe. It should be noted that some libraries can do this unsafe work for you, but they can also break the encapsulation by allowing anyone to cast it back to a RwLock.

1 Like

One serious problem I detected with the wrapping approach is that we can still invoke a write() if we reach out to the inner RwLock like:

state.0.write().unwrap().some_number -= 1;

This should not be a real-world issue between modules though, because state.0 is private unless explicitly made public with:

pub struct ReadOnlyRwLock<T>(pub Arc<RwLock<T>>);

If you encapsulate it correctly, this is a non-issue.

I'm still able to modify the string by accesing inner RwLock with your example:

Because you're inside the same module/library. If you isolate the ro lock within an own module or library, this is not a problem.

It's the same solution I pointed out here:

Thank you all for participating in this. I think this contract (or a more generic, unspecialized solution similar to it) should be a part of the Rust standard library. I think there's a legitimate concern with principle of least priviledge here especially when crossing crate boundaries.

Cheers!

I still think that the sender-receiver pattern as suggested by afetisov could be a more elegant solution.

1 Like