← cosmin bararu

The atomics you don't have_

A 64-bit load can tear on a 32-bit CPU. A reader can load one half before a concurrent write and the other half after it, returning a value no thread ever stored.

A reduced example is a 64-bit handle with a 32-bit generation above a 32-bit slot index. A writer replaces {generation 17, slot 42} with {generation 18, slot 9}. If the reader takes the old low half and the new high half, it gets {generation 18, slot 42}. That handle was never published, and looking it up can pair a valid generation with the wrong slot.

TIME reader load OLD.low load NEW.high writer store NEW word returned OLD.low NEW.high
a two-instruction window can manufacture a value that never existed in memory

The window spans only two instructions, so a test machine will almost never hit it. Across a deployed fleet, it eventually returns a corrupt number with no useful reproducer. On this target, an ordinary 64-bit integer load was not atomic.

The atomics layer still needed one interface across compilers and architectures. That included targets without native 64-bit loads or all the read-modify-write operations callers expected. We built the missing operations from the few primitives each target did provide.

One primitive to build the rest

One 32-bit target had only one useful 64-bit atomic primitive: compare-and-swap. CAS installs a desired value when the location still matches an expected value. If it fails, it writes the value it found back into expected.

That is enough to build the missing read-modify-write operations. Load the current value, compute the replacement, attempt the swap, and retry if another thread changed it first.

uint64_t fetch_add_64(atomic64* p, uint64_t delta) {
    uint64_t old = atomic_load_64(p);
    while (!cas_64(p, &old, old + delta)) {
        // the failed CAS refreshed 'old' with what it found;
        // recompute and try again
    }
    return old;
}

The native atomic add on this target is one hardware read-modify-write. The synthesized version needs a load, an add, and at least one CAS. Another core can change the value before that CAS and force a retry. The loop is lock-free, but not starvation-free. One caller may keep losing while the system as a whole moves forward.

The load that tears

The same target also lacked atomic 64-bit loads and stores. The handle above is why an ordinary integer load was unsafe: both halves can be individually valid while their combination is not.

One toolchain moved a 64-bit floating-point object with one instruction while the equivalent integer access used two. Reinterpreting the bits through that type prevented an ISA-level torn load in that compiler output. It still did not make the access atomic in C++. Concurrent non-atomic reads and writes remain a data race.

A one-instruction move was not enough. The platform needed a compiler guarantee, intrinsic, or assembly with atomic language semantics. Otherwise it used the CAS load or a lock.

Double width and the ABA counter

Compilers may provide one double-width operation: compare-exchange across two machine words. They provide no double-width load, store, or exchange.

This width commonly holds a pointer beside a counter. If a location changes from A to B and back to A, a CAS expecting A cannot see that anything happened. This is the ABA problem. Incrementing the counter on every change gives the recycled A a different counter, so the stale CAS fails.

This is the double-width version of the handle problem. The pointer and counter are meaningful only as one value, so the fallback must read both in one atomic operation.

The layer synthesized exchange, store, and load from CAS. Exchange retries with a constant desired value, and store uses the same exchange but discards the returned value. Load is the surprising one:

// double-width atomic load, built from CAS alone
pair expected = {0, 0};
cas_2w(p, &expected, {0, 0});
return expected;  // either it really was zero, or the failed
                  // CAS just wrote the current value here

Compare the location with zero and ask to replace it with zero. If the location is zero, CAS exchanges zero for zero. Otherwise, failure writes the current value into expected. Either result is an atomic snapshot. The load still pays for a double-width compare-exchange and may request exclusive ownership even though it changes nothing.

cas(addr, expected=0, desired=0) held 0 held x swap succeeds 0 exchanged for 0 · no effect snapshot = 0 swap fails found value written into expected snapshot = x either way you now have the value an atomic load built from the compare's failure reporting
the zero-comparand CAS load, both branches ending in a snapshot

The library call in the hot path

Generic atomics sometimes compile to a call into a support library. At double width, the inline path ran about 3.3x faster than that fallback on x86-64 and about 2.8x faster on a 64-bit ARM core. Those end-to-end measurements justified the extra code.

On another architecture, 128-bit atomics silently became a non-inlined library call even though the hardware could execute them directly. The call was several times slower. We replaced it with inline assembly for that architecture.

I kept most ports in intrinsics and used assembly only when the fallback was missing or measurably slower. New ports began with emulation and added assembly only after measurement.

The reservation

Some architectures provide load-linked and store-conditional instead of CAS. Load-linked reads a location and opens a hardware reservation. Store-conditional writes only if the reservation survived. Any write to the location breaks it, including an A-to-B-to-A change. The library wraps the pair in a retrying macro that loads, applies a caller-supplied transform, attempts the store, and repeats after failure.

The alignment contract

Hardware tracks the reservation over a granule whose size differs by implementation. Any write inside it can break the reservation, even a write to a neighboring variable.

On the tested cores, a cache-line-aligned and cache-line-sized target kept unrelated writes outside the observed granule. Interrupts, context switches, contention, and implementation-specific behavior can still make store-conditional fail.

WHAT KILLS AN LL/SC RESERVATION 1. target shares the granule target hot neighbor load-linked target another core writes neighbor store-conditional target reservation lost: retry 2. target owns the granule aligned, line-sized target load-linked target neighboring writes stay outside store-conditional can succeed
a neighboring write kills the shared granule. An isolated target removes that failure

Only tested cores get the native path

Reservation behavior is implementation-defined, so only tested silicon revisions use the native path. An untested core may have a wider granule. Other targets, including x86 where LL/SC does not exist, use a CAS loop behind the same interface.

Native LL/SC beat the CAS-loop emulation by 22% on one 64-bit ARM core and by 30% on an older 32-bit core. On x86-64, the emulation came within about 4% of a handwritten CAS loop.

The interface stays the same across those implementations, with one layout rule: isolate the value on its own cache line.