It's not correct to say that mutable references cannot coexist with immutable ones. If that were the case, Rust would be borderline unusable, because you'd be only be able to borrow from unborrowed local variables.
The purpose of the borrow checker is to protect against memory safety violations. Most importantly, a piece of data must not be simultaneously modified by two different threads, and the data must not be invalidated while something else is trying to access it. The solution is to create a sort of stack of borrows on each byte in memory. Borrows on the lover levels of the stack cannot be used until the ones on the higher levels are popped (or rather, trying to use a borrow at a lower level pops off all higher ones, and you get a borrow checker error if you try to use any of the popped borrows). Pushing a new borrow on that stack is called "reborrowing": you can only create a new borrow starting from the previous top one (since you can't access anything at the lower levels, including the original place of the data).
Consider the following example:
// We create a new place, containing a value `0u32`.
// The stack of borrows is `[x]`.
let mut p1 = 0u32;
// Now we push a new mutable borrow of the place `p1`
// onto the stack.
// The stack is `[p1, p2]`.
let p2 = &mut p1;
// `foo` is defined below. How can we use `p2` twice
// when `&mut T` is not `Copy` or `Clone`, and it should
// have been moved into `foo`? The answer is reborrowing!
// The compiler implicitly creates a new reborrow of `p2`
// for each function call.
// The stack of borrows inside this function call
// is `[p1, p2, &mut *p2]`.
foo(p2);
// And here it is also `[p1, p2, &mut *p2]`! The old reborrow
// was popped after the previous return from `foo`.
foo(p2);
fn foo(p3: &mut u32) {
// We create a mutable reborrow, just because we can.
// The stack is `[p1, p2, p3, &mut *p3]`.
let p4 = &mut *p3;
// Here we push an immutable reborrow of `p4` onto the
// stack. The place cannot be mutated while this reborrow
// is active.
// The stack is `[p1, p2, p3, p4, &*p4]`.
let p5 = &* p4;
// We use `p4` to read the value.
pritnln!("{p5}");
// We want to mutate the value. This requires us to pop off
// the last immutable reborrow.
// Since we do the mutation via `p3`, we must also pop off
// the mutable borrow `p4`.
// The stack is now `[p1, p2, p3]`.
*p3 = 5;
// If we try to use `p4` or `p5`, we get a borrow checker
// error, because they were already popped.
// dbg!(p4, p5); <-- compile error
}
Note that we didn't really discuss mutability in the above. The important part was the borrow stack.
So how does mutability and UnsafeCell
fit into this picture? Normally the compiler assumes that the pointee of &T
cannot be modified in any way. This is useful both for compiler optimizations and for programmer reasoning. It is also important for thread safety: read-only values can be freely shared between threads, because concurrent reads cannot violate memory safety if there are no writes.
UnsafeCell
allows us to mark a region of memory as "this place may be modified even if borrowed with a &
". This means that reads and writes now require special synchronization. But note that it doesn't violate the borrowing rules described above. You still cannot have multiple active mutable borrows of the same region. Trying to create multiple live mutable borrows, even if they are not used to concurrently access the memory, violates compiler's assumptions and thus violates memory safety.
Actually, the model described above still doesn't really describe what's really happening with the borrows. First, it doesn't apply to the runtime semantics. For that you need to consult the Stacked Borrows and Tree Borrows models. The thing described above is just a conservative approximation used in the static analysis.
Second, even the static model is incomplete. There are cases where multiple mutable borrows can coexist. For a long time it was a kind of hack in the compiler used to support async functions, but now there is an official way to opt-out of &mut
uniqueness using the UnsafePinned
type (not yet stable), just as you can opt-out of immutability for &T
using UnsafeCell
.
In general, you need to remember that the borrow checker isn't a source of truth, but a conservative approximation to the true rules for pointer aliasing. This makes it tractable to implement, verify and reason about, but there are plenty of cases which cannot be fitted in that mold and require unsafe code. Some of the borrow checker assumptions are sufficiently deeply ingrained in the compiler that you need special syntax to opt out of those.