6.3 · Atomic Types and Their Operations
Domain 6 — Concurrency and Parallelism Duration: ~15 minutes Library components:
std::sync::atomic::AtomicBool,std::sync::atomic::AtomicUsize,std::sync::atomic::AtomicPtr,std::sync::atomic::Ordering
Introduction
Atomics are the smallest concurrency primitive. An atomic is a single value whose operations complete indivisibly, as dedicated CPU instructions. It needs no lock, no syscall, and no blocking. Each higher-level tool in this domain (Mutex, channels, OnceLock) uses atomics at its lowest level.
This tutorial shows:
- The atomic type family and its portability limits.
load,store,swap: flags and one-winner claims.- The
fetch_*read-modify-write (RMW) family:fetch_add,fetch_sub,fetch_or,fetch_and,fetch_xor. - Closure-based RMW:
update/try_update(stabilized in Rust 1.95), and the legacyfetch_update(deprecated in Rust 1.99). from_mut/from_mut_slice/get_mut_slice(1.98): exclusive&mutintegers as atomics.- When an atomic is better than a
Mutex, and when it is not.
Memory orderings appear here only as a general rule: Relaxed for standalone counters, SeqCst when you are not sure. Tutorial 6.4 gives the full model.
The Atomic Type Family
std::sync::atomic provides AtomicBool, AtomicI8/AtomicU8 through AtomicI64/AtomicU64, AtomicIsize/AtomicUsize, and AtomicPtr<T>. Three properties define them:
-
Interior mutability: all operations take
&self. An atomic in astaticneeds nounsafe, no lazy initialization, and nomut:// `AtomicU64::new` is a const fn, so it can initialize a static. static REQUESTS_SERVED: AtomicU64 = AtomicU64::new(0); -
Syncby design: the purpose of an atomic is that threads share a&AtomicU64. The hardware serializes concurrent operations. -
Portability limit: not every target has every width (some 32-bit platforms have no 64-bit atomics).
AtomicUsizeandAtomicBoolare the safest defaults. Each type exists only where the platform supports lock-free operations on it.
Each operation takes an Ordering parameter. Until Tutorial 6.4, use this rule. A counter that needs only its own consistency can use Relaxed. Code that publishes other memory should use SeqCst, the default when you are not sure.
A unique &mut u32 proves that no other thread can observe the value. Thus, since 1.98, you can view those bytes as an atomic with AtomicU32::from_mut(&mut n). from_mut_slice does the same for a full array. get_mut_slice is the inverse: it gives an exclusive &mut [AtomicU32] as an ordinary &mut [u32] for single-threaded initialization. The exclusive borrow does the work of a lock.
load, store, swap: Flags and Claims
load and store are atomic reads and writes. The usual pattern is a shutdown flag:
let shutdown = AtomicBool::new(false);
// The coordinator sets the flag. In a real program, it does this while the worker runs.
// `06_09_atomic_counter.rs` sets the flag BEFORE the worker starts, for a deterministic result.
shutdown.store(true, Ordering::Relaxed);
let iterations = thread::scope(|s| {
s.spawn(|| {
let mut work_done = 0_u32;
// The worker checks the flag before each unit of work.
while !shutdown.load(Ordering::Relaxed) {
work_done += 1;
}
work_done
})
.join()
.expect("worker should not panic")
});
assert_eq!(iterations, 0); // the flag was already true at the first check
swap writes a new value and returns the old value in one indivisible step. This gives two common patterns:
-
One-winner claim: N threads race, and exactly one wins:
// claimed: AtomicBool, initially false. 8 threads run this code. let already_claimed = claimed.swap(true, Ordering::Relaxed); // returns the OLD value if !already_claimed { // Only one thread gets `false`. That thread runs the job. } -
Take-and-reset: drain a dirty-flags word atomically. A flag that a different thread raises concurrently goes into this batch or the subsequent batch. The program never loses a flag and never counts a flag twice:
let dirty = AtomicU8::new(0b0110); let batch = dirty.swap(0, Ordering::Relaxed); // read AND clear in one step // batch is 0b0110, and dirty is now 0
Which thread wins a claim is different on each run. That exactly one thread wins is deterministic. These tutorials assert on this type of property.
fetch_add: The Canonical Lock-Free Counter
A non-atomic count += 1 from two threads is a lost-update bug: each thread reads 5, and each thread writes 6. fetch_add does the read-modify-write as one step:
// REQUESTS_SERVED is the static AtomicU64 from the first snippet.
// 4 threads each run this line 10_000 times:
REQUESTS_SERVED.fetch_add(1, Ordering::Relaxed);
// After the join of all the threads, the value is exactly 40_000 on each run:
assert_eq!(REQUESTS_SERVED.load(Ordering::Relaxed), 40_000);
Each fetch_* operation returns the previous value. Frequently, that value is the result that you need. For example, a lock-free ID allocator is only next_id.fetch_add(1, ...), because each caller gets a different previous value.
The bitwise family operates on flag words:
fetch_orsets bits. Each worker sets its own "done" bit. OR is commutative, and that is why no lock is necessary.fetch_andclears bits.fetch_xortoggles bits.
fetch_max and fetch_min also exist, for a running maximum or minimum.
06_09_atomic_counter.rs prints:
requests served: 40000
allocated ids: [100, 101, 102, 103]
worker exited after 0 iterations (flag was pre-set)
All assertions passed.
fetch_update, update, try_update: Arbitrary RMW
There is no fetch_clamp or fetch_saturating_mul. For an operation that is not built in, you supply a closure. Rust 1.95 stabilized the two current methods:
-
update(set_order, fetch_order, f)appliesf: T -> T. It repeats an internal compare-exchange loop until its write succeeds. Then it returns the previous value:// peak_latency_us: AtomicU32, initially 0. Several threads share it. // sample: u32, one latency measurement. // Keep the maximum of all the samples from all the threads: peak_latency_us.update(Ordering::Relaxed, Ordering::Relaxed, |peak| peak.max(sample)); -
try_update(set_order, fetch_order, f)takesf: T -> Option<T>. If the closure returnsNone, the call stops and does not write. The result isOk(previous)after a write, andErr(current)after a stop. A token bucket uses it to "decrement unless zero" in one atomic step:// tokens: AtomicU32, initially 3. 8 threads run this code. let outcome = tokens.try_update(Ordering::Relaxed, Ordering::Relaxed, |t| { t.checked_sub(1) // None when t == 0: stop, no write }); match outcome { Ok(_previous) => { /* this thread got a token: the previous value was 1 or more */ } Err(_current) => { /* the pool is empty: the current value is 0 */ } }With 3 tokens and 8 threads, which threads get a token is different on each run. That exactly 3 succeed and 5 fail is deterministic.
-
fetch_updateis the original form with anOption, and its behavior is identical totry_update. It appears in much existing code. Rust 1.99 deprecates it ("renamed totry_update"), so the compiler gives a warning for each use. Writetry_updatein new code.
One rule applies: the closure may run more than one time, because the method tries again when a different thread wins the race. Keep the closure pure: no side effects, no I/O.
When an Atomic Is Better Than a Mutex, and When It Is Not
Figure: Choosing Between Atomics and Locks
Atomics are better for single independent values. They have no blocking, no syscalls, and no poisoning, and the hot path is one CPU instruction.
Atomics are worse when an invariant includes more than one value. Each of two separate atomics can be consistent while a reader sees the pair in an inconsistent state. Atomicity does not compose across variables. Mutex<Stats> makes the full struct one critical section. Two AtomicU64 values make two independent guarantees. When you are not sure, start with Mutex. Use an atomic only when a profiler shows the need.
Summary
| Concept | Key point |
|---|---|
| Atomic types | AtomicBool, integer widths, AtomicPtr. Operations take &self. new is a const fn. |
| Statics | static N: AtomicU64 = AtomicU64::new(0) needs no unsafe and no lazy initialization. |
load / store | Atomic read and write, for shutdown flags and published state. |
swap | Writes a value and returns the old value: one-winner claims, take-and-reset. |
fetch_add etc. | Indivisible RMW. Returns the previous value (ID allocator). |
fetch_or/and/xor | Lock-free bit operations. Commutative operations make a lock unnecessary. |
update (1.95) | Closure T -> T, repeated compare-exchange loop, returns the previous value. |
try_update (1.95) | Closure T -> Option<T>. None stops the call without a write. |
fetch_update | Legacy name of try_update, deprecated since 1.99. |
from_mut / from_mut_slice / get_mut_slice (1.98) | Exclusive &mut integers ↔ atomics. The exclusive borrow does the work of a lock. |
| Closure rule | The closure may run more than one time. Keep it pure. |
| Atomic vs Mutex | Atomic for one value. Mutex for invariants across values. |
Code Examples
| File | Description |
|---|---|
06_09_atomic_counter.rs | fetch_add counters, static atomics, ID allocation, shutdown flag |
06_10_atomic_bitflags.rs | fetch_or/and/xor, swap take-and-reset, one-winner claim |
06_11_atomic_update.rs | update/try_update (1.95), token bucket, fetch_update (deprecated in 1.99) |
06_28_atomic_from_mut.rs | from_mut / from_mut_slice / get_mut_slice (1.98) |