Atomics and fetch_add

A mutex is a lock around a value; an atomic is a value the processor can change without a lock. The integer types and bool have atomic versions in std::sync::atomic, and every method takes an ordering that says how much synchronisation the operation provides.

This lesson counts with fetch_add, updates with compare_exchange, publishes a value with an AtomicBool, and ends with the compiler refusing a mutable static counter. The orderings are used here and explained in the next lesson.

A counter without a lock

fetch_add adds and returns the previous value as one instruction, so two threads that call it at the same time cannot lose an update. The counter is shared through an Arc like any other value.

Four threads add twenty-five each. The total is one hundred whatever the order, and no lock is taken on the way.

use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; fn main() { let counter = Arc::new(AtomicUsize::new(0)); let mut handles = Vec::new(); for _ in 0..4 { let shared = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..25 { shared.fetch_add(1, Ordering::Relaxed); } })); } for handle in handles { handle.join().unwrap(); } println!("{}", counter.load(Ordering::Relaxed)); }

rustc --edition 2024 counter.rs
./counter

100

Ordering::Relaxed is enough for a counter that is read after every thread has joined, because the join itself is the barrier. The mutex lesson builds the same counter behind a lock, and the difference is one instruction against a lock and an unlock.

Not every update is a plain addition. fetch_update takes a closure that maps the old value to an optional new one, which is the exchange loop of the next section written once by the library. The width of the type matters too: an AtomicU64 is lock-free on a 64-bit target and may be implemented with a lock on a 32-bit one, and is_lock_free is not exposed because the answer is not portable.

compare_exchange

compare_exchange writes a new value only if the current one still equals the expected one, and it reports whether it wrote. A loop around it retries after a lost race, which is how a read-modify-write that is not a single instruction is built.

The program adds five at a time until the value reaches twenty. Three exchanges succeed and the loop stops.

use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(10); let mut rounds = 0; loop { let current = value.load(Ordering::Relaxed); rounds += 1; if current >= 20 { break; } if value .compare_exchange(current, current + 5, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { continue; } } println!("{} {}", value.load(Ordering::Relaxed), rounds); }

rustc --edition 2024 cas.rs
./cas

20 3

The four-argument form takes two orderings: one for the successful exchange and one for the failure. Both are SeqCst here, which is the strongest and the easiest to reason about; a loop that only counts can use weaker ones. The next lesson explains when a weaker ordering is still correct.

The loop is the shape of every compare-and-swap algorithm, and the reason the library also offers compare_exchange_weak: on some processors the operation can fail spuriously, and the weak form lets the loop retry instead of insisting. A data structure built this way is lock-free when at least one thread always makes progress, which is a property to prove and not one the type guarantees.

An atomic counter wraps at the top of its type, which is defined behaviour and worth knowing when the counter is a sequence number: a program that must not wrap compares against the previous value or uses a wider type. The SeqCst ordering costs a memory barrier on the architectures that need one, and a program that only counts pays for it without needing it.

A flag between two threads

An AtomicBool is the smallest way to publish a fact. The store with Release and the load with Acquire are a pair: whatever the writer did before the store is visible to a reader that sees the true value.

The worker sets the flag and the main thread reads it after the join. The print is the flag itself, which is the value the pair of orderings made visible.

use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; fn main() { let ready = Arc::new(AtomicBool::new(false)); let shared = Arc::clone(&ready); let worker = thread::spawn(move || { shared.store(true, Ordering::Release); }); worker.join().unwrap(); println!("{}", ready.load(Ordering::Acquire)); }

rustc --edition 2024 flag.rs
./flag

true

A Relaxed store would also print true here, because the join orders the two threads anyway; the pair matters when the reader does not join, as a spinning consumer does. The difference between the two cases is the whole subject of the next lesson.

Why not a mutable static

A static mut looks like the shortest counter and is the one thing the compiler will not allow in safe code. Two threads can write it at once, and the compiler has no way to know that the access is protected, so every use is an unsafe operation.

The program increments a mutable static and prints it. Both lines are refused with E0133, and the note names the data race the type cannot prevent.

static mut COUNT: usize = 0; fn main() { COUNT += 1; println!("{COUNT}"); }

rustc --edition 2024 static_err.rs

error[E0133]: use of mutable static is unsafe and requires unsafe block
--> static_err.rs:4:5
|
4 | COUNT += 1;
| ^^^^^ use of mutable static
|
= note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior

error[E0133]: use of mutable static is unsafe and requires unsafe block
--> static_err.rs:5:16
|
5 | println!("{COUNT}");
| ^^^^^ use of mutable static
|
= note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0133`.

The replacement is a static of an atomic type, which needs no unsafe block because every operation on it is defined for several threads: static COUNT: AtomicUsize = AtomicUsize::new(0);. The lesson on const and static covers the two kinds of item.

Exercises

  1. Replace the mutex counter of the previous lesson with an atomic and compare the two programs.
  2. Use fetch_max to keep the largest value four threads have seen.
  3. Make the compare_exchange loop add one only if the value is even, and count the retries.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does fetch_add return?

    Answer

    The value before the addition

    The method adds and hands back the previous value, all in one atomic step.

  2. What does a successful compare_exchange guarantee?

    Answer

    The value was still the expected one when it was replaced

    The exchange only happens if the current value matches the expectation.

  3. Which atomic type holds a truth value?

    Answer

    AtomicBool

    The bool has a dedicated atomic type with load, store and swap.

  4. Why does a static mut counter need an unsafe block?

    Answer

    Several threads can write it, which the compiler cannot check

    A mutable static has no protection, so every access is a programmer promise.

  5. What does this program print? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(5); let old = value.fetch_add(3, Ordering::Relaxed); println!("{} {}", old, value.load(Ordering::Relaxed)); }

    Answer

    5 8

    The call returns the old value and stores the sum.

  6. What does this program print? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(1); let result = value.compare_exchange(2, 3, Ordering::SeqCst, Ordering::SeqCst); println!("{} {}", result.is_ok(), value.load(Ordering::SeqCst)); }

    Answer

    false 1

    The expected value does not match, so nothing is written.

  7. What does this program print? use std::sync::atomic::{AtomicBool, Ordering}; fn main() { let flag = AtomicBool::new(false); flag.store(true, Ordering::Release); println!("{}", flag.swap(false, Ordering::AcqRel)); }

    Answer

    true

    swap returns the value from before the store, which was true.

  8. What does this program print? use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let value = AtomicUsize::new(0); value.fetch_add(2, Ordering::Relaxed); value.fetch_add(3, Ordering::Relaxed); println!("{}", value.load(Ordering::Relaxed)); }

    Answer

    5

    The two additions accumulate in the same atomic value.

  9. What is the first line of the compiler output for this program? static mut COUNT: usize = 0; fn main() { COUNT += 1; println!("{COUNT}"); }

    Answer

    error[E0133]: use of mutable static is unsafe and requires unsafe block

    The compiler cannot prove that no other thread writes the static.

  10. What is the first line of the compiler output for this program? use std::sync::atomic::AtomicUsize; fn main() { let value = AtomicUsize::new(0); *value += 1; println!("{value:?}"); }

    Answer

    error[E0614]: type Atomic<usize> cannot be dereferenced

    An atomic has no Deref implementation, so the value is reached only through its methods.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev