Skip to content
C++
Library
since C++20
Advanced

std::atomic_ref

Apply atomic operations to existing non-atomic objects with std::atomic_ref (C++20): syntax, alignment rules, specializations, and pitfalls.

std::atomic_refsince C++20

std::atomic_ref<T> is a class template in <atomic> that applies atomic operations to a referenced, ordinary object of type T for the lifetime of the atomic_ref, so that concurrent accesses made through atomic_ref instances are free of data races.

Overview

std::atomic<T> owns its storage: the object is atomic for its whole life. std::atomic_ref<T> (since C++20) inverts this. The data stays a plain T, and you wrap a reference to it in an atomic_ref only when you need atomic access. This is useful when:

  • You have large arrays or structs of plain data (for example a grid, histogram, or particle buffer) that are mostly accessed single-threaded or through vectorised code, but occasionally need atomic updates in a parallel phase.
  • The storage layout is fixed by an external API, file format, or shared memory, and you cannot change element types to std::atomic<T>.
  • You need non-atomic access in one phase (initialisation, post-processing) and atomic access in another, without paying for atomicity throughout.

Key rules:

  1. Lifetime: the referenced object must outlive every atomic_ref bound to it.
  2. Exclusivity: while any atomic_ref to an object exists, all access to that object (or its subobjects) from any thread must go through atomic_refs. Mixing a plain read or write with a concurrent atomic operation is a data race and undefined behaviour.
  3. Alignment: the object must be aligned to at least std::atomic_ref<T>::required_alignment. For many types this equals alignof(T), but for some (for example double or 64-bit integers on certain 32-bit ABIs, or trivially-copyable structs of 8 or 16 bytes) it may be larger.
  4. Type: T must be trivially copyable (C++20). T must not be cv-qualified in the const-volatile sense required for modification (see pitfalls for const T, C++26).

Multiple atomic_ref objects may refer to the same underlying object, and they synchronise with each other. An atomic_ref itself is cheap: typically a single pointer, copyable, but not assignable (copy-assignment is deleted).

Specializations

atomic_ref provides the same specialization structure as std::atomic:

TExtra operationsSince
Any trivially copyableload, store, exchange, compare_exchange_*, wait/notify_*C++20
Integralfetch_add/sub/and/or/xor, ++, --, +=, -=, &=, |=, ^=C++20
Floating pointfetch_add, fetch_sub, +=, -=C++20
Pointer U*fetch_add, fetch_sub, ++, --, +=, -=C++20
Integralfetch_max, fetch_minC++26

atomic_ref<T>::is_always_lock_free (static constexpr, C++20) and is_lock_free() report lock-freedom. Since C++20 wait(), notify_one() and notify_all() are also provided.

Syntax

cpp
#include <atomic>

template<class T> struct atomic_ref;               // since C++20

int value = 0;                                     // plain object
std::atomic_ref<int> ref(value);                   // C++20: explicit construction

// Properties
constexpr bool always = std::atomic_ref<int>::is_always_lock_free;  // C++20
constexpr std::size_t align = std::atomic_ref<int>::required_alignment; // C++20

// Core operations (all take an optional std::memory_order)
int  v = ref.load(std::memory_order_acquire);
ref.store(42, std::memory_order_release);
int  old = ref.exchange(7);
bool ok = ref.compare_exchange_weak(old, 8);

// Integral specialization
ref.fetch_add(1, std::memory_order_relaxed);
ref += 5;                                          // seq_cst
++ref;

// Waiting (C++20)
ref.wait(0);                                       // block while value == 0
ref.notify_one();

Template argument deduction is not available through CTAD in C++20 in a way that avoids naming T; the constructor takes T&, so write std::atomic_ref<int> r(x) or std::atomic_ref r(x) (CTAD works for the explicit-reference form).

Examples

Parallel histogram over a plain array

A histogram stored as a plain std::vector<unsigned> can be updated concurrently in a parallel phase, then read as ordinary data after joining.

cpp
#include <atomic>
#include <thread>
#include <vector>
#include <span>
#include <cstdint>
#include <iostream>

// C++20: std::atomic_ref, std::span, std::jthread
void accumulate(std::span<unsigned> bins, std::span<const std::uint8_t> data)
{
    for (std::uint8_t b : data) {
        std::atomic_ref<unsigned> bin(bins[b]);       // C++20
        bin.fetch_add(1, std::memory_order_relaxed);  // counter only: relaxed is enough
    }
}

