#162 May 2026

162. Pin<P> — The Pointer Type That Says 'This Won't Move'

Self-referential structs are the obvious “this should work but doesn’t” pattern in Rust: a struct that holds a buffer plus a reference into that buffer falls apart the moment it moves and the reference dangles. Pin<P> is the type that says “the pointee is fixed in memory” — and is the reason every async fn future you’ve ever .awaited can keep a pointer to its own local variables.

Why Pin exists at all

Take a String and a slice into it:

1
2
3
4
struct Mess<'a> {
    buf: String,
    view: &'a str,   // borrows from buf
}

You can’t actually build this — Rust won’t let you write a struct that borrows from one of its own fields, because the move that follows construction would invalidate the borrow. Any helper that produces a Mess and returns it by value moves buf into the caller’s stack frame; view would still point at the old location. Disaster.

async fn bodies generate exactly this kind of struct under the hood: an enum of “states,” each carrying the locals live across an .await. If one of those locals is a reference to another local, the future is self-referential. Move it after polling and you’ve hit UB.

What Pin actually does

Pin<P> wraps a pointerBox<T>, &mut T, Rc<T>, etc. — and downgrades its API. Specifically: you can no longer reach the inner &mut T unless T: Unpin.

Without &mut T, you can’t std::mem::swap or mem::replace to move the value out. That’s the whole guarantee: the value behind a Pin<P> will never move again, except by running its destructor.

1
2
3
4
5
6
use std::pin::Pin;

let mut boxed: Pin<Box<i32>> = Box::pin(42);
// i32: Unpin, so we can still write through the pin.
*boxed = 99;
assert_eq!(*boxed, 99);

For i32 the pinning is theatre — primitives implement Unpin, the marker that says “moving me is fine.” Pin only bites when the inner type is !Unpin.

Where it bites: polling a future

Future::poll takes self: Pin<&mut Self>. The compiler-generated futures from async {} are !Unpin, so you can’t poll them through a plain &mut. You have to pin first.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};

let fut = async { 7 * 6 };
let mut boxed: Pin<Box<_>> = Box::pin(fut);

let mut cx = Context::from_waker(Waker::noop());

match boxed.as_mut().poll(&mut cx) {
    Poll::Ready(v) => assert_eq!(v, 42),
    Poll::Pending => unreachable!(),
}

Box::pin is the easy answer when you need an owned, heap-allocated, pinned future. The allocation is what makes the address stable — Box already promised that.

Stack pinning with pin!

Heap allocation just to poll a future feels heavy, and it is. The pin! macro pins a value on the stack instead: it shadows the binding so you can never get a non-pinned reference to it again.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};

let mut fut = pin!(async { String::from("ready") });

let mut cx = Context::from_waker(Waker::noop());

if let Poll::Ready(s) = fut.as_mut().poll(&mut cx) {
    assert_eq!(s, "ready");
}

The trick is that pin! re-binds fut to a Pin<&mut _> that borrows from a hidden, scope-local slot. The original value lives until the end of the block; nothing can sneak in and move it. Stable since Rust 1.68.

Unpin: the escape hatch for ordinary types

Unpin is an auto trait: almost everything implements it automatically. String, Vec<T>, your own struct of plain fields — all Unpin. For those, Pin<&mut T> and &mut T are interchangeable via Pin::new and Pin::into_inner:

1
2
3
4
5
6
7
8
9
use std::pin::Pin;

let mut s = String::from("hi");
let pinned: Pin<&mut String> = Pin::new(&mut s);
// String: Unpin, so we get back a normal &mut String.
// `into_inner` is an associated function, not a method — avoids
// shadowing whatever `into_inner` the underlying type might have.
Pin::into_inner(pinned).push_str(", world");
assert_eq!(s, "hi, world");

This is why most of the time you can pretend Pin doesn’t exist. It only shows teeth when something is deliberately !Unpin — async-generated futures, intrusive linked-list nodes, and any self-referential struct you opt into with PhantomPinned.

When you’ll see it yourself

In day-to-day code you almost never write Pin<P> directly — the pin! macro and Box::pin cover polling, and tokio::pin! or tokio::spawn cover the async runtime case. You’ll meet Pin<&mut Self> in earnest when you:

  • Hand-roll a Future. poll takes Pin<&mut Self>, so any state machine you implement by hand has to thread it through.
  • Write a self-referential struct. Stick a PhantomPinned field in, and from then on the only way to use the type is through a Pin.
  • Build a custom executor. Pinning the futures the executor stores is exactly the invariant the Future trait is asking you for.

The mental model that sticks: Pin<P> doesn’t pin the pointer — it pins what the pointer points at. The pointer can move, be copied, be passed around; the value under it has promised to stay where it was first pinned, all the way until Drop. That’s the contract the async machinery is built on, and the reason it’s safe to keep an .await point inside a function call hundreds of frames deep.

163. Cow::to_mut — Lazy In-Place Mutation Through Cow

Cow<str> is the type everyone reaches for when a function might need to modify its input. Cow::Borrowed and Cow::Owned are the constructors that get the spotlight; to_mut is the third piece, and it’s the one that actually pays off the laziness.

What to_mut does

to_mut takes &mut Cow<str> and hands back &mut String:

  • If the Cow is already Owned, you get a direct &mut to the inner String.
  • If it’s Borrowed, to_mut clones the slice into a fresh String, swaps the Cow over to Owned, and then hands you the mutable reference.

That asymmetry is the whole point. Many callers borrow and never touch to_mut — they never allocate. The ones that do call it pay the allocation cost exactly once, on first write.

A walking-the-string example

Expand \t into two spaces, but only allocate if the input actually contains a tab:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use std::borrow::Cow;

fn expand_tabs(s: &str) -> Cow<'_, str> {
    let mut out: Cow<'_, str> = Cow::Borrowed(s);
    if let Some(i) = s.find('\t') {
        // First write — `to_mut` clones the slice into a String, then we
        // rebuild from byte `i` onwards.
        let buf = out.to_mut();
        buf.truncate(i);
        for c in s[i..].chars() {
            if c == '\t' {
                buf.push_str("  ");
            } else {
                buf.push(c);
            }
        }
    }
    out
}

