Visualising memory in Rust
I’ve been learning Rust for fun and one of the things I’ve found helpful is to mentally visualise where values are in memory and how they point to each other. The mental model I use is a section of byte-addressable memory split into four regions: code, globals, heap, and (thread) stack(s):
To make these easier to illustrate, I split them out on their own rows (and flip the stack so it grows to the right):
Below are examples of these in case someone else finds them useful as a companion to the relevant sections of the Rust Book. The illustrations simplify some of the types somewhat; for example, they leave out the poison
field of Mutex<T>
and ignores non-pointer type wrappers such as Cell
when that’s not the focus of the example.
const
and static
const
values are ones that can’t change for the lifetime of the program. Thus, the Rust compiler may inline these in several places as an optimisation, as illustrated below. static
is different in that it refers to a particular address and they can be mutable, though mutating these must be done in an unsafe
block.
const ANSWER: i32 = 42;
static mut EMOTION: char = '😊';
fn main() {
// <--- Values illustrated here
unsafe {
EMOTION = '😯';
println!("The answer is {ANSWER}. {EMOTION}");
}
println!("The alternative answer is {:}", alternative_answer());
}
fn alternative_answer() -> i32 {
ANSWER + 10
}
&
and &mut
References are a type of pointer that are guaranteed to be valid and must follow Rust’s borrow rules — there can be either a single mutable borrow or any number of immutable borrows at any one time.
fn main() {
let a = 100;
let b = &a;
println!("{:}", add(&a, b));
}
fn add(x: &i32, y: &i32) -> i32 {
// <-- Values illustrated here
*x + *y
}
Here we’re looking at the memory when executing the add
function and thus we have two stack frames. main
’s stack frame stores the i32
value 100
(bound to the variable a
) and an immutable reference (&i32
) to that value (bound to the variable b
). The add
stack frame has two &i32
references to the 100
value in the previous stack frame and dereferences these using the dereference operator, *
, to access that value.
fn main() {
let mut a: i32 = 20;
let b: &mut i32 = &mut a;
double(b);
println!("{:}", a);
}
fn double(x: &mut i32) {
*x *= 2;
}
In this example, we create one mutable reference and pass this to the double
function. This results in there being two mutable references to a
in memory (you can verify this with lldb
) but this is safe because the second (x
) is guaranteed to be dropped (with the double
stack frame) before the first can be used again in the main
function. Rust’s borrow checker allows this, but it does not allow substituting double(b)
with double(&mut a)
even though that would have been functionally equivalent.
Box<T>
Now let’s consider what things look like when we put values on the heap. A Box<T>
is the basic way to do that.
fn main() {
let a: Box<i32> = Box::new(32);
let b: Box<[i32; 3]> = Box::new([1, 2, 3]);
let c = b[2]; // Copy the third item in the array back to the stack.
println!("a = {a} b = {b:?} c = {c}");
}
When we create a Box<T>
it allocates space on the heap and puts the value there, then stores the Box with a pointer to that location on the stack. Box<T>
holds a Unique<T>
pointer (source).
Rc<T>
The Rc<T>
type implements runtime reference counting which allows us to create some types of valid data structures that Rust’s conservative static type checking will disallow. Rust uses the Drop
trait to automatically decrement the reference count when a reference goes out of scope. It also allows weak references whose existence do not stop the value from being dropped. (Arc<T>
is the thread-safe version of Rc<T>
and the illustrations would look the same if we replaced Rc
with Arc
.)
use std::rc::{Rc, Weak};
fn main() {
let a = Rc::new(42);
let b = Rc::clone(&a);
let w1 = Rc::downgrade(&a);
let w2 = Rc::downgrade(&a);
print_value(&w1); // <--- First illustration shows state inside end of this function
consume(a);
consume(b);
print_value(&w2); // <--- Second illustration shows state inside end of this function
}
fn consume(_: Rc<i32>) {}
fn print_value(w: &Weak<i32>) {
if let Some(val) = w.upgrade() {
println!("Value: {val}");
} else {
println!("There is no value");
}
}
When we create an Rc<T>
it creates an RcBox<T>
on the heap to track the number of strong and weak references in addition to the value itself. Whenever we clone an Rc
it will increase the strong count and when that Rc
is dropped it will decrease it. Rc::downgrade
will create a Weak<T>
reference and increase the weak count (and decrease it when it goes out of scope). Thus, inside the first print_value
function, we have two stack frames and five pointers — three strong and two weak — to the RcBox
on the heap.
After we consume a
and b
all Rc
s referring to the RcBox
have been dropped so the heap value is deallocated and we are left with two invalid pointers in the Weak
values on the stack, but the upgrade()
function will catch this and return None
instead of an Rc
in the second print_value
invocation.
RefCell<T>
RefCell<T>
allows us to have several references to something that can be mutated by deferring Rust’s static borrow checks to runtime. This is also called interior mutability because we end up with immutable references to values we can actually mutate. (More on this in the standard library docs).
use std::cell::{Ref, RefCell, RefMut};
fn main() {
let a: RefCell<i32> = RefCell::new(32);
{
let mut x: RefMut<'_, i32> = a.borrow_mut();
*x += 10;
// <--- First illustration shows state here
}
let y: Ref<'_, i32> = a.borrow();
// <--- Second illustration shows state here
println!("{:}", y);
}
First, the RefCell
has two primary fields (source), a borrow count and the value itself. If we dig a little, the borrow count is an isize
with the convention that mutable borrows are recorded as a negative number and immutable borrows as positive.
If we successfully borrow the value inside the cell we get a RefMut
or Ref
for a mutable and immutable borrow respectively. (If we try to acquire more than one mutable borrow at the same time we get a runtime panic.) The RefMut
and Ref
each basically have two pointers: to the borrow count and the value, so it can adjust the borrow count when cloned or dropped and get/change the value itself.
Rc<RefCell<T>>
We can now combine Rc<T>
and RefCell<T>
to create recursive, mutable data types. The levels of indirection now starts to add up and becomes a bit tedious to follow, but I still find it instructive to go through this as an exercise.
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let x = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&x), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
*x.borrow_mut() += 5;
println!("a after = {:?}", a);
println!("b after = {:?}", b);
}
Mutex<T>
Mutexes are used to synchronise actions between threads and is a wrapper around an operating system primitive. First we look at what this looks like inside a single thread (though this has no practical use).
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut n = m.lock().unwrap();
// <--- A
*n = 6;
}
println!("m = {:?}", m);
}
The Mutex<i32>
stores an innner mutex primitive and the value it protects. When we lock()
the mutex it will block the thread until the mutex is free and then return a MutexGuard<i32>
which points back to the Mutex so that we can dereference it to access the protected value and so it can release the lock by updating the inner mutex primitive when it goes out of scope.
Arc<Mutex<T>>
But let’s consider a Mutex
shared between several threads. Each thread gets its own stack so we have to put the mutex value on the heap and reference this from each thread. We do this using the thread-safe version of Rc<T>
, Arc<T>
(atomic reference counting).
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let c: Arc<Mutex<i32>> = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..2 {
let x: Arc<Mutex<i32>> = Arc::clone(&c);
let handle = thread::spawn(move || {
let mut n = x.lock().unwrap();
*n += 1;
// <--- Illustration shows state of thread 0 here
});
handles.push(handle);
}
println!("{:?}", Arc::strong_count(&c));
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *c.lock().unwrap());
}
When we spawn a thread, we move a cloned Arc
pointing to the Mutex
value on the heap. In the thread we then acquire the lock and increment the value by one. Only one thread will be able to acquire the lock at a time and any other threads that attempt to acquire the lock will block until it can acquire the lock. In the illustration below we see the memory when thread 0 has the lock, which means it has a MutexGuard
on its stack. (Because the protected value is 2, this means thread 1 acquired the lock first and incremented the value by 1 before thread 0 also incremented the value by 1.)
Conclusion
I’ve found this exercise of explicitly visualising the stack frames, heap allocations, and references/pointers between them helpful when learning about Rust’s ownership and borrow models. However, whether this mental model will be practical and useful in realistic day-to-day work is not yet clear to me.