↓ Skip to main content
  1. Posts/

Efficient Memory Management in Rust

··2340 words·12 mins·
Table of Contents
Rust - This article is part of a series.
Part 2: This Article

Rust’s memory model is the whole pitch. Ownership, borrowing, and lifetimes give you memory safety and freedom from data races at compile time, with no garbage collector. The cost is a steeper learning curve than most languages and a mental model you have to internalize before the borrow checker stops feeling like an adversary.

Ownership recap
#

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped and its memory freed. This one rule, combined with the borrow checker’s enforcement of it, is what eliminates entire classes of bugs (double-free, use-after-free, iterator invalidation, data races on shared state) at compile time.

The two things Rust doesn’t check at compile time are logic bugs inside unsafe blocks and resource leaks (which are memory-safe but wasteful). Everything else the language considers memory unsafety, it rejects at compile time.

Lifetimes
#

A lifetime is the compiler’s tracking of how long a reference is valid. Most of the time you don’t write lifetime annotations; the compiler infers them via lifetime elision rules. You write annotations when the compiler can’t infer them, which usually means when a function returns a reference that depends on multiple input references.

fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() { s1 } else { s2 }
}

The 'a annotation says: the returned reference is valid for at least as long as both input references are valid. If one input reference dies sooner than the other, the returned reference is only valid until that shorter lifetime ends.

The three elision rules that let you skip annotations most of the time:

  1. Each input reference gets its own inferred lifetime.
  2. If there’s exactly one input lifetime, the output gets that same lifetime.
  3. If one of the input references is &self or &mut self, the output gets self’s lifetime.

When these rules don’t produce a unique answer, you have to annotate. When they do, the annotations you’d write are exactly what the compiler infers.

The 'static lifetime is a special case: it means the reference is valid for the entire program duration. String literals have 'static lifetime ("hello" is &'static str) because they’re baked into the binary.

Borrowing rules
#

Borrowing rules are enforced by the borrow checker at compile time. Break them and your program doesn’t compile:

  • Any number of immutable references (&T) at once, or
  • Exactly one mutable reference (&mut T) with no immutable references
fn main() {
    let mut s = String::from("hello");

    // Multiple immutable borrows: fine
    let r1 = &s;
    let r2 = &s;
    println!("{r1} {r2}");
    // r1 and r2 no longer used past this point

    // Mutable borrow is now allowed
    let r3 = &mut s;
    r3.push_str(", world!");
    println!("{r3}");
}

That rule is the data-race prevention invariant: nobody mutates data someone else is reading, and only one mutator exists at a time. Other languages check the same invariant at runtime through mutex acquisition. Rust checks it at compile time in the single-threaded case and through the Send and Sync marker traits in the multi-threaded case.

Non-lexical lifetimes
#

Since the 2018 edition (and for 2015-edition code since Rust 1.36), the borrow checker tracks lifetimes based on actual use rather than lexical scope. The r1 and r2 in the example above are no longer used after their println!, so the mutable borrow that follows is legal even though r1 and r2 are technically still in scope. Older Rust code sometimes has explicit scope blocks to work around the pre-NLL borrow checker; those are no longer needed.

Interior mutability
#

Sometimes you need to mutate data through what looks like an immutable reference. The classic case: shared mutable state where the sharing must be immutable at the type level (so you can hand out references) but the mutation has to happen anyway.

Rust’s answer is interior mutability: types that use unsafe internally to move borrow-checking from compile time to runtime. The three main options:

Cell for Copy types
#

use std::cell::Cell;

let c = Cell::new(42);
c.set(100);
println!("{}", c.get());

Cell<T> lets you swap or read the wrapped value through a shared reference. There’s no borrowing involved (the value is copied or moved in and out), so there’s no runtime overhead beyond a direct read or write. get() needs T: Copy; set(), replace(), and take() work on any T (take() needs T: Default), so Cell is usable with non-Copy types as long as you never need to read without moving.

RefCell for non-Copy types
#

use std::cell::RefCell;

fn main() {
    let shared = RefCell::new(String::from("hello"));

    modify(&shared);
    modify(&shared);

    println!("{}", shared.borrow());
}

fn modify(data: &RefCell<String>) {
    let mut inner = data.borrow_mut();
    inner.push_str(", world!");
}

RefCell<T> runtime-checks the borrow rules. borrow() returns an immutable ref, borrow_mut() returns a mutable ref, and if the borrow rules would be violated (two outstanding borrow_mut()s, or a borrow_mut() while a borrow() is live) the program panics.

Cell and RefCell are single-threaded only. They’re not Sync, so the type system prevents you from sharing them across threads.

Mutex and RwLock for cross-thread mutation
#

For interior mutability across threads, std::sync::Mutex<T> and std::sync::RwLock<T> are the standard-library options. Mutex<T> is Sync when T: Send; RwLock<T> needs T: Send + Sync, because multiple readers can hold references at once. Either way you typically wrap them in an Arc to share them.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("Count: {}", *counter.lock().unwrap());
}