The happy path — input has no tab — never enters the if, never allocates, and returns the original slice wrapped in Cow::Borrowed. The unhappy path allocates exactly once.

Composing transformations

to_mut really earns its keep when you chain several optional mutations. The first one that fires flips the Cow to Owned; every following mutation sees an already-owned buffer and reuses it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use std::borrow::Cow;

fn apply_rules<'a>(s: &'a str, rules: &[(char, &str)]) -> Cow<'a, str> {
    let mut out: Cow<'a, str> = Cow::Borrowed(s);
    for &(from, to) in rules {
        if out.contains(from) {
            let replaced = out.replace(from, to);
            *out.to_mut() = replaced;
        }
    }
    out
}

Three things worth pointing at. First, out.contains(from) works because Cow<str> derefs to str. Second, the assignment *out.to_mut() = replaced replaces the inner String, not the Cow itself. Third, once the first rule fires, all subsequent to_mut calls are a no-op &mut String — no extra clones.

Pitfall: to_mut always commits

There’s no “preview, then maybe commit” mode. Calling to_mut on a borrowed Cow clones immediately, even if you never end up writing through the returned reference. So this is a trap:

1
2
3
4
if !out.is_empty() {
    let _ = out.to_mut();  // allocates even though we may not change anything
    // ... maybe mutate, maybe not
}

Guard the call with the actual condition that means “I’m about to write,” not the condition that means “I might.” The mental shortcut: to_mut is the moment you trade your &str for a String. Reach for it lazily, but commit completely.

#161 May 2026

161. Weak<T> — The Non-Owning Pointer That Breaks Rc Cycles

Rc<T> is a counter, not a tracing GC: two Rcs pointing at each other will sit in memory forever, each propping the other’s strong-count above zero. Weak<T> is the cure — a pointer that observes an Rc’s allocation without keeping it alive.

The leak Rc lets you write

A parent node owning its children with Rc, and each child holding an Rc back at the parent, looks innocent — and leaks every node:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::cell::RefCell;
use std::rc::Rc;

struct Node {
    name: String,
    parent: RefCell<Option<Rc<Node>>>,    // <-- the trap
    children: RefCell<Vec<Rc<Node>>>,
}

let root  = Rc::new(Node {
    name: "root".into(),
    parent: RefCell::new(None),
    children: RefCell::new(vec![]),
});
let child = Rc::new(Node {
    name: "child".into(),
    parent: RefCell::new(Some(Rc::clone(&root))),  // child owns root
    children: RefCell::new(vec![]),
});
root.children.borrow_mut().push(Rc::clone(&child));  // root owns child

// drop(root); drop(child);  -> strong_count of each is still 1. Memory never freed.

Once root and child go out of scope, their last external Rcs drop, but each still holds an Rc to the other. The strong-counts never hit zero. Drop never runs. The allocation stays put until the process exits.

Weak<T>: a pointer that doesn’t keep things alive

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    name: String,
    parent: RefCell<Weak<Node>>,          // <-- the fix
    children: RefCell<Vec<Rc<Node>>>,
}

let root = Rc::new(Node {
    name: "root".into(),
    parent: RefCell::new(Weak::new()),
    children: RefCell::new(vec![]),
});

let child = Rc::new(Node {
    name: "child".into(),
    parent: RefCell::new(Rc::downgrade(&root)),  // weak — doesn't bump strong count
    children: RefCell::new(vec![]),
});
root.children.borrow_mut().push(Rc::clone(&child));

assert_eq!(Rc::strong_count(&root), 1);   // only `root` itself
assert_eq!(Rc::weak_count(&root), 1);     // `child.parent`
assert_eq!(Rc::strong_count(&child), 2);  // `child` + `root.children[0]`

Strong pointers own; weak pointers observe. Rc::downgrade(&root) gives you a Weak<Node> that points at the same allocation but only bumps the weak counter. The strong counter — the one that decides when to drop — is unaffected. When the last Rc to a node disappears, the node is destroyed, even if a hundred Weaks are still aimed at it.

Reading through a Weak: upgrade

A Weak doesn’t deref. To get at the value, ask for a temporary Rc back — and be ready for None, because the allocation may already be gone:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::rc::{Rc, Weak};

let strong = Rc::new(String::from("alive"));
let weak: Weak<String> = Rc::downgrade(&strong);

if let Some(rc) = weak.upgrade() {
    assert_eq!(*rc, "alive");
}

drop(strong);            // strong count -> 0, value dropped
assert!(weak.upgrade().is_none());

upgrade is the only way to read through a Weak<T>. The Option return type is the whole point: it forces every caller to handle the case where the upgraded pointer no longer refers to anything. That’s exactly the discipline a back-pointer in a tree needs — “give me my parent, if it’s still around.”

A standalone Weak: Weak::new

You usually want a Weak field initialised before its target exists. Weak::new makes one that points at nothing and upgrades to None:

1
2
3
4
use std::rc::Weak;

let dangling: Weak<i32> = Weak::new();
assert!(dangling.upgrade().is_none());

This is what filled the parent field of root above before we had any pointer to give it. No allocation happens until something is actually downgraded into it.

Counts: strong_count and weak_count

Both counters are inspectable. weak_count is what Rc::downgrade increments; strong_count is the one that gates the drop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::rc::{Rc, Weak};

let a = Rc::new(42);
let w1: Weak<i32> = Rc::downgrade(&a);
let w2: Weak<i32> = Rc::downgrade(&a);

assert_eq!(Rc::strong_count(&a), 1);
assert_eq!(Rc::weak_count(&a), 2);

drop(w1);
assert_eq!(Rc::weak_count(&a), 1);

// Dropping the only strong owner frees the *value* immediately.
// The remaining weak pointer keeps the allocation header alive but
// upgrades will return None.
drop(a);
assert!(w2.upgrade().is_none());

One nuance worth knowing: a live Weak keeps a small allocation header around (so it can check whether the value is still there), but not the value itself. Drop runs on the inner T as soon as the strong-count hits zero, even if a million Weaks outlive it.

