Can two mutable objects under Arc talk back and forth?

This code is a distilled example of something that came up for real as I'm learning Rust. Coming from "easier" OOP languages I sometimes write code where some mutable objects talk to each other by calling methods back and forth. I've heard that in Rust the closest thing to these "easy" object references (like in Swift, Java, etc.) is something like Arc<Mutex<T>>. But as I tried out below, those can't really go back and forth either.

Here I try to call step1, which calls step2, which calls step3 - bouncing back and forth between the two objects. That first attempt leads to deadlock. So I thought I'd pass A as an argument, so it didn't have to be unlocked a second time, but that doesn't compile either. Those methods are step1_pass, step2_pass, and step3.

Is this kind of design translatable to Rust?

#[derive(Default, Debug)]
struct A {
    num: u32,
    b: Option<Arc<Mutex<B>>>
}
#[derive(Default, Debug)]
struct B {
    num: u32,
    a: Arc<Mutex<A>>
}

impl A {
    fn step1(&mut self) {
        println!("A::step1");
        self.b.as_ref().unwrap().lock().unwrap().step2();
    }
    fn step1_pass(&mut self) {
        println!("A::step1_pass");
        let mut mg = self.b.as_ref().unwrap().lock().unwrap();
        mg.step2_pass(self);
    }
    fn step3(&mut self) {
        println!("A::step3");
    }
}
impl B {
    fn step2(&mut self) {
        println!("B::step2");
        self.a.lock().unwrap().step3();
    }
    fn step2_pass(&mut self, a: &mut A) {
        println!("B::step2_pass");
        a.step3();
    }
}
fn main() {
    let mut a = Arc::new(Mutex::new(A::default()));
    let mut b = Arc::new(Mutex::new(B { num: 1, a: a.clone() }));
    a.lock().unwrap().b = Some(b.clone());
    // This will deadlock.
    // a.lock().unwrap().step1();
}

Compiler error:

error[E0502]: cannot borrow `*self` as mutable because it is also borrowed as immutable
  --> learning/rust_lang/examples/arc_mut.rs:81:9
   |
80 |         let mut mg = self.b.as_ref().unwrap().lock().unwrap();
   |                      ------ immutable borrow occurs here
81 |         mg.step2_pass(self);
   |         ^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
82 |     }
   |     - immutable borrow might be used here, when `mg` is dropped and runs the `Drop` code for type `MutexGuard`

You could clone self.b (or the Arc within) to get around the borrow check error.

    fn step1_pass(&mut self) {
        println!("A::step1_pass");
        let arc = self.b.as_ref().unwrap().clone();
        let mut mg = arc.lock().unwrap();
        mg.step2_pass(self);
    }

But it might be better to eliminate the pattern some other way, as it still seems easy to accidentally deadlock if you're holding the mutex lock across calls that "want" to obtain the lock themselves. (Or perhaps look into using a reentrant mutex.)

I'll try to cook up an example.

2 Likes

Thank you. Something about how Rust works is still "not computing" for my brain... so I didn't see that (Arc clone).

There is no perfect analogue of Java-like freely mutable objects in Rust, due to the Java memory model making some things well-defined that would be data races in Rust. But you can approximate it at additional cost by using a design where every field has its own Mutex (or Atomic) type, instead of putting a Mutex around the whole struct:

struct A {
    num: AtomicU32,
    b: Mutex<Arc<B>>,
}
struct B {
    num: AtomicU32,
    a: Mutex<Weak<A>>,
}

Then, when calling from A to B or B to A you make sure never to have any mutex locked when you do that, and you'll end up with Java-like semantics for the calls. (Note: As a completely separate issue, you mustn't use Arc on both sides or you will create a memory leak — so I replaced one Arc with Weak to break the cycle.)

However, this is usually a bad idea — you are losing the benefits of Rust’s borrow checking and thread safety strategy, and paying the costs of emulating OOP-and-GC-oriented programming using tools not designed to do it efficiently. Writing a good Rust program requires organizing your code differently; and by “good” here I mean “efficient and thoroughly statically checked”, not merely subjectively “idiomatic”.

Consider the above “mutex every field” as being not a practical way to program, but an illustration of the semantics of the Java program replicated into Rust — you can start there and then think about how to use fewer mutexes.

5 Likes

In any language, if you have multiple threads mutating and reading shared data elements, and you don't have any synchronization, then you have race conditions. So I question whether this easy approach you're talking about is correct or not.

Or do you mean that there is only a single thread involved? If so, see Cell Rc and RefCell.

