Interior mutability: Cell, OnceCell, LazyLock

Rust · Advanced · 14 min

Caches, counters, lazy globals and shared state all rely on interior mutability, and choosing the right cell avoids panics and locks.

Normally &T means "read-only". Interior mutability is the controlled exception: types that allow mutation through a shared reference by enforcing safety some other way. They all build on `UnsafeCell<T>`, the only legal way in Rust to mutate data reachable through &; every other approach is undefined behaviour.

Single-threaded cells (not Sync):

  • `Cell<T>`: no borrowing at all. You get a copy (for Copy types), set, replace or take the whole value. Zero overhead, can never panic. Ideal for counters and flags.
  • `RefCell<T>`: hands out Ref/RefMut guards and tracks borrows at runtime, panicking on conflicts. Use when you need references into the value.
  • `OnceCell<T>`: written at most once; get_or_init(|| ...) computes on first use. Good for lazily computed fields.
  • `LazyCell<T, F>`: an OnceCell bundled with its initializer.

Thread-safe equivalents:

  • `OnceLock<T>` (since 1.70) and `LazyLock<T>` (since 1.80) for lazily initialised globals: static CONFIG: LazyLock<Config> = LazyLock::new(load);. They replace the lazy_static and once_cell crates.
  • atomics (AtomicUsize, AtomicBool) are the thread-safe Cell for integers.
  • `Mutex<T>`/`RwLock<T>` are the thread-safe RefCell: they block instead of panicking.

Avoid static mut: in the 2024 edition, taking a reference to one is denied by default (the static_mut_refs lint), because it invites data races and aliasing bugs. Use an atomic, a Mutex, or a LazyLock instead.

Interior mutability is a tool for caches, memoization, reference-counted graphs and statistics behind &self APIs. If you can restructure so that one owner holds &mut, that is usually simpler.

Example

use std::{cell::{Cell, OnceCell}, collections::HashMap, sync::LazyLock};

static UNITS: LazyLock<HashMap<&str, f64>> = LazyLock::new(|| {
    println!("(building table once)");
    HashMap::from([("km", 1000.0), ("m", 1.0)])
});

struct Doc {
    text: String,
    reads: Cell<u32>,            // mutated through &self
    word_count: OnceCell<usize>, // computed lazily, once
}

impl Doc {
    fn words(&self) -> usize {
        self.reads.set(self.reads.get() + 1);
        *self.word_count.get_or_init(|| self.text.split_whitespace().count())
    }
}

fn main() {
    let d = Doc { text: "a b c".into(), reads: Cell::new(0), word_count: OnceCell::new() };
    println!("{} {} reads={}", d.words(), d.words(), d.reads.get()); // 3 3 reads=2
    println!("{} {}", UNITS["km"], UNITS["m"]); // (building table once) then 1000 1
}

Remember

  • All interior mutability is built on UnsafeCell
  • Cell for small Copy values, RefCell for borrowed access, OnceCell for compute-once
  • Use LazyLock/OnceLock, atomics or Mutex for globals; avoid static mut

Watch out for

  • Using RefCell where Cell would do and paying for (and risking) runtime borrow panics.

Check yourself

  1. You need a lazily initialised global configuration readable from many threads. What fits best?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/std/cell/index.html