parking_lot::Mutex and parking_lot::RwLock from the parking_lot crate are common drop-in replacements with better performance and no .unwrap() for poisoning, at the cost of a dependency.

Shared ownership: Rc and Arc
#

When you need multiple owners of the same value, wrap it in a reference-counted smart pointer.

Rc<T> (Reference Counted) works in single-threaded contexts. Each Rc::clone increments the reference count; when the count drops to zero, the value is dropped.

use std::rc::Rc;

fn main() {
    let data = Rc::new(String::from("hello"));
    let a = Rc::clone(&data);
    let b = Rc::clone(&data);

    println!("count: {}", Rc::strong_count(&data));  // 3
    drop(a);
    println!("count: {}", Rc::strong_count(&data));  // 2
}

Arc<T> (Atomic Reference Counted) is the thread-safe version. The reference count is updated atomically, which is slightly more expensive than Rc’s non-atomic counter but lets you share ownership across threads. Use Arc in multi-threaded contexts, Rc in single-threaded ones.

Reference cycles between Rcs create memory leaks (the reference count never reaches zero). Use Weak references to break cycles when you need parent-child structures where the child references back to the parent.

Unsafe and raw pointers
#

unsafe doesn’t disable Rust’s safety guarantees; it disables a specific set of checks and shifts the safety burden to you. Inside an unsafe block, you gain the ability to:

  • Dereference raw pointers
  • Call unsafe functions
  • Access mutable static variables
  • Implement unsafe traits
  • Access union fields

You don’t gain the ability to ignore ownership, lifetimes, or the borrow checker. Those still apply. The whole point of unsafe is to build safe abstractions on top of operations the compiler can’t verify.

fn main() {
    let x = 42;
    let y = &x as *const i32;

    unsafe {
        println!("Value at pointer: {}", *y);
    }
}

Raw pointers come in two flavors: *const T (immutable) and *mut T (mutable). Unlike references, they carry no lifetime information, they can be null, they can be dangling, and multiple mutable raw pointers can coexist. Rust doesn’t check any of this; you have to.

In practice you reach for unsafe for two reasons. The first is FFI: calling C functions requires unsafe because the compiler can’t verify that the C side follows Rust’s rules, which is why libc and other wrapper crates hide the calls behind safe interfaces. The second is low-level or performance-critical code, such as custom allocators, SIMD intrinsics, unaligned memory access, and hand-written data structures that can’t be expressed within safe Rust’s constraints.

The Rustonomicon (nomicon.rust-lang.org) is the reference for writing sound unsafe code. Read it before shipping anything non-trivial with unsafe in it.

Custom allocators
#

Rust’s default global allocator is, in practice, std::alloc::System, a thin wrapper over the platform malloc (jemalloc stopped being the default in Rust 1.32). The std docs call the default unspecified, so don’t write code that depends on it. You can replace it globally with the #[global_allocator] attribute and an implementation of the std::alloc::GlobalAlloc trait:

use std::alloc::{GlobalAlloc, Layout, System};

struct CountingAllocator;

unsafe impl GlobalAlloc for CountingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // Delegate to the system allocator, could count/log/instrument here
        System.alloc(layout)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        System.dealloc(ptr, layout)
    }
}

#[global_allocator]
static GLOBAL: CountingAllocator = CountingAllocator;

fn main() {
    let data = Box::new(42);
    println!("{}", data);
}

People swap the global allocator for performance first: mimalloc and jemalloc both have Rust crates (mimalloc, tikv-jemallocator) and often beat the system allocator on multi-threaded workloads. Embedded #![no_std] targets need an allocator matched to the board’s memory layout. And the instrumentation case above is more useful than it looks: wrapping the system allocator to count allocations, log the large ones, or trigger a heap dump is a cheap way to find out where a long-running tool’s memory actually goes.

