Atomics and Global Variables
Understand atomic operations, memory ordering, and safe global state management in Rusts concurrency model
Atomic types are the foundation of lock‑free concurrent programming. They let multiple threads read and write shared data without using a Mutex, and they guarantee that certain operations on that data happen as one indivisible step — no thread can observe the operation halfway done.
In this section you will learn what atomics are, how Rust exposes them, when to choose each memory ordering, and how to combine atomics with global variables to keep your program correct and fast.
Why atomics exist
When two threads access the same piece of memory at the same time, and at least one writes, you have a data race — undefined behaviour. A Mutex prevents the race by letting only one thread touch the memory at a time, but acquiring and releasing a lock carries a cost. In many programs the work inside the critical section is tiny — incrementing a counter, toggling a flag — and a full lock becomes unnecessary overhead.
Atomics solve this by moving the synchronisation into the hardware. The CPU itself guarantees that a single atomic read‑modify‑write (for example “load, add 1, store”) completes without interruption, even on a multicore system. No operating system calls, no waiting.
Atomics vs locks:
Atomic operations are not a replacement for all locking. They are best for small, fast operations where the critical section is a single variable. For complex invariants that span multiple variables, a mutex is still usually the right choice.
Rust’s atomic types
All atomics live in std::sync::atomic. The module provides a separate type for each primitive integer width, plus AtomicBool and AtomicPtr. You do not wrap a normal integer in an atomic wrapper — you use the dedicated atomic type directly.
| Atomic type | Corresponding primitive |
|---|---|
AtomicBool | bool |
AtomicI8 | i8 |
AtomicI16 | i16 |
AtomicI32 | i32 |
AtomicI64 | i64 |
AtomicIsize | isize |
AtomicU8 | u8 |
AtomicU16 | u16 |
AtomicU32 | u32 |
AtomicU64 | u64 |
AtomicUsize | usize |
AtomicPtr | *mut T |
Every atomic type implements Sync, so a shared reference &AtomicU32 is safe to use from many threads. However, an atomic type itself does not share ownership — you still need an Arc or a static item to hand out those references.
Core atomic operations
All atomic types share the same set of operations, each receiving an Ordering argument that controls how strongly the operation synchronises with other threads. The most common are:
load(order)— reads the current valuestore(val, order)— writes a new valueswap(val, order)— writes a new value and returns the old onefetch_add(val, order)— addsvaland returns the previous value (subtraction with negative numbers)compare_exchange(current, new, success, failure)— replaces the value withnewonly if it equalscurrent; returnsOk(old)orErr(actual)compare_exchange_weak— same contract ascompare_exchange, but may fail spuriously; designed for loops
Each of these operations is indivisible. When one thread executes fetch_add, no other thread can see the counter between the read and the write.
compare_exchange_weak is safer in a loop:
compare_exchange_weak may fail even when the value matches, returning Err. Always use it inside a retry loop unless you have a single‑shot use case where a spurious failure is acceptable. The regular compare_exchange is easier to reason about but sometimes generates less efficient machine code because it hides the retry from the compiler.
Memory ordering
Choosing the right ordering is the hardest part of atomics. The ordering tells the compiler and the CPU what reorderings are allowed around the atomic operation. Getting this wrong does not usually crash your program — it just makes it behave unpredictably.
Rust exposes five orderings, exactly matching the C++20 model.
Relaxed
Ordering::Relaxed imposes no synchronisation at all. It guarantees atomicity of the single operation — an increment can never be torn — but it does not guarantee that other threads see your changes in any particular order. Use relaxed only when the value’s relationship with other memory is irrelevant, such as a simple statistics counter.
Acquire and Release
Acquire and Release work in pairs. A store with Release ensures that all previous writes in the same thread become visible to a thread that later loads the same atomic with Acquire. Intuitively: the store releases all prior work to memory; the load acquires that work.
This is the backbone of many lock‑free patterns. For example, publishing an object through a pointer guarded by an atomic flag.
AcqRel
AcqRel combines both semantics. It is often used for read‑modify‑write operations like fetch_add. The loading part behaves as Acquire, the storing part as Release.
SeqCst
SeqCst is the strongest ordering. It prevents all reordering around the atomic operation and also establishes a single, globally agreed order of all SeqCst operations across all threads. It is the easiest to reason about and the most expensive at runtime.
Start with SeqCst:
If you are new to atomics, use SeqCst everywhere. Your program will be correct (assuming the logic itself is sound). You can relax the orderings later after profiling — and only after you have proven the change is safe.
A concrete look at ordering
Imagine two threads communicating through an AtomicBool flag and a normal integer. One thread writes some data, then signals the flag. The other thread waits for the flag, then reads the data.
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
let flag = AtomicBool::new(false);
let data: Vec<i32> = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| {
// Using a release store ensures `data` is visible after the flag.
flag.store(true, Ordering::Release);
});
s.spawn(|| {
// Acquire ensures we see everything that happened before the release.
while !flag.load(Ordering::Acquire) {}
// data is guaranteed to be fully constructed here.
println!("{:?}", data);
});
});
When the flag store uses Release and the load uses Acquire, the compiler and CPU guarantee that data is fully written before the load observes true. With Relaxed, the second thread might see true but still read an empty or partially‑written data.
Mixed atomic and non-atomic access is UB:
Reading the same memory through both atomic and non‑atomic operations without synchronisation is undefined behaviour. Once a variable is accessed atomically, all concurrent accesses to that memory must also be atomic, unless you have established a happens‑before relationship that guarantees no concurrent access.
Example – a global shared counter
A common use case for atomics is a thread‑safe counter that is accessible from anywhere in the program without passing around an Arc. Since atomic types implement Sync, they can be placed in a static variable directly.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn main() {
let handles: Vec<_> = (0..8)
.map(|_| {
thread::spawn(|| {
for _ in 0..1_000_000 {
COUNTER.fetch_add(1, Ordering::Relaxed);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
println!("Final count: {}", COUNTER.load(Ordering::Relaxed));
}
No Mutex, no Arc. Every fetch_add is one atomic instruction on the hardware, and the program prints 8000000 every time. The cost of the ordering Relaxed is minimal here because the counter does not guard any other memory — we only care about the final number.
Building a simple spinlock step by step
Atomics allow building synchronisation primitives yourself. A spinlock is a lock that repeatedly tries an atomic “test‑and‑set” in a loop instead of blocking the thread.
Step 1: define the lock state
A spinlock is just an AtomicBool. false means unlocked, true means locked.
use std::sync::atomic::{AtomicBool, Ordering};
struct SpinLock {
locked: AtomicBool,
}
impl SpinLock {
fn new() -> Self {
SpinLock {
locked: AtomicBool::new(false),
}
}
}
Step 2: implement the lock method
Use compare_exchange to atomically replace false with true. If it fails, the lock is held by another thread — spin until it succeeds.
impl SpinLock {
fn lock(&self) {
while self
.locked
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
// Busy‑wait: reduce contention by hinting the CPU.
std::hint::spin_loop();
}
}
}
The Acquire ordering on success ensures that any reads or writes after lock returns see the previous owner’s changes. On failure we use Relaxed because we are just waiting; we haven’t entered the critical section yet.
Step 3: implement the unlock method
Releasing the lock is just a store of false. We use Release so all work inside the critical section becomes visible to the next thread that acquires.
impl SpinLock {
fn unlock(&self) {
self.locked.store(false, Ordering::Release);
}
}
Step 4: use the spinlock safely
Wrap the lock in a guard so it cannot be forgotten. This pattern mirrors Mutex::lock.
use std::ops::{Deref, DerefMut};
struct SpinLockGuard<'a, T> {
lock: &'a SpinLock,
data: &'a mut T,
}
impl<'a, T> Drop for SpinLockGuard<'a, T> {
fn drop(&mut self) {
self.lock.unlock();
}
}
impl<T> SpinLock<T> {
fn lock(&self) -> SpinLockGuard<T> {
self.inner.lock();
SpinLockGuard {
lock: &self.inner,
data: unsafe { &mut *self.data.get() },
}
}
}
UnsafeCell provides the interior mutability needed to hold the T. The full safe wrapper is beyond this section, but the atomic part is exactly the three previous steps.
Spinlocks waste CPU time:
A spinlock keeps the CPU core at 100% while waiting. This is acceptable only when critical sections are extremely short and lock contention is rare. For anything else a standard Mutex that parks waiting threads is more efficient.
Global variables with atomics
Static atomic variables
A static item in Rust is a global variable with a fixed memory address. Without interior mutability, a static is immutable. Because AtomicUsize (and all other atomic types) provide interior mutability without unsafe, you can declare a mutable global without ever writing static mut.
static FLAGS: AtomicU32 = AtomicU32::new(0);
fn set_bit(bit: u32) {
FLAGS.fetch_or(1 << bit, Ordering::Relaxed);
}
This works from any thread, and the compiler will reject any attempt to place a non‑Sync type inside a static. You cannot accidentally share a RefCell this way.
Lazy global initialisation
Sometimes a global variable needs to be initialised with a computation that is too expensive to run at program start, or that depends on runtime state. Rust’s standard library provides two tools built entirely on atomics: OnceLock and LazyLock.
OnceLock is a synchronisation primitive that allows one‑time initialisation of a value. It stores an Option<T> that starts as None. Multiple threads can call get_or_init simultaneously; exactly one will run the closure.
use std::sync::OnceLock;
fn config() -> &'static HashMap<String, String> {
static CONFIG: OnceLock<HashMap<String, String>> = OnceLock::new();
CONFIG.get_or_init(|| {
// Expensive: read a file, parse JSON, etc.
HashMap::from([
("host".into(), "localhost".into()),
("port".into(), "8080".into()),
])
})
}
Internally, OnceLock uses atomics to synchronise the initialisation. The first call runs the closure; all subsequent calls see the cached value with no further atomic work.
For new code, OnceLock and LazyLock are preferred because they are part of the standard library and integrate naturally with the borrow checker.
Common mistakes and misconceptions
The subtlety of atomics is almost entirely in the ordering. Here are the mistakes that trip up even experienced Rust developers.
Relaxed is not 'fast and fine':
Using Relaxed everywhere works for a standalone counter, but it gives no ordering guarantees. If you use a relaxed store to publish data to another thread, the reader may see the store without seeing the data it was supposed to guard. Always ask: does this atomic operation gate access to other memory? If yes, Relaxed is wrong.
The ABA problem. compare_exchange checks that the value equals current, but the value might have changed to something else and back to current in between. If your algorithm depends on the value never having changed, compare_exchange alone is insufficient. Solutions involve tagging the value with a generation counter (common in lock‑free lists) or using double‑word atomics (AtomicU128 on supported targets).
Mixing atomic and non‑atomic accesses. As mentioned earlier, any concurrent access to the same memory must use atomics for both threads unless a happens‑before relationship (e.g. through join or Acquire‑Release) separates them. A static that is initialised once and then read‑only does not need atomics because no concurrent writes exist.
Believing atomics are wait‑free. Atomic operations are lock‑free — the system as a whole makes progress — but not necessarily wait‑free. A compare_exchange loop may spin indefinitely if many threads contend for the same location. This can cause severe performance degradation.
Atomic load on read‑only memory can fault:
On some platforms, an atomic load is implemented with a compare‑exchange instruction. If the memory page is genuinely read‑only (e.g. a const item or OS‑protected page), an atomic load may cause a segmentation fault. The standard library guarantees that small relaxed loads on writable memory are safe, but be careful when interacting with memory‑mapped hardware or const data.
Summary
Atomics give you the ability to synchronise threads at the lowest possible level, without the overhead of locks. The price is that you must understand memory ordering and guarantee that every shared variable is accessed through an atomic type.
The most important practical takeaway is this: start with SeqCst, profile your program, and only then relax orderings when you can prove the change is correct. The entire standard library synchronisation layer — Mutex, RwLock, OnceLock, channels — rests on atomics. When you grasp them, you understand the machinery under every other concurrent type in Rust.