When to reach for Weak

Whenever the ownership graph has a back-edge or a cycle:

  • Parent pointers in a tree. Children own children with Rc; children point back to parents with Weak.
  • Observer / listener lists. A subject holds Weak<Listener> so listeners can be dropped externally without first deregistering.
  • Caches. A cache holding Weak<T> lets entries vanish the moment their last real user lets go.

The rule is mechanical: pick one direction in the cycle to be the owner (Rc), and make every edge that closes the loop a Weak. If that direction is hard to choose, the lifetime question is probably a real design question hiding inside the data.

Arc<T> has the same thing

Arc<T> gives you the same pair on the thread-safe side: Arc::downgrade returns a std::sync::Weak<T> (different type, same shape) that you upgrade to an Option<Arc<T>>. Same rules, same idiom — atomic counters under the hood.

Reach for Weak the moment any node in your structure needs to point at something that also points back. The borrow checker can’t catch this leak; making the back-edge weak is the design that does.

#160 May 2026

160. Arc<T> — Atomic Reference Counting for Threads, Plus Mutex and RwLock

Rc<T> gave us shared ownership inside one thread. The moment you thread::spawn it, the compiler refuses. Arc<T> is the same shape with an atomic counter: same clone-the-pointer ergonomics, but Send + Sync and safe to share between threads — and the building block under almost every concurrent pattern in Rust.

The pain: Rc stops at the thread boundary

Rc’s counter is a plain usize. Two threads incrementing it at the same time could read, write, and lose updates — and a lost increment would free a still-referenced allocation. So Rc<T> is deliberately !Send, and the compiler stops you before you can try:

1
2
3
4
5
6
use std::rc::Rc;
use std::thread;

let cfg = Rc::new(String::from("app"));
thread::spawn(move || println!("{cfg}"));
// error[E0277]: `Rc<String>` cannot be sent between threads safely

Arc<T>: same API, atomic counter

Arc is Rc’s sibling with an atomic strong-count. Same new, same clone, same Deref<Target = T>, same “read-only through the pointer.” The cost is a fetch_add on every clone and a fetch_sub on every drop — measurable in tight benchmarks, free everywhere else:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::Arc;
use std::thread;

let shared = Arc::new(vec![1, 2, 3, 4, 5]);

let mut handles = vec![];
for i in 0..3 {
    let view = Arc::clone(&shared);
    handles.push(thread::spawn(move || {
        let sum: i32 = view.iter().sum();
        (i, sum)
    }));
}

let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(results.len(), 3);
assert!(results.iter().all(|(_, s)| *s == 15));

Every spawned thread gets its own Arc clone, bumps the strong-count on the way in, and decrements on the way out. The Vec is dropped exactly once — when the last thread (or the main one) lets go of the final clone.

The Arc::clone(&x) convention is even more important here than for Rc: at a glance you want to know that the closure captured a counter bump, not a 200MB deep copy of the value inside.

Read-only is still the default — wrap for mutation

Just like Rc, you only get &T through an Arc<T>:

1
2
3
4
5
use std::sync::Arc;
let a = Arc::new(String::from("app"));
let b = Arc::clone(&a);
a.push_str("-prod");
// error: cannot borrow data in an `Arc` as mutable

That’s the right default — two threads with &mut to the same value would be an instant data race. To mutate, you compose Arc with a lock. The wrapper choice mirrors the morning’s tradeoff: RefCell would have given us runtime borrow checking on one thread; across threads we need synchronization the OS understands.

Arc<Mutex<T>>: the workhorse of shared mutable state

Pair an Arc with a Mutex and you get the most common concurrent pattern in Rust: many threads, one allocation, exclusive write access at a time.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0_u64));

let mut handles = vec![];
for _ in 0..8 {
    let c = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..1_000 {
            *c.lock().unwrap() += 1;
        }
    }));
}
for h in handles { h.join().unwrap(); }

assert_eq!(*counter.lock().unwrap(), 8_000);

The Arc is what each thread owns; the Mutex is what makes the increment safe. Drop either half and the code doesn’t compile: without the Arc, the Mutex can’t be moved into eight closures at once; without the Mutex, you can’t get &mut u64 from &Mutex<u64> at all.

Arc<RwLock<T>>: when reads dominate

When the value is read far more often than it’s written — a config, a cache, a routing table — swap the Mutex for an RwLock. Many readers can hold a shared lock at the same time; writers still take it exclusively.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::{Arc, RwLock};
use std::thread;

let config = Arc::new(RwLock::new(String::from("v1")));

let mut readers = vec![];
for _ in 0..4 {
    let c = Arc::clone(&config);
    readers.push(thread::spawn(move || {
        let g = c.read().unwrap();
        g.len()
    }));
}

{
    let mut w = config.write().unwrap();
    *w = String::from("v2-prod");
}

let lens: Vec<_> = readers.into_iter().map(|h| h.join().unwrap()).collect();
assert!(lens.iter().all(|&n| n == 2 || n == 7));
assert_eq!(*config.read().unwrap(), "v2-prod");

Arc<Mutex<T>> vs Arc<RwLock<T>> is one of those daily judgment calls: if write contention is high, Mutex is often faster because RwLock has more bookkeeping; if reads are ≥10× writes, RwLock lets the readers actually run in parallel.

The catch: Arc<T> doesn’t make T thread-safe

Arc::new(x) only requires T: Sync to be Send. If you wrap a Cell<u32> (which is !Sync) in an Arc, the type system catches you trying to share it across threads — there’s no magic; the wrappers compose because their Send/Sync bounds compose.

1
2
3
4
5
6
7
8
use std::sync::Arc;
use std::cell::Cell;
use std::thread;

let a = Arc::new(Cell::new(0));
let b = Arc::clone(&a);
thread::spawn(move || b.set(1));
// error: `Cell<i32>` cannot be shared between threads safely

That’s why Arc<Mutex<T>> and Arc<RwLock<T>> are the workhorses: the inner type provides the Sync, the Arc provides the Send.

When not to reach for Arc

