Interior Mutability: Cell, OnceLock & Atomics
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:
| Type | The 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 friends | The 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.
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());
}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.
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"));
}(building station table)
Some("Jaipur")
Some("Lucknow")
NoneThe 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.
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);
}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.
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));
}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::Relaxedguarantees only that this one operation is atomic. It is the right choice for independent counters and statistics like the one above.Ordering::Releaseon a store paired withOrdering::Acquireon a load is used when a flag announces that other data is ready ("the results vector is filled, you may read it").Ordering::SeqCstis the strongest and easiest to reason about. If you are not sure which ordering you need, useSeqCst; 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:
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);
}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 need | One thread | Several threads |
|---|---|---|
Change a small Copy value through & | Cell<T> | AtomicU32, AtomicBool, ... |
| Change any value, exclusive access | RefCell<T> | Mutex<T> |
| Many readers or one writer | RefCell<T> | RwLock<T> |
| Fill once, read forever | OnceCell<T> | OnceLock<T> |
| Global value computed on first use | LazyCell<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
RefCellorCellacross threads. They are notSync, so the code does not compile. UseMutex,RwLockor an atomic. - Building separate
loadandstoresteps on an atomic. Another thread can slip in between. Usefetch_add,compare_exchangeor aMutex. - Using
Relaxedfor a "data is ready" flag. The reader may see the flag before the data. UseReleaseandAcquire, orSeqCst. - Holding a
RefCellborrow or aMutexGuardfor a long time. Other code panics or waits. Keep the borrow or lock to a few lines. - Adding
lazy_staticoronce_cellto new projects.LazyLockandOnceLockare in the standard library.
Key points
- Interior mutability means changing data through
&T; every such type wrapsUnsafeCelland 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-threadedOnceCell) is filled once withget_or_init;LazyLockandLazyCellcompute a value on first use.- Atomics such as
AtomicUsizeandAtomicBoolgive lock-free counters and flags;Relaxedis fine for independent counters,Acquire/ReleaseorSeqCstfor flags that publish data. - A
Mutexbecomes 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
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());
}[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?
With no references to the inside, there is nothing that a change could invalidate.
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?
LazyLock runs the initialiser once on first use and is safe to share between threads.
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?
Each fetch_add is one indivisible step, so no increment is lost; Relaxed is enough for a simple counter.
4. What does it mean when a Mutex is poisoned?
Poisoning warns later users; lock() returns an error that can be unwrapped or recovered with into_inner().
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
