EthicsComputerCourses
Join training +91 90532 10052

Interior Mutability: Cell, OnceLock & Atomics

Rust · Lesson 29 of 43 Advanced 10 min read Updated

Rust's basic rule is simple: through a shared reference &T you can only read. Yet real programs need a few exceptions. A web page counts its views while many handlers hold &page, a configuration table is built the first time someone asks for it, and a server counts requests from many threads at once. Interior mutability is the name for types that allow changing data through a shared reference, safely.

You already met two such types: RefCell in Smart Pointers: Box, Rc & RefCell and Mutex in Fearless Concurrency. This lesson completes the family: Cell, the "set once" types OnceCell and OnceLock, the lazy statics LazyLock and LazyCell, atomic integers, and what to do when a Mutex is poisoned.

How interior mutability stays safe

Every one of these types is built on UnsafeCell<T>, the only type through which Rust allows mutation behind &. On its own, UnsafeCell is unsafe to use. Each safe wrapper adds one rule that makes data races and dangling references impossible:

TypeThe rule that keeps it safe
Cell<T>Never hands out a reference to the inside; values are copied or swapped in and out
RefCell<T>Counts borrows at run time and panics on a conflict
OnceCell<T>, OnceLock<T>The value can be written only once, and only before anyone reads it
Mutex<T>, RwLock<T>A lock lets one writer (or many readers) in at a time
AtomicUsize, AtomicBool and friendsThe processor performs each read-modify-write as one indivisible step

The types in the first three rows are for one thread (except OnceLock). The others work across threads.

Cell: copy values in and out

Cell<T> is the lightest option. get() returns a copy of the value (so T must be Copy), set() replaces it, and replace() swaps in a new value and returns the old one. Because a Cell never gives you a reference to its contents, nothing can be "still borrowed" when you change it, so there is no run-time check at all.

Rust
use std::cell::Cell;

struct Page {
    title: String,
    views: Cell<u32>,
}

impl Page {
    fn visit(&self) -> u32 {
        self.views.set(self.views.get() + 1);
        self.views.get()
    }
}

fn main() {
    let page = Page { title: String::from("Board Results 2026"), views: Cell::new(0) };

    let from_home = &page;
    let from_search = &page;
    from_home.visit();
    from_search.visit();
    println!("{} viewed {} times", page.title, page.visit());

    let old = page.views.replace(100);
    println!("old: {old}, new: {}", page.views.get());
}
Output
Board Results 2026 viewed 3 times
old: 3, new: 100

visit takes &self, not &mut self, yet it changes the counter. That is the point: two shared references exist at the same time and both can count. Cell is not Sync, so the compiler will not let you share it between threads. For non-Copy data such as a Vec or a HashMap cache, use RefCell instead; a common pattern is a RefCell<HashMap<...>> field that remembers results so a &self method can avoid repeating slow work.

OnceLock: set once, read many times

Some values are expensive to build and never change afterwards: a lookup table, a parsed configuration, a database connection pool. OnceLock<T> (in std::sync, stable since Rust 1.70) starts empty and is filled exactly once. Its get_or_init method runs your closure on the first call and simply returns the stored value on every later call, even when many threads call it at the same moment.

Rust
use std::collections::HashMap;
use std::sync::OnceLock;

fn station_name(code: &str) -> Option<&'static str> {
    static STATIONS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();

    let table = STATIONS.get_or_init(|| {
        println!("(building station table)");
        HashMap::from([("NDLS", "New Delhi"), ("JP", "Jaipur"), ("LKO", "Lucknow")])
    });
    table.get(code).copied()
}

fn main() {
    println!("{:?}", station_name("JP"));
    println!("{:?}", station_name("LKO"));
    println!("{:?}", station_name("XYZ"));
}
Output
(building station table)
Some("Jaipur")
Some("Lucknow")
None