Arc is the right tool for genuine shared ownership across threads, but it isn’t the only way to move data across one. If a thread just needs to borrow something for a bounded scope, scoped threads let you hand out plain &T without an Arc clone at all. If a value only needs to move from producer to consumer, an mpsc channel transfers ownership directly. Use Arc when more than one thread genuinely owns the same allocation at the same time — not as the default smart pointer for “I have a value and threads.”

159. Rc<T> — Single-Threaded Shared Ownership

The borrow checker’s one-owner rule is a feature until you’re modeling a graph, a cache, or any structure where “who owns this?” honestly has more than one answer. Rc<T> is the escape hatch: a reference-counted pointer that lets multiple owners share the same heap allocation, single-threaded, no locking, no overhead beyond a counter increment.

The pain: a value with no single owner

Imagine a config blob a couple of subsystems need to read. You don’t want to copy it — it’s big — and you can’t give either subsystem a &Config without inventing a parent that outlives them both:

1
2
3
4
5
6
7
8
struct Config { name: String }

fn build() -> (Logger, Metrics) {
    let cfg = Config { name: "app".into() };
    let logger = Logger { cfg: &cfg };  // error: `cfg` does not live long enough
    let metrics = Metrics { cfg: &cfg };
    (logger, metrics)
}

You could clone() the Config and hand each subsystem its own copy. You could thread the lifetime through every type that touches it. Or you could acknowledge that this value genuinely has multiple owners and say so.

Rc<T>: many owners, one allocation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use std::rc::Rc;

struct Config { name: String }
struct Logger { cfg: Rc<Config> }
struct Metrics { cfg: Rc<Config> }

let cfg = Rc::new(Config { name: "app".into() });
let logger = Logger { cfg: Rc::clone(&cfg) };
let metrics = Metrics { cfg: Rc::clone(&cfg) };

assert_eq!(logger.cfg.name, "app");
assert_eq!(metrics.cfg.name, "app");

Rc::new puts the Config on the heap alongside a strong-count of 1. Rc::clone doesn’t clone the Config — it bumps the counter and hands back another pointer to the same allocation. When a clone is dropped the count decrements; when the last clone is dropped, the Config is destroyed and the heap memory is freed.

The convention is Rc::clone(&x) rather than x.clone(). Both compile, both do the same thing, but the explicit form makes “this is a cheap counter bump, not a deep copy” obvious at the call site — especially helpful when T itself is Clone.

Inspecting the count

Rc::strong_count is a debugging window into the same counter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::rc::Rc;

let a = Rc::new(String::from("shared"));
assert_eq!(Rc::strong_count(&a), 1);

let b = Rc::clone(&a);
let c = Rc::clone(&a);
assert_eq!(Rc::strong_count(&a), 3);

drop(b);
assert_eq!(Rc::strong_count(&a), 2);

You almost never need to read it in production code — its main use is asserting “yes, this is actually shared” in tests, or understanding a memory leak.

You only get &T through an Rc<T>

The price for shared ownership is read-only access. Rc<T> is Deref<Target = T>, but not DerefMut. Try to mutate and the compiler stops you:

1
2
3
4
5
6
use std::rc::Rc;

let cfg = Rc::new(String::from("app"));
let other = Rc::clone(&cfg);
cfg.push_str("-prod");
// error: cannot borrow data in an `Rc` as mutable

This is the correct default. If two owners could both call &mut simultaneously, you’d have a data race in single-threaded code — exactly the bug Rust exists to prevent. So you need a runtime borrow check, which is what RefCell<T> provides.

The classic pattern: Rc<RefCell<T>>

When the shared value also needs to mutate, wrap it in a RefCell. The outer Rc gives you shared ownership; the inner RefCell gives you borrow_mut through &self:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::rc::Rc;
use std::cell::RefCell;

let log: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));

let writer_a = Rc::clone(&log);
let writer_b = Rc::clone(&log);

writer_a.borrow_mut().push("a says hi".into());
writer_b.borrow_mut().push("b says hi".into());

let snapshot = log.borrow();
assert_eq!(snapshot.len(), 2);
assert_eq!(snapshot[0], "a says hi");

Every Rc::clone owns the cell; every borrow goes through RefCell’s runtime check. Forget the inner RefCell and you’ll be staring at cannot borrow as mutable errors. Forget the outer Rc and you can’t share the cell in the first place. The pair shows up so often that “Rc<RefCell<…>>” should land in your fingers as one token.

Rc::clone vs (*rc).clone()

It’s worth seeing the difference once:

1
2
3
4
5
6
7
8
9
use std::rc::Rc;

let a = Rc::new(vec![1, 2, 3]);

let cheap = Rc::clone(&a);        // counter += 1, no allocation
let expensive: Vec<i32> = (*a).clone();   // brand new Vec, separate heap allocation

assert_eq!(Rc::strong_count(&a), 2);   // a + cheap; `expensive` isn't an Rc at all
assert_eq!(expensive, vec![1, 2, 3]);

Rc<T> doesn’t change what T::clone does — it just adds a much cheaper way to make another pointer to the same T. Use Rc::clone for the pointer; reach for (*rc).clone() only when you really do want a fresh, independent T.

The catch: Rc<T> is not Send

Rc is deliberately not thread-safe. The counter increment isn’t atomic; if two threads bumped it simultaneously you could undercount and free a still-referenced allocation. Try to move one across a thread boundary and the compiler refuses:

1
2
3
4
5
6
use std::rc::Rc;
use std::thread;

let a = Rc::new(42);
thread::spawn(move || println!("{a}"));
// error: `Rc<i32>` cannot be sent between threads safely

That refusal is the whole reason Rc is cheap — no atomic ops, no fences. Cross threads and you want the atomically-counted sibling: Arc<T>, which this afternoon’s bite is about. Same API, same shape, just Send + Sync and a slightly costlier clone.

The other catch: reference cycles

Two Rcs pointing at each other never drop, because each is keeping the other’s count above zero:

1
2
3
4
struct Node {
    next: RefCell<Option<Rc<Node>>>,
}
// build a -> b -> a … and neither will ever be freed