Note: std::alloc::Allocator is a separate, still-unstable trait (behind #![feature(allocator_api)]) for per-collection allocator selection. Don’t confuse it with the stable GlobalAlloc. For global allocator replacement, GlobalAlloc is what you want.

Zeroizing sensitive data
#

Cryptographic keys, passwords, and other sensitive material shouldn’t linger in memory after use. The compiler is free to reorder or elide writes that it thinks don’t affect program behavior, so a naive data = [0; N] at the end of scope isn’t reliable.

The zeroize crate solves this properly. It uses volatile writes, which LLVM is not allowed to elide, to guarantee the zeroing actually happens (versions before 1.9 added a compiler fence on top).

Add it to Cargo.toml:

[dependencies]
zeroize = { version = "1", features = ["derive"] }

Then derive Zeroize and ZeroizeOnDrop:

use zeroize::{Zeroize, ZeroizeOnDrop};

#[derive(Zeroize, ZeroizeOnDrop)]
struct SecretKey {
    key: [u8; 32],
}

impl SecretKey {
    fn new(material: [u8; 32]) -> Self {
        Self { key: material }
    }
}

fn main() {
    let key = SecretKey::new([0x42; 32]);
    // use the key ...
    // when key goes out of scope, its memory is guaranteed zeroed
}

ZeroizeOnDrop calls zeroize() on drop, guaranteeing the wipe happens even on panic-unwind. This is the correct pattern for anything holding cryptographic material.

The companion crate secrecy builds on zeroize with SecretBox<T> and SecretString (named Secret<T> in versions before 0.10), which hide the value from Debug output, prevent accidental copies, and require an explicit expose_secret() call to read the wrapped value.

Searching memory safely
#

If you need to search byte patterns in your own process (JIT introspection, dynamic loader work, debugging), the safe way in Rust is to use slice operations rather than raw pointer arithmetic. The memchr crate provides highly optimized SIMD-accelerated searching:

// Cargo.toml: memchr = "2"
use memchr::memmem;

fn main() {
    let haystack = b"the quick brown fox jumps over the lazy dog";
    let needle = b"brown";

    if let Some(pos) = memmem::find(haystack, needle) {
        println!("Found at byte {pos}");
    }
}

For arbitrary-type searches without transmuting through unsafe, the standard-library windows() iterator works:

fn find_pattern<T: PartialEq>(haystack: &[T], needle: &[T]) -> Option<usize> {
    haystack.windows(needle.len()).position(|w| w == needle)
}

fn main() {
    let data = [0u8, 1, 2, 3, 4, 5, 6, 7, 8, 9];
    let pattern = [3u8, 4, 5];

    match find_pattern(&data, &pattern) {
        Some(i) => println!("Found at index {i}"),
        None => println!("Not found"),
    }
}

If n is larger than the haystack, windows(n) yields nothing and position() returns None, so there’s no manual bounds check to write (it does panic on n == 0, so guard an empty needle). For byte searches, memmem::find is much faster than the generic windows().position() because it uses SIMD instructions internally.

If you actually need to interpret raw memory as typed values, bytemuck and zerocopy are the safe-abstraction crates for that. Their derive macros prove at compile time that a type is safe to reinterpret (no padding, every bit pattern valid), and the cast functions check alignment and size at runtime, panicking or returning an error instead of handing you a misaligned reference. Between the two, most hand-written unsafe for this job goes away.

Where Rust fits in security work
#

The memory guarantees are why Rust keeps showing up in security-adjacent work. The kernels came first: Linux has carried Rust in mainline since 6.1 (December 2022), Microsoft announced Rust in the Windows kernel in 2023, and Android has treated it as a platform language since Android 12, with Keystore2 and the ultra-wideband stack among the early rewrites. All three adopted it for driver and system code specifically to kill memory-safety bugs.

Crypto followed for the same reason. RustCrypto is pure Rust; rustls is a Rust TLS stack whose crypto providers (ring and aws-lc-rs) still carry BoringSSL-derived C and assembly underneath. The memory safety comes from the language. Constant-time behavior does not, since Rust makes no such guarantee, so those libraries lean on the subtle crate and careful review for that part. Fuzzers (cargo-fuzz and libfuzzer-sys ), network scanners, and analysis tools increasingly ship in Rust for the same mix of speed and safety. And the WebAssembly runtimes, Wasmtime and Wasmer among them, are Rust-first because a sandbox written in a memory-unsafe language tends to become the bug it was supposed to contain.

The price is the borrow checker’s learning curve, and it’s real; expect a few weeks of fighting it. After that the compiler is doing the memory-safety review for you, and it never gets tired or skims the diff.

Further reading
#

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.
Rust - This article is part of a series.
Part 2: This Article