How to Avoid Fighting Rust Borrow Checker
The 3 important facts in Rust:
- Tree-shaped ownership. In Rust's ownership system, one object can own many children or no chlld, but must be owned by exactly one parent. Ownership relations form a tree. 1
- Mutable borrow exclusiveness. If there exists one mutable borrow for an object, then no other borrow to that object can exist. Mutable borrow is exclusive.
- Borrow is contagious. If you borrow a child, you indirectly borrow the parent (and parent's parent, and so on) if crossing function boundary. Just borrowing one wheel of a car makes you borrow the whole car. Combined with previous point, it can cause troubles for mutable data.
Note that Rust borrow checker can reject some incorrect programs but also reject some correct programs.
Considering reference shape
Firstly consider the reference 2 shape of your in-memory data.
- If the reference is tree-shaped, then it's simple and natural in Rust.
- If the reference shape has sharing, things become a little complicated.
- Sharing means there are two or more references to the same object.
- If shared object is immutable:
- If the sharing is scoped (only temporarily shared), then you can use immutable borrow. You may need lifetime annotation.
- If the sharing is not scoped (may share for a long time, not bounded within a scope), you need to use reference counting (
Rc
in singlethreaded case,Arc
in possibly-multithreaded case)
- If shared object is mutable, then it's in borrow-check-unfriendly case. Solutions elaborated below.
- Contagious borrow can cause unwanted sharing (elaborated below).
- If the reference shape has cycle, then it's also in borrow-check-unfriendly case. Solutions elaborated below.
The most fighting with borrow checker happens in the borrow-check-unfriendly cases.
Summarize solutions
The solutions in borrow-checker-unfriendly cases (will elaborate below):
- Data-oriented design. Avoid unnecessary getter and setter. Split borrow.
- Avoid just-for-convenience circular reference.
- Use ID/handle to replace borrow.
- Avoid mutation or defer mutation.
Arc<QCell<T>>
,Arc<RwLock<T>>
- Use unsafe and raw pointer.
Contagious borrow issue
The previously mentioned two important facts:
- Mutable borrow exclusiveness. If you mutable borrow it, others cannot borrow it.
- Borrow is contagious. Just borrowing one wheel of a car makes you borrow the whole car, if the borrow crosses function boundary. (unless using split borrow that works within one scope)
A simple example: 3
pub struct Parent {
total_score: u32,
children: Vec<Child>
}
pub struct Child {
score: u32
}
impl Parent {
fn get_children(&self) -> &Vec<Child> {
&self.children
}
fn add_score(&mut self, score: u32) {
self.total_score += score;
}
}
fn main() {
let mut parent = Parent{total_score: 0, children: vec![Child{score: 2}]};
for child in parent.get_children() {
parent.add_score(child.score);
}
}
Compile error:
25 | for child in parent.get_children() {
| ---------------------
| |
| immutable borrow occurs here
| immutable borrow later used here
26 | parent.add_score(child.score);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
This code is totally memory-safe: the .add_score()
only touch the total_score
field, and .get_children()
only touch the children
field. They work on separate data, they still clashes, because of contagious borrow:
- In
fn get_children(&self) -> &Vec<Child> { &self.children }
, although the method body just borrowschildren
field, the return value indirectly borrows the wholeself
, not just one field. - In
fn add_score(&mut self, score: u32) { self.total_score += score; }
, the function body only mutably borrowedtotal_score
field, but the argument&mut self
borrows the wholeParent
, not just one field. - Inside the loop, one immutable borrow to the whole
Parent
and one mutable borrow to the wholeParent
overlaps in lifetime.
The core problem is that you just want to borrow one field, but forced to borrow the whole object.
What if I just inline get_children
and add_score
? Then it compiles fine:
pub struct Parent {
total_score: u32,
children: Vec<Child>
}
pub struct Child {
score: u32
}
fn main() {
let mut parent = Parent{total_score: 0, children: vec![Child{score: 2}]};
for child in &parent.children {
let score = child.score;
parent.total_score += score;
}
}
Why that compiles? Because it does a split borrow: the compiler sees borrowing of individual fields in one function (main()
), and don't do contagious borrow.
The deeper cause is that:
- Borrow checker works locally: when seeing a function call, it only checks function signature, instead of checking code inside the function. (Its benefit is to make borrow checking faster and simpler. Doing whole-program analysis is hard and slow, and doesn't work with things like dynamic linking.)
- Information is lost in function signature: the borrowing information becomes coarse-grained and is simplified in function signature. The type system does not allow expressing borrowing only one field, and can only express borrowing the whole object. There are propsed solutions.
The solutions:
- Avoid OOP-style getter and setter (just make fields public), unless necessary.
- Data-oriented design (DOD). Just directly work on data and make fields public.
- If you design a library and want encapsulation, it's recommended to use ID/handle to replace borrow (elaborated below).
- The getter that returns cloned/copied value is fine. If data is immutable, getter is also usually fine.
- Use ID/handle to replace borrow.
- Other workarounds like
Arc<QCell<T>>
Arc<RwLock<T>>
, etc. - Avoid mutation or defer mutation:
Avoid mutation or defer mutation
The previous problem occurs partially due to mutable borrow exclusiveness. If all borrows are immutable, then contagious borrow is usually not a problem.
The common way of avoiding mutation is mutate-by-recreate: All data is immutable. When you want to mutate something, you create a new version of it. Just like in pure functional language (e.g. Haskell).
Unfortunately, mutate-by-recreate is also contagious: if you recreated a new version of a child, you need to also recreate a new version of parent that holds the new child, and parent's parent, and so on. There are abstractions like lens to make this kind of cascade-recreate more convenient.
Mutate-by-recreate can be useful for cases like:
- Safely sharing data in multithreading (read-copy-update (RCU), copy-on-write (COW))
- Take snapshot and rollback efficiently
Mutate-by-recreate can be optimized by sharing unchanged sub-structures. See also: Persistent data structure, Rope.
Another solution is to treat mutation as data. To mutate something, append a mutation command into command queue. Then execute the mutation commands at once. (Note that command should not indirectly borrow base data.)
- In the process of creating new commands, it only do immutable borrow to base data, and only one mutable borrow to the command queue.
- When executing the commands, it only do one mutable borrow to base data at a time.
What if I need the latest state before executing the commands in queue? Then inspect both the command queue and base data to get latest state (LSM tree does similar things). You can often avoid needing to getting latest state during processing, by separating it into multiple stages.
Treating mutation as data also has other benefits:
- The mutation can be serialized, and sent via network or saved to disk.
- The mutation can be inspected for debugging and logging.
- You can post-process the command list, such as sorting, filtering. If the data is sharded, the mutation command can dispatch to specific shard.
- In distributed system, there is a log (command list) that's synchronized between nodes using a consensus protocol (like Raft). The log is source-of-truth: the mutable state is completely derived from the log (and previous state checkpoints).
- The idea of turning operations into data is also adopted by io_uring and modern graphics APIs (Vulkan, Metal, WebGPU).
The previous code rewritten using deferred mutation:
pub struct Parent {
total_score: u32,
children: Vec<Child>
}
pub struct Child {
score: u32
}
pub enum Command {
AddTotalScore(u32),
// can add more kinds of commands
}
impl Parent {
fn get_children(&self) -> &Vec<Child> {
&self.children
}
fn add_score(&mut self, score: u32) {
self.total_score += score;
}
}
fn main() {
let mut parent = Parent{total_score: 0, children: vec![Child{score: 2}]};
let mut commands: Vec<Command> = Vec::new();
for child in parent.get_children() {
commands.push(Command::AddTotalScore(child.score));
}
for command in commands {
match command {
Command::AddTotalScore(num) => {
parent.add_score(num);
}
};
}
}
About circular reference
Circular reference in mathematics
Some may argue that "Circular reference is a bad thing. Look how much trouble do circular references create in mathematics":
Details
-
Circular proof: if A then B, if B then A. Circular proof is wrong. It can prove neither A nor B.
-
The set that indirectly includes itself cause Russel's paradox: Let R be the set of all sets that are not members of themselves. R contains R deduces R should not contain R, and vice versa. Set theory carefully avoids cirular reference.
-
Halting problem is proved impossible to solve, by using circular reference:
Assume there exists a function
halts(program, input)
, which takes in aprogram
andinput
data, and outputs a boolean telling whetherprogram(input)
will eventually halt.Then construct a paradox program
paradox
:
fn paradox(program: Program) {
if halts(program, program) {
while true {} // dead loop
} else {
return; // halts
}
}
Then halts(paradox, paradox)
will cause a paradox. If it returns true, then paradox(paradox)
halts, but in paradox
's definition it should deadloop.
Rice's theorem is an extension to Halting problem: All non-trivial semantic properties of programs are undecidable (includes whether it eventually halts).
- Gödel's incomplete theorem.
- Firstly encode symbols, statements and proofs into data 4. The statements that contain free variables (e.g. x is a free variable in "x is an even number") can also be encoded (it can represent "functions" and even "higher-order functions").
is_proof(theory, proof)
allows determining whether a proof successfully proves a theory.- Then
provable(theory)
is defined as whether there exists aproof
that satisfiesis_proof(theory, proof)
. - Unprovable is inverse of provable:
unprovable(theory) = !provable(theory)
- Let
H(x) = unprovable(x(x))
. Then letG = H(H) = unprovable(H(H)) = unprovable(G)
5, which creates a self-referencial statement:G
meansG
is not provable. IfG
is true, thenG
is not provable, thenG
is false, which is a paradox.
There is something in common between Halting problem, Russel's paradox and Gödel's incomplete theorem: they all self-reference and "negate" itself, causing paradox.
Circular reference in programming
Circular reference being bad in mathematics does NOT mean they are also bad in programming. The circular reference in math theories are different to circular reference in data. There are many valid cases of circular references in programming (e.g. there are doublely-linked list running in Linux kernel that works fine).
But circular reference do add risks to memory management:
- In C/C++, circular reference need to be carefully handled to avoid use-after-free.
- In GC languages, circular reference has memory leak risk. If all children references parents, and parents references children, then referencing any child will keep the whole structure alive.
Here are some common use cases of circular reference:
- Case 1: The parent references a child. The child references its parent, just for convenience. (Referencing to parent is not necessary, parent can be passed by argument)
- Case 2: The parent registers a callback to child. When something happened on child, the callback is called, and parent do something. It that case, parent references child, child references callback, callback references parent.
- Case 3: In a tree structure, the child references parent allows getting the path from one node to root node. Without it, you cannot get the path from just one node reference, and need to store variable-length path information.
- Case 4: The data is inherently a graph structure that can contain cycles.
- Case 5: The data requires self-reference.
Avoid "just-for-convenience" circular reference in Rust
In the case 1 above: The child references parent, just for convenience. In OOP code. If you just have a reference to a child object, and you want to use some data in parent, child referencing parent would be convenient. Without it, the parent also need to be passed as argument.
That convenience in OOP languages will lead to troubles in Rust. It's recommended to pass extra arguments instead of having circular reference.
Note that due to previously mentioned contagious borrow issue, you cannot mutably borrow child and parent at the same time (except using interior mutability). The workaround is to 1. do a split borrow on parent and pass the individual components of parent (pass more arguments and be more verbose than in other languages) 2. use interior mutability (e.g. RefCell
, Mutex
, QCell
).
The callback circular reference
Observer pattern is commonly used in GUI and other dynamic reactive systems. If parent want to be notified when some event happens on child, the parent register callback to child, and child calls callback when event happens.
However, the callback function object often have to reference the parent (because it need to use parent's data). Then it creates circular reference: parent references child, child references callback, callback references parent, as mentioned previously in case 2.
Solutions:
-
Use reference counting and interior mutability. The classical ones:
Rc<RefCell<T>>
(singlethreaded),Arc<RwLock<T>>
(multithreaded). I also recommend usingQCell
(elaborated below):Rc<QCell<T>>
,Arc<QCell<T>>
.The back-reference (callback to parent, child to parent) should use
Weak
to avoid memory leak. -
Use event bus to replace callbacks. Similar to the previous deferred mutation, we turn event into data. Each component listen to specific "event channel" or "event topic". When something happens, put the event into event bus, then event bus notifies components.
-
Use ID/handle to replace borrow (elaborated later).
The circular reference that's inherent in data structure
In the previously mentioned case 3 and case 4, circular reference is needed in data structure.
Solutions:
- Use reference counting and interior mutability (previously mentioned). This is recommended when there are many different types of components and you want to add new types easily (like in GUI).
- Use ID/handle to replace borrow (elaborated later). This is recommended when you want more compact memory layout, and you rarely need to add new types into data (suitable for data-intensive cases, can obtain better performance due to cache-friendliness).
Self-reference
Self-reference means a struct contains an interior pointer to another part of data that it owns.
Zero-cost self reference requires Pin
and unsafe
. Normal Rust mutable borrow allow moving the value out (by mem::replace
, or mem::swap
, etc.). Pin
disallows that, as self-reference pointer can be invalidated by moving. They are complex and hard to use. Workarounds includes separating child and use reference counting so it's no longer self-reference.
Use handle/ID to replace borrow
Data-oriented design:
- Try to pack data into contagious array, (instead of objects laid out sparsely managed by allocator).
- Use handle (e.g. array index) or ID to replace reference.
- Decouple object ID with memory address. An ID can be save to disk and sent via network, but a pointer cannot (the same address cannot be used in another process or after process restart, because there may be other data in the same address).
- The different fields of the same object doesn't necessarily need to be together in memory. The one field of many objects can be put together (parallel array).
- Manage memory based on arenas.
One kind of arena is slotmap:
- Slotmap is basically an array of elements, but each element has a version integer.
- Each handle (key) has an index and a version.
- When accessing the slotmap, it firstly does a bound check, then checks version.
- After removing element, the version increments. The previous handle cannot get the new element at the same index, because of version mismatch.
- The handles (keys) are just
Copy
-able data that's not restricted by borrow checker. - Although memory safe, it still has the equivalent of "use-after-free": using a handle of an already-removed object cannot get element from the slotmap 6. Each get element operation may fail.
- Note that slotmap is not efficient when there are many unused empty space between elements. Slotmap offers two other variants for sparce case.
Other map structure, like HashMap
or TreeMap
can also be arenas.
If no element can be removed from arena, then a Vec
can also be an areana.
One important fact: when we use ID/handle to replace borrow, the borrow checker no longer ensure that the ID/handle will point to a living object.
Entity component system
Entity component system (ECS) is a way of organizing data that's different to OOP. In OOP, an object's fields are laid together in memory. But in ECS, each object is separated into components. The same kind of components for different entities are managed together (often laid together in memory). It can improve cache-friendliness. (Note that performance is affected by many factors and depends on exact case.)
ECS also favors composition over inheritance. Inheritance tend to bind code with specific types that cannot easily compose.
(For example, in an OOP game, Player
extends Entity
, Enemy
extends Entity
. There is a special enemy Ghost
that ignores collision and also extends Enemy
. But one day if you want to add a new player skill that temporarily ignores collision like Ghost
, you cannot make Player
extend Ghost
and have to duplicate code. In ECS that can be solved by just combining special collision component.)
Generalized reference and two reference semantics
The concept of generalized reference:
- The reference in GC languages is generalized reference.
- Pointer is generalized reference.
- Borrowing in Rust is generalized reference.
- Ownership in Rust is also considered as generalized reference.
- Smart pointer (
Rc
,Arc
,Weak
,Box
in Rust,shared_ptr
,weak_ptr
,unique_ptr
in C++, etc.) are generalized reference. - IDs are generalized reference. (It includes all kinds of IDs, including handles, UUID, string id (URL, file path, username, etc.), integer id, primary key, and all kinds of identification information).
The generalized reference is separated into two kinds: strong and weak:
-
Strong generalized reference: The system ensures it always points to a living object.
It contains: normal references in GC languages (when not null), Rust borrow and ownership, strong reference counting (
Rc
,Arc
,shared_ptr
when not null), and ID in database with foreign key constraint. -
Weak generalized reference: The system does NOT ensure it points to a living object.
It contains: ID (no foreign key constraint), handles, weak reference in GC languages, weak reference counting (
Weak
,weak_ptr
).
The major differences:
- For weak generalized references, every data access may fail, and requires error handling. (just panic is also a kind of error handling)
- For strong generalized reference, the lifetime of referenced object is tightly coupled with the existence of reference:
- In Rust, the coupling comes from borrow checker. The borrow is limited by lifetime and other constraints.
- In GC langauges, the coupling comes from GC. The existence of a strong reference keeps the object alive. Note that in GC languages there are live-but-unusable objects (e.g. Java
FileInputStream
is unusable after closing). - In reference counting, the coupling of course comes from runtime reference counting.
- For weak generalized reference, the lifetime of object is decoupled from referces to it.
If you want to design an abstraction that decouples object lifetime and how these objects are referenced, it's recommended to either:
- Use weak generalized reference, such as ID and handle. The object can be freed without having to consider how its IDs are held.
- Use strong generalized reference, but add a new usability state that's decoupled with object lifetime. This is common in GC languages. Examples:
- In JS, if you send an
ArrayBuffer
to another web worker, theArrayBuffer
object can still be referenced and kept alive, but the binary content is no longer accessible from thatArrayBuffer
object. - In Java, the IO-related objects (e.g.
FileInputStream
) can no longer be used after closing, even these objects are still referenced and still alive.
- In JS, if you send an
Mutable borrow exclusiveness
As previously mentioned, Rust has mutable borrow exclusiveness:
- A mutable borrow to one object cannot co-exist with any other borrow to the same object. Two mutable borrows cannot co-exist. One mutable and one immutable also cannot co-exist.
- Multiple immutable borrows for one object can co-exist.
That is also called "mutation xor sharing", as mutation and sharing cannot co-exist.
In multi-threading case, this is natural: multiple threads read the same immutable data is fine. As long as one thread mutates the data, other thread cannot safely read or write it without other synchronization (atomics, locks, etc.).
But in single-threaded case, this restriction is not natural at all. No mainstream language (other than Rust) has this restriction.
Mutation xor sharing is, in some sense, neither necessary nor sufficient. It’s not necessary because there are many programs (like every program written in Java) that share data like crazy and yet still work fine. It’s also not sufficient in that there are many problems that demand some amount of sharing – which is why Rust has “backdoors” like
Arc<Mutex<T>>
,AtomicU32
, and—the ultimate backdoor of them all—unsafe
.
Interior pointer
Rust has interior pointer. Interior pointer are the pointers that point into some data inside another object. A mutation can invalidate the memory layout that interior pointer points to. So that mutable borrow exclusiveness is still important for memory safety in single thread:
For example, you can take pointer of an element in Vec
. If the Vec
grows, it may allocate new memory and copy existing data to new memory, thus the interior pointer to it can become invalid (break the memory layout that interior pointer points to). Mutable borrow exclusiveness can prevent this issue from happening:
fn main() {
let mut vec: Vec<u32> = vec!(1, 2, 3);
let interior_pointer: &u32 = &vec[0];
vec.push(4);
print!("{}", *interior_pointer);
}
Compile error:
3 | let interior_pointer: &u32 = &vec[0];
| --- immutable borrow occurs here
4 | vec.push(4);
| ^^^^^^^^^^^ mutable borrow occurs here
5 | print!("{}", *interior_pointer);
| ----------------- immutable borrow later used here
Another example is about enum
: interior pointer pointing inside enum
can also be invalidated, because different enum variants has different memory layout 7. In one layout the first 8 bytes is integer, in another layout the first 8 bytes may be a pointer. Treating an integer as a pointer is definitely not memory-safe.
enum DifferentMemoryLayout {
A(u64, u64),
B(String)
}
fn main() {
let mut v: DifferentMemoryLayout = DifferentMemoryLayout::A(1, 2);
let interior_pointer: &u64 = match v {
DifferentMemoryLayout::A(ref a, ref b) => {a}
DifferentMemoryLayout::B(_) => { panic!() }
};
v = DifferentMemoryLayout::B("hello".to_string());
println!("{}", *interior_pointer);
}
Compile error:
9 | DifferentMemoryLayout::A(ref a, ref b) => {a}
| ----- `v` is borrowed here
...
12 | v = DifferentMemoryLayout::B("hello".to_string());
| ^ `v` is assigned to here but it was already borrowed
13 | println!("{}", *interior_pointer);
| ----------------- borrow later used here
Note that sometimes mutating can keep validity of interior pointer. For example, changing an element in Vec<u32>
doesn't invalidate interior pointer to elements, because there is no memory layout change. But Rust by default prevents all mutation when interior pointer exists (unless using interior mutability).
Interior pointer in other languages
Golang also supports interior pointer, but doesn't have such restriction. For example, interior pointer into slice:
package main
import "fmt"
func main() {
slice := []int{1, 2, 3}
interiorPointer := &slice[0]
slice = append(slice, 4)
fmt.Printf("%v\n", *interiorPointer)
fmt.Printf("old interior pointer: %p new interior pointer: %p\n", interiorPointer, &slice[0])
}
Output
1
old interior pointer: 0xc0000ac000 new interior pointer: 0xc0000ae000
Because after re-allocating the slice, the old slice still exists in memory (not immediately freed). If there is an interior pointer into the old slice, the old slice won't be freed by GC. The interior pointer will always be memory-safe (but may point to stale data).
Golang also doesn't have sum type, so there is no equivalent to enum memory layout change in the previous Rust example.
Also, Golang's doesn't allow taking interior pointer to map entry value, but Rust allows. Rust's interior pointer is more powerful than Golang's.
In Java, there is no interior pointer. So no memory safety issue caused by interior pointer.
But in Java there is one thing logically similar to interior pointer: Iterator
. Mutating a container can cause iterator invalidation:
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer value = iterator.next();
if (value < 3) {
list.remove(0);
}
}
}
}
That will get java.util.ConcurrentModificationException
. Java's ArrayList
has an internal version counter that's incremented every time it changes. The iterator code checks concurrent modification using version counter.
Even without the version check, it will still be memory-safe because array access is range-checked.
Note that iteration invalidation is logic error, no matter whether it's memory-safe or not.
In Java, you can remove element via the iterator, then the iterator will update together with container, and no longer invalidate. Or use removeIf
that avoids managing iterator.
Mutable borrow exclusiveness is still important in single-threaded case, because of interior pointer. But if we don't use any interior pointer, then mutable borrow exclusiveness is not necessary for memory safety in single-thread case.
That's why mainstream languages has no mutable borrow exclusiveness, and still works fine in single-threaded case. Java, JS and Python has no interior pointer. Golang and C# have interior pointer, they have GC and restrict interior pointer, so memory safe is still kept without mutable borrow exclusiveness.
One benefit of interior pointer is to allow tight memory layout, without having to do extra heap allocation just to get a reference some inner data.
Interior mutability summary
Mutable borrow exclusiveness is overly restrictive. It is not necessary for memory safety in single-threaded code when not using interior pointer. There is interior mutability that allows getting rid of that constraint.
Interior mutability allows you to mutate something from an immutable reference to it. (Because of that, immutable borrow doesn't necessarily mean the pointed data is actually immutable. This can cause some confusion.)
Ways of interior mutability:
Cell<T>
. It's suitable for simple copy-able types like integer. In the previous contagious borrow example, if thetotal_score
is replaced withCell<u32>
then mutating it doesn't need mutable borrow of parent thus avoid the issue.Cell<T>
only supports replacing the wholeT
at once, and doesn't support getting a mutable borrow.RefCell<T>
, suitable for data structure that does incremental mutation, in single-threaded cases. It has internal counters tracking how many immutable borrow and mutable borrow currently exist. If it detects violation of mutable borrow exclusiveness,.borrow()
or.borrow_mut()
will panic.It can cause crash if there is nested borrow that involves mutation.Mutex<T>
RwLock<T>
, for locking in multi-threaded case. Its functionality is similar toRefCell
. Note that unnecessary locking can cost performance, and has risk of deadlock. It's not recommended to overuseArc<Mutex<T>>
just because it can satisfy the borrow checker.QCell<T>
. Elaborated below.
They are usually used inside reference counting (Arc<...>
, Rc<...>
).
RefCell
is not panacea
In the previous contagious borrow case, wrapping parent in RefCell<>
can make the code compile. However it doesn't fix the issue. It just turns compile error into runtime panic:
use std::cell::RefCell;
pub struct Parent { total_score: u32, children: Vec<Child> }
pub struct Child { score: u32 }
impl Parent {
fn get_children(&self) -> &Vec<Child> {
&self.children
}
fn add_score(&mut self, score: u32) {
self.total_score += score;
}
}
fn main() {
let parent: RefCell<Parent> = RefCell::new(Parent{total_score: 0, children: vec![Child{score: 2}]});
for child in parent.borrow().get_children() {
parent.borrow_mut().add_score(child.score);
}
}
It will panic with RefCell already borrowed
error.
In Rust, just having a mutable borrow &mut T
, Rust assumes that you can use it at any time. But holding the reference is different to using reference. It's entirely possible that I have two &mut T
for same object, but I only use one at a time. This is the use case that RefCell
solves.
RefCell
still follows mutable borrow exclusiveness rule. In previous contagious borrow example, the Parent
is borrowed one immutablely and one mutable, thus RefCell
will still panic at runtime.
Another problem: It's hard to return a reference borrowed from RefCell
.
As the previous example can be fixed by Cell
, without RefCell
, here is another contagious borrow example:
use std::collections::HashMap;
pub struct Parent {
entries: HashMap<String, Entry>,
passed_names: Vec<String>
}
pub struct Entry { score: u32 }
impl Parent {
fn get_entries(&self) -> &HashMap<String, Entry> {
&self.entries
}
fn add_name(&mut self, name: &str) {
self.passed_names.push(name.to_string());
}
}
fn main() {
let mut map: HashMap<String, Entry> = HashMap::new();
map.insert("a".to_string(), Entry { score: 100 });
let mut parent = Parent {
entries: map, passed_names: vec![]
};
for (name, entry) in parent.get_entries() {
if (entry.score > 20) {
parent.add_name(name);
}
}
}
Compile error
22 | for (name, entry) in parent.get_entries() {
| --------------------
| |
| immutable borrow occurs here
| immutable borrow later used here
23 | if (entry.score > 20) {
24 | parent.add_name(name);
| ^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
Then let's try to fix it using RefCell
. As previously mentioned, wrapping Parent
in RefCell
doesn't work. We need to wrap two fields of parent into RefCell
:
pub struct Parent {
entries: RefCell<HashMap<String, Entry>>,
passed_names: RefCell<Vec<String>>
}
pub struct Entry { score: u32 }
impl Parent {
fn get_entries(&self) -> &HashMap<String, Entry> {
self.entries.borrow()
}
fn add_name(&self, name: &str) {
self.passed_names.borrow_mut().push(name.to_string());
}
}
But returning a reference in RefCell
is not that simple:
error[E0308]: mismatched types
--> src\main.rs:10:9
|
9 | fn get_entries(&self) -> &HashMap<String, Entry> {
| ----------------------- expected `&HashMap<String, Entry>` because of return type
10 | self.entries.borrow()
| ^^^^^^^^^^^^^^^^^^^^^ expected `&HashMap<String, Entry>`, found `Ref<'_, HashMap<String, Entry>>`
|
= note: expected reference `&HashMap<_, _>`
found struct `Ref<'_, HashMap<_, _>>`
help: consider borrowing here
|
10 | &self.entries.borrow()
| +
Because the reference borrowed from RefCell
is not normal reference, it's actually Ref
. Ref
implements Deref
so it can be used similar to a normal borrow. But it's different to a normal borrow.
The "help: consider borrowing here" suggestion won't solve the compiler error. If you follow its instruction you will get new compile error "returns a reference to data owned by the current function". It's important that compiler's suggestion may be misleading. Don't focus too much on compiler's suggestion.
Returning Ref
works:
impl Parent {
fn get_entries(&self) -> Ref<HashMap<String, Entry>> {
self.entries.borrow()
}
}
But returning Ref
defeats the purpose of getter abstraction. Ref
is tightly coupled with RefCell
. For example, if one day the value returned by get_entries
become data that's computed on demand:
impl Parent {
fn get_entries(&self) -> Ref<HashMap<String, Entry>> {
let mut map: HashMap<String, Entry> = HashMap::new();
map.insert("a".to_string(), Entry { score: 100 });
RefCell::new(map).borrow()
}
}
Then
12 | RefCell::new(map).borrow()
| -----------------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| temporary value created here
A more "adaptive" approach is to save referenced data as Rc<RefCell<...>>
. But it's NOT recommended to overuse Rc<RefCell<>>
, because:
- It has (relatively small) performance cost.
Rc
Reference counting andRefCell
borrow counting costs peformance. (Performance is affected by many factors. It depends on exact case.) Rc
has memory leak risk (need to useWeak
to cut loop)RefCell
has runtime panic risk. A previous example already shows. See also- Its syntax ergonomic is not good. The code will have a lot of "noise" like
.borrow().borrow_mut()
etc.
Similarily, Arc
is the multi-threaded version of Rc
. Mutex
RwLock
are the multi-threaded version of RefCell
. It's also not recommended to overuse things like Arc<Mutex<T>>
:
Arc
's performance cost is larger thanRc
because it involves atomic operation. Elaborated below.Mutex
RwLock
's performance cost is larger thanRefCell
because locking also involves atomic operation, can reduce parallelism and can cause context switch.Arc
also has memory leak risk (need to useWeak
to cut loop)Mutex
andRwLock
have deadlock risk.- Its syntax ergonomic is not good either.
QCell
QCell<T>
has an internal ID. QCellOwner
is also an ID. You can only use QCell
via an QCellOwner
that has matched ID.
The borrowing to QCellOwner
"centralizes" the borrowing of many QCell
s associated with it, ensuring mutable borrow exclusiveness. Using it require passing borrow of QCellOwner
in argument everywhere it's used.
QCell will fail to borrow if the owner ID doesn't match. Different to RefCell
, if owner ID matches, it won't panic just because nested borrow.
Its runtime cost is low. When borrowing, it just checks whether cell's id matches owner's id. It has memory cost of owner ID per cell.
One advantage of QCell
is that the duplicated borrow will be compile-time error instead of runtime panic, which helps catch error earlier. If I change the previous RefCell
panic example into QCell
:
pub struct Parent { total_score: u32, children: Vec<Child> }
pub struct Child { score: u32 }
impl Parent {
fn get_children(&self) -> &Vec<Child> { &self.children }
fn add_score(&mut self, score: u32) { self.total_score += score; }
}
fn main() {
let owner: QCellOwner = QCellOwner::new();
let parent: QCell<Parent> = QCell::new(&owner, Parent{total_score: 0, children: vec![Child{score: 2}]});
for child in parent.ro(&owner).get_children() {
parent.rw(&mut owner).add_score(child.score);
}
}
Compile error:
17 | for child in parent.ro(&owner).get_children() {
| --------------------------------
| | |
| | immutable borrow occurs here
| immutable borrow later used here
18 | parent.rw(&mut owner).add_score(child.score);
| ^^^^^^^^^^ mutable borrow occurs here
GPUI's Model<T>
is similar to Rc<QCell<T>>
, where GPUI's AppContext
correspond to QCellOwner
.
It can also work in multithreading, by having RwLock<QCellOwner>
. This can allow one lock to protect many pieces of data in different places 8.
Ghost cell and LCell are similar to QCell, but use closure lifetime as owner id. They are zero-cost, and more restrictive (use closure lifetime as owner id).
Rust lock is not re-entrant
Re-entrant lock means one thread can lock one lock, then lock it again, then unlock twice, without deadlocking. Rust lock is not re-entrant. (Rust lock is also responsible for keeping mutable borrow exclusiveness. Allowing re-entrant can produce two &mut
for same object.)
For example, in Java, the two-layer locking doesn't deadlock:
public class Main {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
synchronized (lock) {
System.out.println("within two layers of locking");
}
}
System.out.println("finish");
}
}
But in Rust the equivalent will deadlock:
fn main() {
let mutex: Mutex<u64> = Mutex::new(0);
{
let mut g1: MutexGuard<u64> = mutex.lock().unwrap();
{
println!("going to do second-layer lock");
let mut g2 = mutex.lock().unwrap();
println!("within two layers of locking");
}
}
println!("finish");
}
It prints going to do second-layer lock
then deadlocks.
In Rust, it's important to be clear about which scope holds lock. You cannot just casually do locking like in Java/C# (this method touch that shared data so add synchronized
9). Golang lock is also not re-entrant.
Another important thing is that Rust only unlocks at the end of scope by default. mutex.lock().unwrap()
gives a MutexGuard<T>
. MutexGuard
implements Drop
, so it will drop at the end of scope. It's different to the local variables whose type doesn't implement Drop
, they are dropped after their last use (unless borrowed). This is called NLL (non-lexical lifetime).
Just clone the data
For example, if borrow checker has trouble with a string borrowing, you can just clone the string. It's usually fine as long as it's not performance bottleneck.
Note that there are two different kinds of data:
- The identity of object is important. Cloning it is treated as adding a new entity into the system.
- The identity of object is not important. Only object content matters. Cloning doesn't affect semantics. Cloning is fine.
Arc
is not always fast
Arc
uses atomic operations to change its reference count.
However, when many threads frequently change the same atomic counter, performance can degrade. The more threads touching it, the slower it is.
Modern CPUs use cache coherency protocol (e.g. MOESI). Atomic operations often require the CPU core to hold "exclusive ownership" to cache line (this may vary between different hardware). Many threads frequently doing so cause cache contention, similar to locking, but on hardware.
Solutions:
- Avoid sharing the same reference count. Copying data is sometimes better.
- arc_swap. It uses hazard pointer and other mechanics to improve performance.
- trc and hybrid_rc. They use per-thread non-atomic counter, and another shared atomic counter for how many threads use it. This can make atomic operations be less frequent, getting higher performance.
- aarc and crossbeam_epoch. Use epoch-based memory reclamation.
These deferred memory reclamation techniques (hazard pointer, epoch-based) are also used in lock-free data structures. If one thread can read an element while another thread removes and frees the same element in parallel, it will not be memory-safe (this issue doesn't exist in GC languages).
Using unsafe
By using unsafe you can freely manipulate pointers and are not restricted by borrow checker. But writing unsafe Rust is harder than just writing C, because you need to carefully avoid breaking the constraints that safe Rust code relies on. A bug in unsafe code can cause issue in safe code.
Writing unsafe Rust correctly is hard. Here are some traps in unsafe:
- Don't violate mutable borrow exclusiveness.
- A
&mut
cannot overlap with any other borrow that overlaps. - The overlap here also includes interior pointer. A
&mut
to an object cannot co-exist with any other borrow into any part of that object. - Violating that rule cause undefined behavior and can cause wrong optimization. Rust adds
noalias
attribute for mutable borrows into LLVM IR. LLVM will heavily optimize based onnoalias
(including merging and reorder reads/writes. Being able to merge and reorder reads/writes enables a lot of optimization.). See also - The above rule doesn't apply to raw pointer
*mut T
. - It's very easy to accidentally violate that rule when using borrows in unsafe. It's recommended to always use raw pointer and avoid using borrow (including slice borrow) in unsafe code. Related1, Related2
- A
- Pointer provenance.
- Two pointers created from two provenance is considered to never alias. If their address equals, it's undefined behavior.
- Converting an integer to pointer gets a pointer with no provenance, which is undefined behavior, unless the integer was converted from a pointer.
- Adding a pointer with an integer doesn't change provenance.
- Using uninitialized memory is undefined behavior.
a = b
will drop the original object in place ofa
. Ifa
is uninitialized, then it will drop an unitialized object, which is undefined behavior. Useaddr_of_mut!(...).write(...)
Related- Handle panic unwinding.
- ...
Modern compilers tries to optimize as much as possible. To optimize as much as possible, the compiler makes assumptions as much as possible. Breaking any of these assumption can lead to wrong optimization. That's why it's so complex. See also
Unfortunately Rust's syntax ergonomics on raw pointer is currently not good:
- If
p
is a raw pointer, you cannot writep->field
(like in C/C++), and can only write(*p).field
- Raw pointer cannot be method receiver (self).
- There is no "raw pointer to slice". You need to manually
.add()
pointer and dereference. Bound checking is also manual.
Contagious borrowing between branches
Current borrow checker does coarse-grained analysis on branch. One branch's borrowing is contagious to another branch.
Currently, this won't compile (see also):
fn get_default<'r, K: Hash + Eq + Copy, V: Default>(
map: &'r mut HashMap<K, V>,
key: K,
) -> &'r mut V {
match map.get_mut(&key) { // -------------+ 'r
Some(value) => value, // |
None => { // |
map.insert(key, V::default()); // |
// ^~~~~~ ERROR // |
map.get_mut(&key).unwrap() // |
} // |
} // |
}
Becaue the first branch Some(value) => ...
's output value indirectly mutably borrows map
, the second branch has to also indirectly mutably borrow map
, which conflicts with another mutable borrow in scope.
This will be fixed by Polonius borrow checker. Currently (2025 Aug) it's available in nightly Rust and can be enabled by an option.
Send
and Sync
Rust favors tree-shaped ownership. Each object is owned by exactly one place. If you send something to another thread, only one thread can access it, so it's thread-safe. No risk of data race.
Sending an immutable borrow to another thread is also fine as long as the shared data is actually immutable.
But there are exceptions. One exception is interior mutability, like Cell
and RefCell
. Because interior mutability, the data pointed by immutable borrow &T
may no longer actually be immutable. So Rust prevents sharing &Cell<T>
and &RefCell<T>
by making Cell<T>
and RefCell<T>
not Sync
. If X
is Sync
then &X
is Send
. If X
is not Sync
then &X
is not Send
. This prevents Cell
and RefCell
from being shared across threads.
Rc
's reference counter is not atomic, so Rc
is neither Send
or Sync
.
But &Mutex<T>
can be shared because lock protects them. Also immutable reference to atomic cells like &AtomicU32
can be shared because of atomicity. Mutex<T>
and AtomicU32
are Sync
so &Mutex<T>
and &AtomicU32
are Send
.
There are things that are Sync
but not Send
, like MutexGuard
. If something is already locked, sharing its reference to other threads temporarily is fine. But moving a MutexGuard
to another thread is not fine because locking is tied to thread.
When using async runtime
Tokio is a popular async runtime.
In Tokio, submitting a task require the future to be Send
and 'static
.
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
It requires the future need to be Send
and 'static
:
-
'static
means:- it's a standalone value that doesn't borrow other things, or
- it references a global value that will always live when program is running, or
- it only borrows global values
The future being
'static
often require the future be standalone and doesn't borrow other things. If the future need to share data with outside, passArc<T>
into (not&Arc<T>
).Note that the "static" in C/C++/Java/C# often mean global value. But in Rust,
'static
can also mean mean standalone (owned) value that's not global. -
Send
means that the future can be sent across threads. Tokio use work-stealing, which means that one thread's task can be stolen by other threads that currently have no work.
Another important trap is that, the normal sleep std::thread::sleep
and normal locking std::sync::Mutex
should not be used when using async runtime, because they block using OS functionality without telling async runtime, so they will block the async runtime's scheduling thread. In Tokio, use tokio::sync::Mutex
and tokio::time::sleep
.
Side effect of extracting and inlining variable
In C and GC languages:
- If a variable is used only once, you can inline that variable. This will only change execution order (except in short-circuit 10).
- Extracting a variable will only change execution order (except when variable is used twice or in short-circuit).
But in Rust it's different.
Lifetime of temporary value
- A temporary value drops immediately after evaluating, except when there is a borrow to it, its lifetime extends by the borrow. It's called temporary lifetime extension.
- There are implicit ways of creating borrow.
DeRef
can implicitly borrow,a.b()
can implicitly borrowa
match
,if let
orwhile let
can also borrow which extend the lifetime- Sometimes temporary lifetime extension doesn't work, such as
let guard = Mutex::new(0).lock().unwrap();
- There are implicit ways of creating borrow.
- A value that's put into a local variable:
- If its type implements
Drop
, then it will drop at the end of scope (one example isMutexGuard
). - If its type doesn't implement
Drop
, then it will drop after its last use. This is called NLL (non-lexical lifetime).
- If its type implements
Simplify:
- Putting a temporary value to local variable usually make it live longer.
- Inlining a local variable usually make it live shorter.
Reborrow
Normally mutable borrow &mut T
can only be moved and cannot be copied.
But reborrow is a feature that sometimes allow you to use a mutable borrow multiple times. Reborrow is very common in real-world Rust code. Reborrow is not explicitly documented. See also
Example:
fn mutate(i: &mut u32) -> &mut u32 {
*i += 1;
i
}
fn mutate_twice(i: &mut u32) -> &mut u32 {
mutate(i);
mutate(i)
}
That works. Rust will implicitly treat mutate(i)
as mutate(&mut *i)
so that i
is not moved into and become usable again.
But extracting the second i
into a local variable early make it not compile:
fn mutate_twice(i: &mut u32) -> &mut u32 {
let j: &mut u32 = i;
mutate(i);
mutate(j)
}
7 | fn mutate_twice(i: &mut u32) -> &mut u32 {
| - let's call the lifetime of this reference `'1`
8 | let j: &mut u32 = i;
| - first mutable borrow occurs here
9 | mutate(i);
| ^ second mutable borrow occurs here
10 | mutate(j)
| --------- returning this value requires that `*i` is borrowed for `'1`
Move cloned data into closure
tokio::spawn
require future to be standalone and doesn't borrow other things ('static
).
Passing passing an Arc
(that's used later) into moved closure makes closure borrow the Arc
. The data that contains borrow is not standalone (not 'static
).
#[tokio::main]
async fn main() {
let data: Arc<u64> = Arc::new(1);
let task1_handle = tokio::spawn(async move {
println!("From task: Data: {}", *data);
});
println!("From main thread: Data: {}", *data);
}
Compile error
6 | let data: Arc<u64> = Arc::new(1);
| ---- move occurs because `data` has type `Arc<u64>`, which does not implement the `Copy` trait
7 |
8 | let task1_handle = tokio::spawn(async move {
| ---------- value moved here
9 | println!("From task: Data: {}", *data);
| ---- variable moved due to use in coroutine
...
12 | println!("From main thread: Data: {}", *data);
| ^^^^ value borrowed here after move
Manually clone the Arc<T>
and put it into local variable works. It will make the cloned version to move into future:
#[tokio::main]
async fn main() {
let data: Arc<u64> = Arc::new(1);
let data2 = data.clone(); // this is necessary
let task1_handle = tokio::spawn(async move {
println!("From task: Data: {}", *data2);
});
println!("From main thread: Data: {}", *data);
}
Note that inlining data2
local variable make it not compile:
#[tokio::main]
async fn main() {
let data: Arc<u64> = Arc::new(1);
let task1_handle = tokio::spawn(async move {
println!("From task: Data: {}", *data.clone());
});
println!("From main thread: Data: {}", *data);
}
5 | let data: Arc<u64> = Arc::new(1);
| ---- move occurs because `data` has type `Arc<u64>`, which does not implement the `Copy` trait
6 | let task1_handle = tokio::spawn(async move {
| ---------- value moved here
7 | println!("From task: Data: {}", *data.clone());
| ---- variable moved due to use in coroutine
8 | });
9 | println!("From main thread: Data: {}", *data);
| ^^^^ value borrowed here after move
There is a proposal on improving syntax ergonomic of it.
Summarize the contagious things
- Borrowing that cross function boundary is contagious. Just borrowing a wheel of car can indirectly borrow the whole car.
- Mutate-by-recreate is contagious. Recreating child require also recreating parent that holds the new child, and parent's parent, and so on.
- Lifetime annotation is contagious. If some type has a lifetime parameter
'a
, then every type that holds it and every function signature that use it must also have the lifetime parameter'a
(some may be auto-inferred, but not all). Refactoring that adds/remove lifetime parameter can be a huge work. - In current borrow checker, one branch's borrowing is contagious to the whole branching scope.
async
is contagious.async
function can call normal function. Normal function cannot easily callasync
function (but it's possible to call by blocking).- Being not
Sync
/Send
is contagious. A struct that indirectly owns a non-Sync
data is notSync
. A struct that indirectly owns a non-Send
data is notSend
. - Error passing is contagious. If panic is not acceptable, then all functions that indirectly call a fallible function must return
Result
.
Footnotes
-
Reference counting (
Rc
,Arc
) allows sharing. Here I mean "native" Rust ownership relation form a tree. ↩ -
Note that here "reference" here means reference in general OOP context (where there is no distinction between ownership and non-owning reference, think about reference in Java/C#/JS/Python). This is different to the Rust reference. I will use "borrow" for Rust reference in this article. ↩
-
This simplified code example is just for illustrating contagious borrow issue. The total score doesn't need to be a mutable field. It's analogous a complex state that will exist in real applications. ↩
-
Specifically, Gödel encodes symbols, statements and proofs into integer, called Gödel number. There exists many ways of encoding symbols/statements/proofs as data, and which exact way is not important. For simplicity, I will treat them all as data, and ignore the conversion between data and symbol/statements/proofs. ↩
-
Here
x(x)
is symbol substitution. replacing the free variablex
withx
, while avoid making two different variables same name by renaming when necessary. It's also similar to Y combinator:Y = f -> (x -> f(x(x))) (x -> f(x(x)))
. In that casef = unprovable
,H = x -> f(x(x))
,Y(f) = H(H)
,Y(f)
is a fixed point off
:f(Y(f)) = Y(f)
.G = Y(f)
,f(G) = G
↩ -
Each slotmap ensures key uniqueness, but if you mix keys of different slotmaps, the different keys of different slotmap may duplicate. Using the wrong key may successfully get an element but logically wrong. ↩
-
The memory layout here means how we map between information and in-memory binary data. It's not just the placement of fields of a struct. ↩
-
Sometimes, having fine-grained lock is slower because of more lock/unlock operations. But sometimes having fine-grained lock is faster because it allows higher parallelism. Sometimes fine-grained lock can cause deadlock but coarse-grained lock won't deadlock. It depends on exact case. ↩
-
Although re-entrant lock is more tolerant to this kind of casual locking, not being clear about locking means risk of deadlock. Casual locking is not recommended even in Java. ↩
-
a() || b()
will not executeb()
ifa()
returns true.a() && b()
will not executeb()
ifa()
returns false. ↩