Rc is a counter, not a tracing GC — it can’t notice that an island of nodes is unreachable from the outside as long as the nodes are still pointing at each other. The fix is Weak<T>, the non-owning sibling pointer you get from Rc::downgrade. That’s tomorrow’s bite. For now: if your data is a tree, plain Rc<RefCell<T>> is fine; if it’s a graph or has back-pointers, plan for Weak.

#158 May 2026

158. OnceLock<T> and LazyLock<T, F> — The std Replacements for lazy_static!

This morning’s Atomic* handled “many threads, one scalar.” For “many threads, one value — computed once, then read forever” the std answer is two types: LazyLock<T, F> when you can name the initializer up front, OnceLock<T> when you only learn the value at runtime. Both make the old lazy_static! macro and the once_cell crate redundant.

The bad old days: lazy_static!

Until Rust 1.80 (mid-2024), a thread-safe lazy global meant a macro from a crate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Cargo.toml: lazy_static = "1"
use lazy_static::lazy_static;
use std::collections::HashMap;

lazy_static! {
    static ref COUNTRIES: HashMap<&'static str, &'static str> = {
        let mut m = HashMap::new();
        m.insert("fr", "France");
        m.insert("de", "Germany");
        m
    };
}

fn main() {
    assert_eq!(COUNTRIES.get("fr"), Some(&"France"));
}

It worked, but it pulled in a dependency, used macro magic to fake a static, and gave you a custom Deref wrapper instead of a normal type. Every dependency in your tree that wanted a lazy global was either pulling in lazy_static or the more modern once_cell crate.

The std way: LazyLock<T, F>

Stabilized in Rust 1.80, LazyLock is a normal type — no macro, no Deref trickery, just a static like any other:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::collections::HashMap;
use std::sync::LazyLock;

static COUNTRIES: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
    let mut m = HashMap::new();
    m.insert("fr", "France");
    m.insert("de", "Germany");
    m
});

assert_eq!(COUNTRIES.get("fr"), Some(&"France"));

The closure runs the first time anything touches COUNTRIES, exactly once, and every thread that races to be that “first” gets the same value back. If two threads arrive together, one wins the init and the other blocks until it’s done — same contract lazy_static! always had, now in std.

When the initializer isn’t known at compile time: OnceLock<T>

LazyLock is great when you can write the initializer as a const fn-friendly closure. But what about config you only know after main() starts — a CLI flag, an env var, a parsed file? Stuffing that into a closure means either re-reading the env var at first use or capturing values you don’t have yet at static time. That’s OnceLock<T>’s job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use std::sync::OnceLock;

static GREETING: OnceLock<String> = OnceLock::new();

fn init_from_args(name: &str) {
    GREETING.set(format!("hello, {name}")).ok();   // ok() = ignore "already set"
}

fn greeting() -> &'static str {
    GREETING.get().map(String::as_str).unwrap_or("hello, world")
}

init_from_args("ferris");
assert_eq!(greeting(), "hello, ferris");

set returns Err(value) if someone beat you to it — handy when multiple call sites might initialize and you want the first one to win without panicking.

The convenience method: get_or_init

The “check if set, init if not” dance is so common that OnceLock ships it as one call:

1
2
3
4
5
6
7
8
9
use std::sync::OnceLock;

fn config() -> &'static String {
    static CFG: OnceLock<String> = OnceLock::new();
    CFG.get_or_init(|| std::env::var("APP_CFG").unwrap_or_else(|_| "default".into()))
}

assert!(!config().is_empty());
assert_eq!(config(), config());    // same &'static str on every call

This is what most “lazy global computed from runtime data” code actually wants. Functions can own their own OnceLock as a function-local static — no need to pollute the module namespace.

Which one when

You haveReach for
A closure that needs no runtime inputLazyLock<T, F>
A value you’ll set later (from main, a builder, a DI container)OnceLock<T> with set
Per-function memoization of an expensive computationOnceLock<T> with get_or_init
Single-threaded version of the aboveLazyCell / OnceCell

LazyLock is the closer match to lazy_static!. OnceLock is the closer match to manual Mutex<Option<T>> patterns where you wanted “set once, read many.”

The trait bounds, briefly

Because these are Sync and live in a static, the contained T must be Send + Sync. The initializer closure for LazyLock must be Send. You won’t notice for String, HashMap, Vec, Arc<…> — but if you try to stuff an Rc<T> in there the compiler will (correctly) yell. The single-threaded versions, LazyCell and OnceCell, have no such bound — that’s the whole reason both pairs exist.

What you can finally delete

If a crate in your tree still has lazy_static = "1" or once_cell = "1" in its Cargo.toml, and your MSRV is 1.80 or newer, the migration is mechanical:

1
2
3
4
// before
lazy_static! { static ref X: T = init(); }
// after
static X: LazyLock<T> = LazyLock::new(init);
1
2
3
4
5
6
// before
use once_cell::sync::OnceCell;
static X: OnceCell<T> = OnceCell::new();
// after
use std::sync::OnceLock;
static X: OnceLock<T> = OnceLock::new();

One fewer dependency, one less macro in the expansion, and the type that shows up in error messages is just LazyLock<T> — not some crate-private deref wrapper. Tomorrow’s bite picks up the thread on Arc<T> — what to reach for when “one global value” isn’t enough and you need shared ownership across threads.

157. Atomic* — The Thread-Safe Cell for Scalars

A Cell<T> lets a single thread mutate through &selfget/set instead of &mut. The atomic types in std::sync::atomic are the same shape, just Sync: a counter, flag, or pointer many threads can poke at without a Mutex, no lock acquisition, no guard, no panic on contention.

The pain: Mutex<u64> for a single counter

A request counter shared across worker threads is the textbook reach-for-Arc<Mutex<_>> case — and the textbook overkill:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::{Arc, Mutex};
use std::thread;

let hits = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();

for _ in 0..8 {
    let h = Arc::clone(&hits);
    handles.push(thread::spawn(move || {
        for _ in 0..1000 {
            let mut g = h.lock().unwrap();   // lock, increment, unlock — 1000 times
            *g += 1;
        }
    }));
}
for h in handles { h.join().unwrap(); }
assert_eq!(*hits.lock().unwrap(), 8_000);

