Everyone asks it eventually, usually around the twentieth borrow error:
Why can’t I just have two
&mutto the same thing? I know what I’m doing. I’ll be careful.
The safety answer — “because you’d get a use-after-free” — is true and unsatisfying, because you can imagine being careful enough to avoid one. You have written C. You have been careful before.
Here is the answer that is not negotiable. The guarantee is not a warning label the compiler prints for your benefit. It is an input the compiler consumes. By the time your code reaches machine instructions, rustc has already told LLVM that no aliasing occurs, and LLVM has already generated code that is only correct if that is true. There is no version of “careful” that un-generates it.
The Nomicon’s example, worked all the way through
fn compute(input: &u32, output: &mut u32) {
if *input > 10 {
*output = 1;
}
if *input > 5 {
*output *= 2;
}
}
Read it as written. Two loads of *input, two conditional stores through output. Now ask: how many times must the machine actually load *input?
Once. input is a &u32 and output is a &mut u32, and by the aliasing rule they cannot refer to the same place — a shared reference and an exclusive reference to one location cannot coexist. So nothing between the two ifs can change *input. The compiler may:
-
cache
*inputin a register and never reload it; - eliminate the second load entirely;
-
merge the two conditions, since
*input > 10implies*input > 5; -
collapse the whole thing into an
if/elsewith a single comparison and a single store.
The result is roughly:
fn compute(input: &u32, output: &mut u32) {
let cached = *input; // one load
if cached > 10 {
*output = 2; // 1 * 2, folded at compile time
} else if cached > 5 {
*output *= 2;
}
}
One load instead of two, one branch instead of two, one store instead of possibly two, and a constant folded that could not have been folded otherwise.
💡Write the same function in C with two unsigned* parameters. Which of those four optimisations is the C compiler allowed to perform, and why?
click to reveal
None of them, unless you write restrict.
In C, void compute(const unsigned *input, unsigned *output) permits input == output. If they alias, the store *output = 1 changes *input, and the second if must see the new value. So:
- the second load cannot be eliminated — it might see a different value;
-
*input > 10no longer implies*input > 5at the second test, because*inputmay have become1in between; - the branches cannot be merged;
- nothing can be folded.
The C compiler must emit the naive version: load, test, store, load again, test, load, multiply, store. const does not help, because const on a pointer parameter is a promise about that pointer, not about the memory — it says “I will not write through this”, not “nobody will”.
The C fix is restrict: void compute(const unsigned *restrict input, unsigned *restrict output). That is a promise by the programmer, unchecked, and if you break it the behaviour is undefined. In Rust the same promise is made by the type and proved by the compiler. Same optimisation, no trust required.
What rustc actually emits
This is not a metaphor. rustc annotates LLVM IR parameters:
-
&mut T→noalias, plusdereferenceable(N)and oftenalign(N) -
&T(whereTcontains noUnsafeCell) →noalias readonly
noalias is LLVM’s own name for exactly the C restrict semantics: within this function, memory reachable through this pointer is not reachable through any other pointer the function can see. dereferenceable(N) says the first N bytes are always safely loadable — which lets LLVM hoist loads out of loops and across branches speculatively, because a load that cannot fault can be moved anywhere.
So Rust hands the optimiser two guarantees per reference parameter, on every function, automatically, with no annotation burden and no way to get it wrong. C gets them only where a human remembered to write restrict and was right.
The four families of optimisation this unlocks
-
Redundant load elimination. A value loaded through
&Tstays valid across stores through unrelated&mut U. This is the big one; it is why the example above has one load instead of two. -
Store forwarding and dead store elimination. Two stores through the same
&mutwith no intervening read can be collapsed to the last one — impossible if some other pointer might have observed the first. -
Reordering and hoisting. Loads and stores through provably-disjoint references can be scheduled freely, which is what lets a loop body be pipelined or vectorised at all. Auto-vectorisation of
for (a, b) in dst.iter_mut().zip(src)depends entirely ondstandsrcnot overlapping. - Passing in registers. A value known not to be observable through another name does not need to live in memory.
💡If noalias is such a big win, why is Rust not dramatically faster than C on everything?
click to reveal
Because the win is concentrated, not spread.
Where it is large: tight numeric loops over slices, code that repeatedly reads through one reference while writing through another, and anything auto-vectorisable. These are exactly the kernels where C programmers already reach for restrict, so a well-tuned C library has often already claimed the same win by hand.
Where it is small: code dominated by allocation, branching, syscalls, cache misses, or dynamic dispatch. If your program spends its time waiting on memory or the network, the number of loads the optimiser eliminated is noise.
There is also a cost on the other side of the ledger. Rust’s bounds checks, its Option/Result branching, and its move-heavy calling conventions all cost something, and the two roughly cancel in typical application code. The honest summary is that Rust and C compile to comparable machine code for comparable programs, and Rust gets there without a human having to promise anything.
The intellectually important point is not “Rust is faster”. It is that the guarantee has been spent. Whether it bought a lot or a little in your particular function, it has been consumed by codegen, and you cannot get a refund by being careful.
The history, which is genuinely humbling
It would be nice to end there. The real story has a decade of embarrassment in it, and leaving it out would make the argument look tidier than it is.
rustc emitted noalias early. Then it turned it off, because programs miscompiled. Then it turned it on again, and turned it off again. The switch flipped repeatedly across many releases over several years.
The bugs were not in Rust. They were in LLVM: noalias had been under-exercised because in practice almost no C code uses restrict heavily, so LLVM’s handling of it — particularly around inlining, where a noalias argument’s scope has to be tracked into the callee — had latent bugs that nobody had ever hit at scale. Rust emitted it on every reference parameter of every function, which is orders of magnitude more noalias than LLVM had ever seen, and the bugs surfaced immediately.
Fixing them took years of upstream work. It is on today. Two things are worth taking from that:
- The aliasing guarantee is not free even when the language proves it. Somebody has to make the backend correct, and “this optimisation is valid” and “this optimisation is correctly implemented” are different claims.
- Rust’s semantics were, for a while, ahead of what the toolchain could safely exploit. That is a normal state of affairs in compilers and not a scandal.
The only sanctioned way out: UnsafeCell
If the rule is consumed by codegen, how does RefCell work? It mutates through a &self — a shared reference — which by everything above should be impossible, and if LLVM believed &T were noalias readonly there, the program would miscompile.
The answer is UnsafeCell<T>. It is the only type in the language whose presence changes the aliasing rules. Any &T where T transitively contains an UnsafeCell loses the readonly annotation: the compiler stops assuming the pointee is immutable, and mutation through the shared reference becomes legal.
It is a compiler primitive. You cannot write it yourself, you cannot emulate it with transmute or raw pointers, and doing so is undefined behaviour rather than a clever trick. Every interior-mutability type in std — Cell, RefCell, Mutex, RwLock, AtomicUsize, OnceCell — bottoms out in UnsafeCell. That is why item 3.22’s runtime counter is legal and your hand-rolled version would not be.
What to say next time you are asked
Two sentences.
The aliasing rule is not a safety belt you may unbuckle when you feel confident; it is a fact the optimiser has already relied on to generate the instructions your program is made of. “I’ll be careful” would have to mean “I will also un-run the optimiser”, and it does not.
Item 3.24 puts this together with the rest into the framing worth ending the track on: the borrow checker is a proof system, not a linter.