Skip to content

Atomics

An atomic operation on a shared word happens all at once: no other thread can observe it half-done, and no two of them can lose an update to each other. Basalt exposes that as a set of concrete value types — AtomicU32, AtomicBool, AtomicPointer<T> and their siblings — each carrying the operations that make sense for it.

An atomic makes one operation on one word indivisible. It does not make two operations, or two words, a transaction — and that is the boundary at which you need a Mutex instead.

The types, and the operations each one has

Section titled “The types, and the operations each one has”
Type Holds Operations
AtomicI8 AtomicI16 AtomicI32 AtomicI64 A signed integer of that width Load Store Exchange CompareExchange FetchAdd FetchSub FetchOr FetchAnd
AtomicU8 AtomicU16 AtomicU32 AtomicU64 An unsigned integer of that width The same eight
AtomicBool A flag Load Store Exchange CompareExchange
AtomicPointer<T> A T* Load Store Exchange CompareExchange

Each type carries only the operations that are valid for it. AtomicBool has no FetchAdd because adding to a flag is a bug, and the missing method makes it unwritable rather than diagnosable. AtomicPointer<T> is typed for the same reason: a void* surface would put a cast at every use site. There is no atomic floating-point type, and no way to make an arbitrary T atomic — the widths above are what the hardware provides in one instruction.

Each type is an aggregate with one public value field and no constructor of its own. It is trivially copyable, zero-initialisable, and pushable onto an arena:

AtomicU32 counter{}; // zero-initialised, ready to use
Span<AtomicU32> per_slot = arena.Push<AtomicU32>(64); // arena memory, all zeroes, all usable

Reaching for value directly is a data race while another thread may touch the object. Read and write it through the methods; Load() gives you a copy of the word to reason about.

Every operation takes an Ordering and defaults to Ordering::SeqCst, which is the ordering that needs no reasoning at the call site. The weaker ones are worth naming when you know exactly what the operation has to guarantee.

Ordering Constrains Reach for it when
Relaxed Nothing beyond the atomicity of this one operation The word is the whole story: a statistic, an identifier hand-out
Acquire On a load: nothing after it may be observed before it The load that consumes something another thread published
Release On a store: nothing before it may be observed after it The store that publishes work done before it
AcqRel Both, on one read-modify-write The operation that consumes and publishes at once
SeqCst All of the above, plus one order every thread agrees on The default, and the answer whenever two threads each write one word and read the other’s

Relaxed — a counter nobody reads until the work is over.

(void)frames_dropped.FetchAdd(1u, Ordering::Relaxed); // the total is read after the join

Release and Acquire — publishing data through a pointer. The pair is what makes the non-atomic writes before the store visible after the load. Neither ordering does anything on its own; they work as a pair on the same word.

// Producer.
frame->vertices = vertices; // ordinary writes
frame->count = count;
ready_frame.Store(frame, Ordering::Release); // everything above is now published
// Consumer.
Frame* frame = ready_frame.Load(Ordering::Acquire); // if non-null, the writes above are visible
if (frame != nullptr)
{
Draw(frame->vertices, frame->count);
}

AcqRel — the last worker to finish. FetchSub both consumes what the other workers published and publishes this worker’s own results to whoever reads the zero.

if (remaining.FetchSub(1u, Ordering::AcqRel) == 1u)
{
Merge(results); // every worker's writes are visible here
}

SeqCst — two threads, two flags, each reading the other’s. Under weaker orderings both threads can see the other’s flag unset and both proceed. SeqCst is what forbids that outcome, and it is the default precisely so that this shape is safe when you have not thought about it.

CompareExchange swaps only if the current value is the one you expected. On failure it writes what it actually found into expected, which is what makes the retry loop work without a second load.

u32 observed = high_water.Load();
while (candidate > observed && !high_water.CompareExchange(observed, candidate))
{
// The call refreshed `observed` with the value it found. Re-test and try again.
}

It takes two orderings: one for the successful swap, one for the failing load. The failure ordering must be Relaxed, Acquire or SeqCst, and never stronger than the success ordering. It defaults to Relaxed, so passing a weaker success ordering on its own stays valid; a debug assertion catches the invalid pairs.

This is the mistake the type set invites, because each individual operation is so obviously safe.

// Racy: the load and the store are
// two instants, and another thread
// runs between them.
const u32 id = next_id.Load();
next_id.Store(id + 1u);
// One indivisible operation: no two
// threads can be handed the same id.
const u32 id = next_id.FetchAdd(1u);

The same applies across two words. head and tail may each be atomic, and a reader that loads both still sees a pair that was never simultaneously true. When an invariant spans more than one word, the thing that holds it is a lock, not an atomic.

An atomic is also not a lock on its own. A flag saying “the buffer is ready” publishes a buffer that is written once; it does not protect a buffer that keeps changing, because nothing stops the writer from starting the next change while a reader is inside the old one.

Situation Use
An invariant spanning more than one variable Mutex
A critical section that does real work, not one update Mutex
State read by many threads and written by few RwLock
A thread that must wait until the value becomes something ConditionVariable — spinning on Load burns a core

Contention is the other limit. Writing an atomic gives the writing core exclusive ownership of the cache line it sits on, so a single counter hammered by every core is a bottleneck no ordering fixes. Give each thread its own counter and sum them at the end.

Parameters, orderings and the per-type contract are documented in basalt/core/concurrency/Atomic.h.

Signature Purpose
Load(order = SeqCst) Reads the value.
Store(value, order = SeqCst) Writes the value.
Exchange(value, order = SeqCst) Writes the value, returns the previous one.
CompareExchange(expected, desired, success = SeqCst, failure = Relaxed) Swaps if the value equals expected. On failure, loads the observed value into expected. Returns whether it swapped.
FetchAdd(value, order = SeqCst) Adds, returns the value before the addition. Integer types only.
FetchSub(value, order = SeqCst) Subtracts, returns the value before. Integer types only.
FetchOr(value, order = SeqCst) Bitwise or, returns the value before. Integer types only.
FetchAnd(value, order = SeqCst) Bitwise and, returns the value before. Integer types only.
AtomicThreadFence(order) A standalone fence, tied to no object. Relaxed does nothing.