Memory Without a Garbage Collector

Article · 14:00

Rust has no garbage collector and no manual free: memory is released when its owner goes out of scope, at a point the compiler can see, so the cost is predictable and there is no pause to tune. Drop order, zero-cost abstractions, leaks, and what unsafe enables.

Memory Without a Garbage Collector

Rust has no garbage collector and no manual free. Memory is released when its owner goes out of scope, at a point the compiler can see - which means the cost is predictable and there is no pause to tune.

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) {
        println!("  dropping {}", self.0);
    }
}

fn main() {
    println!("entering main");
    {
        let _a = Noisy("a");
        let _b = Noisy("b");
        println!("  both alive");
    }   // dropped here, in reverse order of declaration

    let moved = Noisy("moved");
    take(moved);                     // dropped inside take, not here
    println!("back in main");

    let boxed = Box::new(Noisy("on the heap"));
    drop(boxed);                     // released exactly here
    println!("main is returning");
}

fn take(value: Noisy) {
    println!("  take() received {}", value.0);
}

Every one of those lines is decided at compile time. There is no collector deciding when, and no finaliser running on a thread you do not control.

Stack, heap, and which one you get

fn main() {
    // On the stack: a fixed size, known at compile time.
    let point = (1_i64, 2_i64);
    let array = [0_u8; 16];

    // On the heap: a size decided at runtime, with a stack-sized handle.
    let vector: Vec<u8> = vec![0; 1_000_000];
    let text = String::from("owns its bytes");
    let boxed: Box<i64> = Box::new(42);

    // size_of tells you the HANDLE's size, not the data's.
    println!("tuple:  {} bytes", std::mem::size_of_val(&point));
    println!("array:  {} bytes", std::mem::size_of_val(&array));
    println!("Vec:    {} bytes of handle, {} of data",
             std::mem::size_of_val(&vector), vector.len());
    println!("String: {} bytes of handle, {} of data",
             std::mem::size_of_val(&text), text.len());
    println!("Box:    {} bytes", std::mem::size_of_val(&boxed));

    // A Vec is a pointer, a length and a capacity - three words.
    println!("len {} capacity {}", vector.len(), vector.capacity());
}

Zero-cost abstractions, meant literally

An iterator chain compiles to the same machine code as the loop you would have written. The abstraction has no runtime representation at all.

fn main() {
    let numbers: Vec<u64> = (1..=1_000_000).collect();

    let by_loop = {
        let mut total = 0u64;
        for n in &numbers {
            if n % 3 == 0 {
                total += n * 2;
            }
        }
        total
    };

    let by_iterator: u64 = numbers.iter().filter(|n| *n % 3 == 0).map(|n| n * 2).sum();

    println!("loop     {by_loop}");
    println!("iterator {by_iterator}");
    println!("identical: {}", by_loop == by_iterator);
    println!("and in release mode they compile to the same instructions");

    // Generics are monomorphised: one specialised copy per concrete type, so a
    // generic function is as fast as a hand-written one. The cost is compile
    // time and binary size, not speed.
    println!("{} {}", double(21), double(1.5));
}

fn double<T: std::ops::Add<Output = T> + Copy>(value: T) -> T {
    value + value
}

What still leaks

Rust prevents use-after-free and double-free. It does not prevent a leak: forgetting a value is safe, because nothing unsafe happens as a result.

use std::cell::RefCell;
use std::rc::Rc;

struct Node {
    next: RefCell<Option<Rc<Node>>>,
}

fn main() {
    // A reference cycle. Neither count reaches zero, so neither is dropped.
    let a = Rc::new(Node { next: RefCell::new(None) });
    let b = Rc::new(Node { next: RefCell::new(None) });

    *a.next.borrow_mut() = Some(Rc::clone(&b));
    *b.next.borrow_mut() = Some(Rc::clone(&a));

    println!("a count {}, b count {}", Rc::strong_count(&a), Rc::strong_count(&b));
    println!("dropping both handles now leaks the pair - Weak is the fix");

    // And the explicit version, which is safe and occasionally what you want:
    let leaked: &'static mut String = Box::leak(Box::new(String::from("never freed")));
    println!("{leaked}");

    // Breaking the cycle by hand, so this program does not actually leak.
    *a.next.borrow_mut() = None;
    *b.next.borrow_mut() = None;
    println!("cycle broken: a count {}", Rc::strong_count(&a));
}

unsafe: a smaller language inside the language

unsafe does not turn off the borrow checker. It enables five things the compiler cannot verify - dereferencing a raw pointer, calling an unsafe function, implementing an unsafe trait, accessing a mutable static, and reading a union field - and asks you to uphold the invariants yourself.

fn main() {
    let mut value = 42;

    // Creating raw pointers is safe; dereferencing them is not.
    let r1 = &value as *const i32;
    let r2 = &mut value as *mut i32;

    unsafe {
        println!("r1 points at {}", *r1);
        *r2 = 99;
    }
    println!("value is now {value}");

    // The real use: wrapping something unverifiable in a safe API. This is how
    // Vec, RefCell and every channel in the standard library are built.
    let mut data = vec![1, 2, 3, 4];
    let (left, right) = split_at_mut(&mut data, 2);
    left[0] = 10;
    right[0] = 30;
    println!("{data:?}");
}

// Safe to call, unsafe inside: the caller cannot misuse it, because the
// function guarantees the two halves do not overlap.
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    assert!(mid <= len);
    let ptr = values.as_mut_ptr();

    unsafe {
        (
            std::slice::from_raw_parts_mut(ptr, mid),
            std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

The discipline: keep unsafe blocks small, document the invariant each one relies on, and expose a safe API around them. Most applications never write one.

What to take away

  • Memory is freed when its owner goes out of scope - no collector, no pauses.
  • Drop runs in reverse declaration order, and on the way out of a panic too.
  • A Vec or String is a small handle on the stack pointing at heap data.
  • Iterators and generics are zero-cost: monomorphised into the code you would have written.
  • Leaks are safe and therefore possible - reference cycles are the usual cause.
  • unsafe enables five specific operations; wrap it in a safe API and keep it small.