Single thread, like the example code A -> B -> A. Though in this case I don't think I can use [Ref]Cell and Rc, because sometimes the thing needs to be accessed from different threads.

People can probably offer suggestions or alternative designs if you describe the complete picture. Problems like this have specific solutions that depend on the details.

Interesting, could you give an example? Thanks!

Well, at the most basic level, every single field of an object or array[1] can be accessed from multiple threads without data race or tearing. For example, this program is sound:

class Example {
    Example ref;

    public static void main(String[] args) {
        Example parent = new Example();
        Example child1 = new Example();
        Example child2 = new Example();

        new Thread(() -> {
            while (true) {
                parent.ref = child1;
            }
        }).start();

        while (true) {
            parent.ref = child2;
        }
    }
}

To write fully equivalent lock-free code in Rust, you’d need to bring in arc-swap or a similar data structure which allows the ref field to be written atomically (and manages the reference counts of the Arcs you’d need to use for child1 and child2). In Java, this applies implicitly to every field regardless of the type of the field.

This means that in Java, locks are used for correctness, not soundness — the consequences of forgetting to use a lock are that data changes in a way that you didn’t want but is still consistent with the rules of the language; there is no undefined behavior. So if you want arc-swap style “read often, write rarely and with atomic replacement”, in Java that’s just an ordinary field.

There’s a lot more that can be said about the Java memory model and what kinds of lock-free data structures it enables, but I am not an expert on that and cannot write up a neat example. Back when I was writing concurrent Java for profit, we were lucky to have a person on our team who did understand it and could tell us exactly what we could and couldn’t get away with — but without that kind of wizardry a lot of Java programs still incidentally take advantage of being able to not add explicit locks around things which Rust would require. Thus, rewriting in Rust may often require taking different approaches to data organization — even in single-threaded code since Rust’s model applies to reentrant mutation (A calls B calls A) just as much as multi-threaded mutation.


Of course, all this is specific to Java and other languages have other approaches. But many “OOP and GC” languages have some solution such that modifying objects from multiple threads isn’t easily-hit instant UB — for example, Python has the global interpreter lock, so every such read or write is in fact sequential and can’t be a race.[2]


  1. that is, nearly all of the things that in Rust we call “places” ↩︎

  2. I hear they’re trying to eliminate the GIL but I haven’t heard what they’re replacing it with ↩︎

1 Like

Although it probably only happens on machines with word sizes smaller than 64-bit, a gotcha in Java, an exception to the rules you mentioned, is that long and double values can be torn by races unless they are declared volatile.
https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.6

Go's memory model is an interesting one since it is close to Java's model but has pointers, which cause a much bigger gotcha. Data corruption can occur if certain structs are read or written without synchronization. https://go.dev/ref/mem#restrictions

... races on multiword data structures can lead to inconsistent values not corresponding to a single write. When the values depend on the consistency of internal (pointer, length) or (pointer, type) pairs, as can be the case for interface values, maps, slices, and strings in most Go implementations, such races can in turn lead to arbitrary memory corruption.

3 Likes

One way to make it a bit easier in Rust is to make the functions operate only on unlocked references for as long as possible, rather than locking them to call methods. It may feel easier to make them free functions, so they don't feel like they are owned by any specific type. If they need to access a linked object, they will only have to lock the other object for as long as it takes to get a copy of that reference. Similarly, the lock is only held while the object is directly modified.

This is entirely untested, but shows the idea:

#[derive(Default, Debug)]
struct A {
    num: u32,
    b: Option<Arc<Mutex<B>>>
}
#[derive(Default, Debug)]
struct B {
    num: u32,
    a: Arc<Mutex<A>>
}

impl A {
    fn step1(Arc<Mutex<Self>> me) {
        println!("A::step1");
        let b = me.lock().unwrap().b.clone();
        B::step2(b.unwrap());
    }
    fn step3(Arc<Mutex<Self>> me) {
        println!("A::step3");
    }
}
impl B {
    fn step2(Arc<Mutex<Self>> me) {
        println!("B::step2");
        let a = me.lock().unwrap().a.clone();
        A::step3(a);
    }
}
fn main() {
    let mut a = Arc::new(Mutex::new(A::default()));
    let mut b = Arc::new(Mutex::new(B { num: 1, a: a.clone() }));
    a.lock().unwrap().b = Some(b.clone());
    
    A::step1(a);
}

A variation of this is to take &Mutex<Self> me, if the Arc is irrelevant for the me values.