157. Atomic* — The Thread-Safe Cell for Scalars
A Cell<T> lets a single thread mutate through &self — get/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:
| |
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, …)
| |
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:
| |
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:
| |
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:
| |
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:
| Type | Notes |
|---|---|
AtomicBool | Lock-free flags |
AtomicU8 / U16 / U32 / U64 / Usize | Unsigned counters, bitmasks |
AtomicI8 / I16 / I32 / I64 / Isize | Signed 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.