Why are reads sometimes tolerated during mut borrows, and sometimes not?

Why would changing a field from a u8 to a reference make a borrow checking error go away???

This code contains a classic error -- using a value that's been mutably borrowed. playground link

#![allow(dead_code)]
pub struct Block<'a> {
    current: u8,
    unrelated: &'a u8,
}

fn f<'a>(_block: &mut Block<'a>, _u: u8) {}

fn bump<'a>(block: &mut Block<'a>) {
    f(block, block.current);  // ERROR
}

fn main() {}

I understand why it's wrong. But then why does the exact same thing work fine if the field current is a reference? playground link

#![allow(dead_code)]
pub struct Block<'a> {
    current: &'a u8,
    unrelated: &'a u8,
}

fn f<'a>(_block: &mut Block<'a>, _u: &'a u8) {}

fn bump<'a>(block: &mut Block<'a>) {
    f(block, block.current);  // OK
}

fn main() {}
3 Likes

Curious. I tried also making the lifetimes in the second example differ, and it still compiles: Rust Playground (note: this was written before your recent minimizations)

It is perhaps not surprising that the compiler deduces that reading the borrow's referent is safe (since the fact that it is borrowed at the same time that Block is mutably borrowed could serve as a proof that they're disjoint).

However, I'm surprised that it's able to read the borrow itself! :shrug:

Ain'tcha always full of surprises, rust?

Is it because in first example Block is the owner of the u8 and in the second example it's a borrower of the reference? Since it's a shared borrower, it seems like it's "safe" to allow disjointness there.

Sheesh. This is definitely a bug. I filed this issue:

https://github.com/rust-lang/rust/issues/38899

3 Likes

So I wonder if it's really a bug or not. Say someone constructs a Block instance - they need a shared ref to some u8. No matter whether this Block is then mutably borrowed, nothing really prevents existing/outstanding u8 references from existing at the same time. So even though a mutable/unique borrow exists for this Block, the inner u8 reference cannot be unique by definition. It would seem that preventing a read of current wouldn't really achieve much except "lock you out" of reading from a mutably/uniquely borrowed referent, but this "lock out" wouldn't really prevent any misuses since we've already established that there can be existing shared u8 borrows at the same time.

Or is the issue that the following two should behave identically:

fn bump<'a>(mut block: &mut Block<'a>) {
    let x = &mut block;
    let c = block.current; // ERROR: block is borrowed mutably
    let d = &*block.current; // OK currently
}

I'm fairly new to Rust myself, so curious if I'm missing something here.

The problem is that, when you borrow something mutably, you are claiming that the reference has unique access. So, in this example, the only way to access the memory should be through x:

fn bump<'a>(mut block: &mut Block<'a>) {
    let x = &mut block;
    let c = block.current; // ERROR: block is borrowed mutably
    let d = &*block.current; // OK currently
}

This is important because other parts of the compiler assume that it is true. So, for example, they would allow us to start another thread that is accessing and mutating x (e.g., using something like this in rayon):

fn bump<'a>(mut block: &mut Block<'a>) {
    let x = &mut block;
    rayon::scope(|s| {
        s.spawn(|_| { x.current = ...; }); // mutate x.current, and hence block.current
        let d = &*block.current; // OK currently -- but now it's a data race with the spawn above
    });
}

Ah, interesting example. Does that actually work? Wouldn't 'a have to be 'static in order to move the Block into that closure and have it guaranteed that the 'a refs are still valid at all in the other thread?

That's what scope does. It guarantees that any threads spawned have exited before it returns to the caller, so you can pass non-static references which outlive the scope call into the threads.

Re the issue of vec.push(vec.len()), it seems like a solution to that—making the implicit reborrowing of &mut arguments happen after all arguments are evaluated—would make OPs original code work, since the field would be copied out before the value is reborrowed. I'm not sure if such a solution would be viable though. Making f(block, ...) different from f(&mut *block, ...) would be surprising, and I can't justify partially evaluating the argument expression in the second case. The suggestion relies on viewing the reborrow as a separate step from evaluating the arguments rather than as syntax sugar for writing out the reborrow explicitly.

Thanks @stevenblenkinsop. Indeed, the data race is pretty clear there then (I had mistakenly assumed that such a thing wasn't possible due to the lifetime constraint).