The table is built once, on the first call, and lives in a static for the rest of the program, which is why get_or_init can return a &'static reference. OnceLock also has set(value), which returns Err if a value is already there, and get(), which returns None before initialisation. Its single-threaded twin is std::cell::OnceCell, with the same methods and no synchronisation cost.

LazyLock: lazy global values

OnceLock needs a function around it. When you simply want a global value that is computed on first use, LazyLock<T> (Rust 1.80) is shorter: you give it the initialising closure when you declare the static, and it behaves like a plain value afterwards.

Rust
use std::env;
use std::sync::LazyLock;

struct Config {
    city: String,
    max_seats: u32,
}

static CONFIG: LazyLock<Config> = LazyLock::new(|| {
    println!("(reading configuration)");
    Config {
        city: env::var("APP_CITY").unwrap_or_else(|_| String::from("Pune")),
        max_seats: 72,
    }
});

fn main() {
    println!("Program started");
    println!("City: {}", CONFIG.city);
    println!("Seats: {}", CONFIG.max_seats);
}
Output
Program started
(reading configuration)
City: Pune
Seats: 72

This is the output when the APP_CITY environment variable is not set. The closure runs only when CONFIG is first used, not when the program starts, and only once. LazyCell is the single-threaded version.

Atomics: lock-free counters and flags

For a single number or flag shared by many threads, a Mutex<u64> works but is heavier than necessary. The types in std::sync::atomic, such as AtomicUsize, AtomicU32, AtomicU64 and AtomicBool, use special processor instructions so that operations like "add one" happen as one indivisible step. Two threads can never both read 41 and both write 42.

Rust
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;

static PAGE_VIEWS: AtomicUsize = AtomicUsize::new(0);
static MAINTENANCE: AtomicBool = AtomicBool::new(false);

fn handle_request() -> bool {
    if MAINTENANCE.load(Ordering::Relaxed) {
        return false;
    }
    PAGE_VIEWS.fetch_add(1, Ordering::Relaxed);
    true
}

fn main() {
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..250 {
                    handle_request();
                }
            });
        }
    });
    println!("Views: {}", PAGE_VIEWS.load(Ordering::Relaxed));

    MAINTENANCE.store(true, Ordering::Relaxed);
    println!("Served during maintenance? {}", handle_request());
    println!("Views: {}", PAGE_VIEWS.load(Ordering::Relaxed));
}
Output
Views: 1000
Served during maintenance? false
Views: 1000

Four threads make 250 requests each, and not a single increment is lost. The total is printed only after thread::scope has waited for every thread, so the output is the same on every run. Atomics can be declared directly as static values because new is a const fn; no Arc or LazyLock is needed.

The main methods are load, store, fetch_add and fetch_sub (which return the previous value), swap, and compare_exchange, which writes a new value only if the current value is the one you expected.

Every atomic operation takes an Ordering, which controls how it is ordered with respect to other memory operations:

  • Ordering::Relaxed guarantees only that this one operation is atomic. It is the right choice for independent counters and statistics like the one above.
  • Ordering::Release on a store paired with Ordering::Acquire on a load is used when a flag announces that other data is ready ("the results vector is filled, you may read it").
  • Ordering::SeqCst is the strongest and easiest to reason about. If you are not sure which ordering you need, use SeqCst; it can be a little slower but is never weaker than the others.

Mutex poisoning

lock() on a Mutex returns a Result. If a thread panics while it holds the lock, the mutex becomes poisoned: Rust marks it because the data inside may be half-updated. Every later lock() returns an Err, and lock().unwrap() then panics too. Usually that is what you want, but you can inspect the data and recover:

Rust
use std::sync::Mutex;
use std::thread;

fn main() {
    let scores = Mutex::new(vec![10, 20]);

    let result = thread::scope(|s| {
        s.spawn(|| {
            let mut guard = scores.lock().unwrap();
            guard.push(30);
            if guard.len() > 2 {
                panic!("scorer crashed while updating");
            }
        })
        .join()
    });
    println!("thread panicked: {}", result.is_err());
    println!("poisoned: {}", scores.is_poisoned());

    let data = scores.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    println!("data: {:?}", *data);
}
Output
thread panicked: true
poisoned: true
data: [10, 20, 30]