Eight threads contending on a lock for an n += 1 is a lot of ceremony to add one to an integer. The CPU has a single instruction for this. Rust exposes it.

The fix: AtomicU64 (or AtomicUsize, AtomicBool, …)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

let hits = Arc::new(AtomicU64::new(0));
let mut handles = Vec::new();

for _ in 0..8 {
    let h = Arc::clone(&hits);
    handles.push(thread::spawn(move || {
        for _ in 0..1000 {
            h.fetch_add(1, Ordering::Relaxed);   // one instruction, no lock
        }
    }));
}
for h in handles { h.join().unwrap(); }
assert_eq!(hits.load(Ordering::Relaxed), 8_000);

No lock(), no guard, no unwrap. fetch_add is a single read-modify-write — on x86 it’s literally lock xadd. The Arc is still there because the threads need shared ownership, but the interior is lock-free.

The API is just Cell’s API, with orderings

Every atomic has the same small surface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::sync::atomic::{AtomicUsize, Ordering};

let n = AtomicUsize::new(7);

// like Cell::get / Cell::set
let v = n.load(Ordering::Relaxed);     assert_eq!(v, 7);
n.store(42, Ordering::Relaxed);
assert_eq!(n.load(Ordering::Relaxed), 42);

// like Cell::replace
let old = n.swap(100, Ordering::Relaxed);
assert_eq!(old, 42);
assert_eq!(n.load(Ordering::Relaxed), 100);

Notice what’s missing: there is no &mut T anywhere. You never borrow the inside. You read out a copy or write one in. That’s why this works across threads at all — there’s nothing to alias.

Read-modify-write: the real reason atomics exist

The fetch_* family is where atomics earn their keep. Each is a single uninterruptible round-trip:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::atomic::{AtomicI32, Ordering};

let n = AtomicI32::new(10);

assert_eq!(n.fetch_add(5, Ordering::Relaxed), 10);  // returns old
assert_eq!(n.load(Ordering::Relaxed), 15);

assert_eq!(n.fetch_sub(3, Ordering::Relaxed), 15);
assert_eq!(n.fetch_or(0b1000, Ordering::Relaxed), 12);
assert_eq!(n.fetch_and(0b1100, Ordering::Relaxed), 0b1100);
assert_eq!(n.load(Ordering::Relaxed), 0b1100);

fetch_add, fetch_sub, fetch_or, fetch_and, fetch_xor, fetch_min, fetch_max — each one returns the value before the operation. That “before” is what makes them composable: you know exactly which thread did the increment that took you from 999 to 1000.

For anything more complex than a single op (clamp, toggle a state machine, transform), reach for update instead of hand-rolling a compare_exchange loop.

AtomicBool: the flag that doesn’t need a Mutex

The most common “I just want one bit” case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
use std::sync::atomic::{AtomicBool, Ordering};

let stop = AtomicBool::new(false);

// thread A
stop.store(true, Ordering::Release);

// thread B's hot loop
if stop.load(Ordering::Acquire) {
    // shut down
}
# assert!(stop.load(Ordering::Acquire));

Release on the writer + Acquire on the reader pairs everything written before the store with everything read after the load — the standard cancellation-flag pattern. Relaxed would be fine if stop is the only thing the two threads share; use Acquire/Release when the flag is gating other writes.

The full menu

std::sync::atomic ships an atomic for every primitive size:

TypeNotes
AtomicBoolLock-free flags
AtomicU8 / U16 / U32 / U64 / UsizeUnsigned counters, bitmasks
AtomicI8 / I16 / I32 / I64 / IsizeSigned deltas
AtomicPtr<T>Raw *mut T, for hand-rolled lock-free structures

Not every target supports every width lock-free (32-bit ARM lacks 64-bit CAS, for example). cfg(target_has_atomic = "64") lets you gate code that requires it. On modern x86_64 and aarch64, all of the above are lock-free.

What you give up vs Mutex<T>

Atomics work only on values the CPU already knows how to swap in one instruction. The moment you need to atomically update two fields together — a counter and a timestamp, say — you’re back to Mutex<T>. There is no AtomicStruct. You can’t fetch_push a Vec.

The other thing you give up is loud failure. A Mutex poisoned by a panic returns an Err; a deadlock blocks forever and shows up in a stack dump. An atomic happily does the wrong thing forever if you pick the wrong Ordering — the bug manifests as a flaky test under heavy load on a weakly-ordered CPU, and not at all on your laptop. Use SeqCst when in doubt; reach for Relaxed/Acquire/Release only when you can name what’s being synchronized with what.

When to reach for atomics

Counters, flags, generation numbers, fetch_add-style ID allocators, the “is this initialized yet” bit. Anything where the value fits in a register and the only operation is read / write / one-shot RMW.

Anything fatter — a config map, a parsed AST, a connection pool — wants a Mutex<T> or RwLock<T> wrapped in an Arc. And for the “compute once, then read forever” case across threads, there’s a purpose-built tool — that’s this afternoon’s bite.

#156 May 2026

156. RwLock<T> — Many Readers OR One Writer, When Reads Dominate

This morning’s Mutex<T> treats every caller the same: one at a time, no matter what they’re doing. If ninety-nine of them only want to read, that’s ninety-nine threads serialized behind a lock they didn’t need. RwLock<T> splits the door in two — many readers OR one writer — so read-heavy workloads actually fan out.

The pain: Mutex serializes readers too

A Mutex doesn’t know or care whether you’re going to mutate. Two threads that just want to peek at a config still queue up:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::sync::{Arc, Mutex};
use std::thread;

let config = Arc::new(Mutex::new(vec!["a", "b", "c"]));
let mut handles = Vec::new();

for _ in 0..8 {
    let c = Arc::clone(&config);
    handles.push(thread::spawn(move || {
        let g = c.lock().unwrap();        // all 8 threads serialize here
        g.iter().map(|s| s.len()).sum::<usize>()
    }));
}

Eight threads, eight reads, zero writes — and they still run one at a time. For a config that’s read on every request and updated once an hour, that’s a lot of wasted parallelism.

The fix: read() and write()