int main()
{
    std::vector<std::uint8_t> input(1'000'000);
    for (std::size_t i = 0; i < input.size(); ++i)
        input[i] = static_cast<std::uint8_t>(i * 31);

    std::vector<unsigned> bins(256, 0);               // plain, non-atomic storage

    {
        constexpr unsigned N = 4;
        std::vector<std::jthread> workers;            // C++20: joins in destructor
        std::size_t chunk = input.size() / N;
        for (unsigned t = 0; t < N; ++t) {
            std::span<const std::uint8_t> part(input.data() + t * chunk, chunk);
            workers.emplace_back(accumulate, std::span<unsigned>(bins), part);
        }
    }   // all jthreads joined: join synchronises-with, so plain reads below are safe

    unsigned total = 0;
    for (unsigned c : bins) total += c;               // plain access, phase has ended
    std::cout << total << '\n';
}

The join provides the happens-before edge, so relaxed ordering on the increments is sufficient and the final plain reads are race-free.

Atomic access to a struct member in shared state

cpp
#include <atomic>

struct Stats {
    alignas(std::atomic_ref<long>::required_alignment) long hits = 0; // C++20
    long misses = 0;
};

void record_hit(Stats& s)
{
    std::atomic_ref<long> h(s.hits);
    h.fetch_add(1, std::memory_order_relaxed);
}

alignas(required_alignment) guarantees that the member meets the requirement even on ABIs where plain long is under-aligned for atomic use.

Lock-free maximum with a CAS loop

Before C++26 there is no fetch_max for atomic_ref, so use compare-exchange:

cpp
#include <atomic>

void update_max(int& target, int candidate)
{
    std::atomic_ref<int> ref(target);                       // C++20
    int cur = ref.load(std::memory_order_relaxed);
    while (cur < candidate &&
           !ref.compare_exchange_weak(cur, candidate,
                                      std::memory_order_relaxed)) {
        // cur reloaded on failure
    }
    // C++26: ref.fetch_max(candidate, std::memory_order_relaxed);
}

Waiting on a plain flag (C++20)

cpp
#include <atomic>

void waiter(int& state)
{
    std::atomic_ref<int> s(state);
    s.wait(0, std::memory_order_acquire);   // C++20: block until state != 0
}

void signaller(int& state)
{
    std::atomic_ref<int> s(state);
    s.store(1, std::memory_order_release);
    s.notify_all();                         // C++20
}

Both sides must construct atomic_refs over the same object; wait/notify pair up on the referenced object's address.

Best Practices

  • Separate phases. Structure code as atomic phase, then join/barrier, then plain phase. The synchronisation event (thread join, std::barrier (C++20), std::latch (C++20)) is what makes the plain accesses safe.
  • Use alignas(std::atomic_ref<T>::required_alignment) for members and std::aligned_alloc or over-aligned allocators for buffers when required_alignment > alignof(T).
  • Pick the weakest correct memory order. Counters and histograms need only memory_order_relaxed; publication of data needs release/acquire.
  • Check is_always_lock_free with static_assert in performance-critical code. A non-lock-free atomic_ref may use an internal lock table keyed by address, which is slower and not address-free.
  • Prefer std::atomic<T> when you control the type and always need atomicity. atomic_ref is a tool for retrofitting, not a default.
  • Create atomic_refs locally (per function or per loop body). They are trivially cheap and avoid dangling-reference hazards.

Common Pitfalls

  • Mixing atomic and non-atomic access. Reading x directly while another thread does atomic_ref<int>(x).fetch_add(1) is a data race, even if the plain read looks harmless.
  • Insufficient alignment. Constructing an atomic_ref over an under-aligned object is undefined behaviour. This commonly bites with double/int64_t in packed structs or char buffers reinterpreted as wider types.
  • Dangling references. The referenced object (for example an element of a std::vector) must not be destroyed or reallocated while an atomic_ref exists. push_back on a vector during an atomic phase invalidates everything.
  • Overlapping objects. Different atomic_refs of different types over overlapping storage (for example atomic_ref<int> and atomic_ref<short> over the same bytes) are not synchronised against each other.
  • Locking fallback and shared memory. When not lock-free, the implementation may use hidden locks, so atomic_ref over memory shared between processes is only safe if is_always_lock_free is true.
  • Padding bits in structs. compare_exchange on a struct compares object representations. Padding bytes can cause spurious failures; use structs with no padding.
  • Const references. Before C++26, atomic_ref<const T> is not permitted. Since C++26 it is allowed, but only for read operations (load, wait).
  • Floating-point fetch_add ordering. Floating-point addition is not associative, so atomic accumulation across threads gives nondeterministic rounding. Use per-thread partials and reduce if reproducibility matters.

See Also

  • std::atomic and std::atomic_flag (C++11): owning atomic types and the full operation set
  • std::memory_order (C++11): ordering semantics for the operations above
  • std::barrier and std::latch (C++20): phase synchronisation that pairs naturally with atomic_ref
  • std::jthread (C++20): thread type used in the examples
  • std::condition_variable: blocking synchronisation when wait/notify on a single word is not enough