(The panic message of the crashed thread also appears on standard error; it is left out here.) The push happened before the panic, so the data really was changed by a thread that never finished its work. into_inner() on the error gives you the guard anyway. Recover only when you can check that the data still makes sense; otherwise let the panic spread with unwrap().

Choosing the right type

What you needOne threadSeveral threads
Change a small Copy value through &Cell<T>AtomicU32, AtomicBool, ...
Change any value, exclusive accessRefCell<T>Mutex<T>
Many readers or one writerRefCell<T>RwLock<T>
Fill once, read foreverOnceCell<T>OnceLock<T>
Global value computed on first useLazyCell<T>LazyLock<T>

Prefer ordinary &mut borrowing whenever the design allows it; it is checked at compile time and costs nothing. Reach for interior mutability when the shape of the program really needs shared changes.

Common mistakes

  • Using RefCell or Cell across threads. They are not Sync, so the code does not compile. Use Mutex, RwLock or an atomic.
  • Building separate load and store steps on an atomic. Another thread can slip in between. Use fetch_add, compare_exchange or a Mutex.
  • Using Relaxed for a "data is ready" flag. The reader may see the flag before the data. Use Release and Acquire, or SeqCst.
  • Holding a RefCell borrow or a MutexGuard for a long time. Other code panics or waits. Keep the borrow or lock to a few lines.
  • Adding lazy_static or once_cell to new projects. LazyLock and OnceLock are in the standard library.

Key points

  • Interior mutability means changing data through &T; every such type wraps UnsafeCell and adds a safety rule.
  • Cell<T> copies or swaps values in and out with no run-time check; RefCell<T> checks borrows at run time.
  • OnceLock (and single-threaded OnceCell) is filled once with get_or_init; LazyLock and LazyCell compute a value on first use.
  • Atomics such as AtomicUsize and AtomicBool give lock-free counters and flags; Relaxed is fine for independent counters, Acquire/Release or SeqCst for flags that publish data.
  • A Mutex becomes poisoned when a thread panics while holding it; into_inner() on the error lets you recover if the data is still valid.

Exercise

A railway booking system needs unique ticket numbers. Write fn next_ticket_id() -> u32 that uses a static AtomicU32 starting at 1001. In main, start three scoped threads that each take two ticket numbers and return them as an array. Collect all six numbers, sort them and print them, then print the next free number. Explain in one sentence why a Cell<u32> would not work here.

Show solution
Rust
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;

static NEXT_ID: AtomicU32 = AtomicU32::new(1001);

fn next_ticket_id() -> u32 {
    NEXT_ID.fetch_add(1, Ordering::Relaxed)
}

fn main() {
    let mut ids = thread::scope(|s| {
        let mut handles = Vec::new();
        for _ in 0..3 {
            handles.push(s.spawn(|| [next_ticket_id(), next_ticket_id()]));
        }
        let mut all = Vec::new();
        for handle in handles {
            all.extend(handle.join().unwrap());
        }
        all
    });
    ids.sort();
    println!("{ids:?}");
    println!("Next free id: {}", next_ticket_id());
}
Output
[1001, 1002, 1003, 1004, 1005, 1006]
Next free id: 1007

fetch_add returns the old value and increases the counter in one step, so no two threads ever get the same number; which thread gets which number varies, which is why we sort before printing. A Cell<u32> would not compile here because Cell is not Sync, so it cannot be shared between threads.

Quick Quiz

1. Why does Cell<T> need no run-time borrow check?

2. You need a global lookup table that is built the first time it is used and shared by all threads. Which type fits best?

3. Four threads each call COUNTER.fetch_add(1, Ordering::Relaxed) 100 times. What is the counter afterwards (starting from 0), once all threads are joined?

4. What does it mean when a Mutex is poisoned?

Learn Rust with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call