RwLock<T> has two acquire methods, and they map directly onto the two halves of the aliasing rule:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use std::sync::RwLock;

let lock = RwLock::new(vec![1, 2, 3]);

// Many readers can hold a read guard at the same time.
{
    let r1 = lock.read().unwrap();
    let r2 = lock.read().unwrap();
    assert_eq!(r1.len(), 3);
    assert_eq!(r2.len(), 3);
}

// Exactly one writer at a time, with no readers alive.
{
    let mut w = lock.write().unwrap();
    w.push(4);
}

assert_eq!(*lock.read().unwrap(), vec![1, 2, 3, 4]);

read() hands back a RwLockReadGuard that derefs to &T. write() hands back a RwLockWriteGuard that derefs to &mut T. Both release on drop. The whole point: any number of read() guards can be alive at once, as long as no write() guard is.

The classic shape: Arc<RwLock<T>> with many readers

The pattern is the same Arc<_>-wraps-the-shared-thing shape as Arc<Mutex<T>>, but the parallelism story changes. Readers actually run at the same time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::sync::{Arc, RwLock};
use std::thread;

let cache: Arc<RwLock<Vec<u32>>> = Arc::new(RwLock::new(vec![10, 20, 30]));
let mut handles = Vec::new();

// 8 readers, all running concurrently.
for _ in 0..8 {
    let c = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        let g = c.read().unwrap();
        g.iter().sum::<u32>()
    }));
}

// One writer, runs alone.
{
    let c = Arc::clone(&cache);
    handles.push(thread::spawn(move || {
        let mut w = c.write().unwrap();
        w.push(40);
        0
    }));
}

let sums: Vec<u32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// Every reader saw either the pre-write or post-write state, never a torn one.
assert!(sums.iter().all(|&s| s == 60 || s == 100 || s == 0));

The point isn’t the assertion — it’s that the eight readers can interleave freely on real hardware. Swap in a Mutex and they’d be a stairstep.

The footgun: writer starvation

A reader-heavy workload can keep the lock in “shared” mode forever. A writer waiting on write() blocks every new reader from joining (on most platforms), but the current readers keep working — and as soon as one of them is done, if a new reader sneaks in before the writer is scheduled, the writer stays parked. The standard library’s RwLock does not promise any particular fairness policy, and historically the behavior varied per OS.

Two practical takeaways:

  1. Keep the write path short. Compute what you want to write outside the lock; take write() only to swap the result in.
  2. If you find yourself reaching for “give readers priority” or “give writers priority” knobs, you’ve outgrown std::sync::RwLock. Either restructure to publish snapshots through Arc::new swaps, or pull in parking_lot::RwLock which exposes fairness controls.

The other footgun: holding the read guard across a write

Same shape as the Mutex “hold the guard too long” bug, but uglier — because nested re-entry on the same thread will deadlock, not panic:

1
2
3
4
5
6
let lock = RwLock::new(0u32);

let r = lock.read().unwrap();
// Some library calls back into us here and tries:
let mut w = lock.write().unwrap();  // deadlock: we still hold `r`
*w += 1;

RefCell would panic loudly with already borrowed. RwLock will silently park the thread, and you’ll see it only in a stack dump. When in doubt, drop the guard explicitly before any call you don’t control.

Going the other way: downgrade to keep reading what you just wrote

Once you’ve finished a write and want to keep reading the same value without a release/reacquire window, RwLockWriteGuard::downgrade is the atomic way across. Worth knowing about for the cache-refresh shape, where the writer thread immediately turns into a long-lived reader.

When to pick RwLock vs Mutex

Mutex<T> for short critical sections, mixed read/write workloads, or anywhere the per-operation cost of a lock matters. Cheaper per acquire, simpler mental model, no fairness surprises.

RwLock<T> when reads vastly outnumber writes and the read critical section is non-trivial — long enough that running them in parallel actually pays for the higher per-acquire cost. Read-only config lookups served on every request, periodically refreshed snapshots, anything shaped like “1000 readers, 1 writer per minute.”

If reads are short (a single field load) and contention is low, plain Mutex is often faster in practice — the extra bookkeeping in RwLock isn’t free. Measure before assuming the read-write split is a win.

#155 May 2026

155. Mutex<T> — Cross-Thread Exclusive Access, With a Guard Instead of a Panic

Yesterday’s RefCell<T> gives you &mut through &self on a single thread — and panics the moment a second borrow shows up. Mutex<T> is the same idea wearing a hard hat: it’s Sync, so it works across threads, and instead of panicking on contention it just blocks until the other holder is done.

The pain: RefCell is single-threaded, and panics on contention

RefCell<T> is !Sync. The moment you try to share one across threads, the compiler stops you:

1
2
3
4
5
6
7
8
9
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;

let shared = Arc::new(RefCell::new(0u32));
let s2 = Arc::clone(&shared);

thread::spawn(move || { *s2.borrow_mut() += 1; });
// error[E0277]: `RefCell<u32>` cannot be shared between threads safely

Even on one thread, RefCell will panic if a borrow_mut() clashes with a live borrow(). That’s fine for logic bugs you want to find loudly — useless for two real threads that genuinely both want to write.

The fix: lock() returns a guard

Mutex<T> is Sync (when T: Send), and its .lock() method takes &self, blocks until the mutex is free, and hands back a MutexGuard<'_, T>. The guard derefs to &mut T, and releases the lock when it drops:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::sync::Mutex;

let m = Mutex::new(0u32);

{
    let mut g = m.lock().unwrap();   // blocks here if held; we're alone, so it's instant
    *g += 1;
    *g += 1;
}                                     // lock released as `g` drops

assert_eq!(*m.lock().unwrap(), 2);

The .unwrap() is there because lock() returns Result — see “Poisoning” below.

The classic: Arc<Mutex<T>> across threads

Mutex is the inside half. To share ownership across threads you wrap it in Arc — the thread-safe sibling of Rc (covered in Sunday’s morning bite):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0u32));
let mut handles = Vec::new();

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

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

assert_eq!(*counter.lock().unwrap(), 800);

Eight threads, a hundred increments each, eight hundred total — no torn writes, no races, because every increment runs while exactly one thread holds the lock.

The footgun: holding the guard too long

The single most common Mutex bug is leaving the guard alive across slow work. Anything between lock() and the guard going out of scope is serialized:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// BAD — lock held for the whole block, including the slow call
let g = state.lock().unwrap();
if let Some(v) = g.cache.get(&key) {
    slow_network_thing(v);            // every other thread is blocked on us
}

// BETTER — get out what you need, drop the guard, then do the slow work
let v = state.lock().unwrap().cache.get(&key).cloned();
if let Some(v) = v {
    slow_network_thing(&v);
}

drop(g) works too if you can’t easily restructure. The mental model: the guard is a lease, keep it as short as you can.

Poisoning, and why .lock() returns Result

If a thread panics while holding the lock, the Mutex is poisoned. Future lock() calls return Err(PoisonError) so you can decide whether the data is still consistent. You can always recover the inner guard with .into_inner():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::sync::{Arc, Mutex};
use std::thread;

let m = Arc::new(Mutex::new(vec![1, 2, 3]));
let m2 = Arc::clone(&m);

let _ = thread::spawn(move || {
    let _g = m2.lock().unwrap();
    panic!("oops");
}).join();                            // panic propagates, then mutex is poisoned

let mut g = match m.lock() {
    Ok(g) => g,
    Err(p) => p.into_inner(),         // we know our data is still fine
};
g.push(4);
assert_eq!(*g, vec![1, 2, 3, 4]);

For data where one panic mid-update really would leave things half-written, the Err is the signal to crash the whole component instead of papering over it.

When to pick Mutex vs RefCell vs RwLock

RefCell<T> for single-threaded interior mutability where contention is a bug. Panics loudly. Zero locking cost.

Mutex<T> when more than one thread needs to write — or might. Blocks instead of panicking. One holder at a time, readers and writers treated identically.

RwLock<T> (covered in this afternoon’s bite) when reads vastly outnumber writes and you want many readers to proceed in parallel. Pricier per-op than Mutex, but the parallelism wins for read-heavy workloads.

154. UnsafeCell<T> — The Primitive Every Interior-Mutability Type Is Built On

This morning’s OnceCell / LazyCell bite — and Cell, RefCell, Mutex, RwLock, OnceLock, every atomic — all bottom out at the same type: UnsafeCell<T>. It is the one and only legal way in Rust to mutate through a shared reference. You’ll almost never type its name, but knowing what it does explains why all the rest exist.

The pain: &T is “you may not mutate” — to the optimizer too

Outside UnsafeCell, the compiler is allowed to assume &T points to data that won’t change underneath it. It can cache loads, hoist reads out of loops, mark pointers noalias in LLVM. Try to fake interior mutability with a raw cast and you’ve signed up for undefined behavior — not “it works but is ugly,” actual UB the optimizer is free to exploit:

1
2
3
4
fn cheat(r: &u32) {
    let p = r as *const u32 as *mut u32;
    unsafe { *p = 99; } // UB: mutating through &T without UnsafeCell
}

The cast compiles. The write may even appear to happen. But the program has lost all guarantees, and the next release of rustc — or a different opt level — can break it.

The fix: UnsafeCell::get returns *mut T through &self

UnsafeCell<T> is the one type the compiler treats specially: a &UnsafeCell<T> is not a promise that the inside won’t change, so noalias and friends don’t apply. Its .get() method takes &self and hands back a raw *mut T:

1
2
3
4
5
6
use std::cell::UnsafeCell;

let cell = UnsafeCell::new(10u32);
let p: *mut u32 = cell.get();    // legal through &cell
unsafe { *p = 20; }              // legal — UnsafeCell opted out of the no-mutation rule
assert_eq!(unsafe { *cell.get() }, 20);

That’s the whole feature. Every other interior-mutability type in std is a safe wrapper around exactly this primitive plus an invariant — a borrow counter, a lock bit, an atomic flag — that justifies the unsafe block inside.

Build Cell from scratch in 20 lines

To see it in action, here’s a stripped-down Cell<T> clone. Real std::cell::Cell is more polished, but the shape is identical:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::cell::UnsafeCell;

struct MyCell<T> {
    value: UnsafeCell<T>,
}

impl<T: Copy> MyCell<T> {
    fn new(v: T) -> Self {
        Self { value: UnsafeCell::new(v) }
    }

    fn get(&self) -> T {
        // SAFETY: UnsafeCell makes Self !Sync, so no thread races us.
        // We never hand out a reference into the cell, so no aliased &mut exists.
        unsafe { *self.value.get() }
    }

    fn set(&self, v: T) {
        // SAFETY: same as `get`.
        unsafe { *self.value.get() = v; }
    }
}

let counter = MyCell::new(0u32);
counter.set(counter.get() + 1);
counter.set(counter.get() + 1);
assert_eq!(counter.get(), 2);

UnsafeCell does the optimizer-level work; the Copy bound plus “never lend a reference out” does the safety work. Drop either piece and the type is unsound.

The !Sync default — and how Mutex opts back in

UnsafeCell<T> is !Sync even when T: Sync. That’s deliberate: if you could share an &UnsafeCell<T> across threads with no synchronization, two threads could race on the inside and you’d be back to UB.

That’s why Cell and RefCell are !Sync (single-thread only), and why Mutex<T> carries an explicit:

1
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

The unsafe impl is the author asserting “I added the lock; now sharing across threads is sound.” The pattern recurs in every sync interior-mutability type — RwLock (tomorrow’s afternoon bite), OnceLock, LazyLock, the atomics. The shape is always: UnsafeCell<T> + an invariant + an unsafe impl Sync.

When to reach for it yourself

In application code: basically never. Cell, RefCell, Mutex, RwLock, OnceCell, OnceLock cover every common pattern and they’re already audited.

Real reasons to hold UnsafeCell directly: writing a new lock primitive, an arena that hands out &mut slots from &self, a lock-free data structure, FFI cells that mirror an existing C struct. If you’re not building infrastructure of that kind, the right move is to use a wrapper that already wraps it — and now you know what’s in the bottom of the box.