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++20std::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:
- Lifetime: the referenced object must outlive every
atomic_refbound to it. - Exclusivity: while any
atomic_refto an object exists, all access to that object (or its subobjects) from any thread must go throughatomic_refs. Mixing a plain read or write with a concurrent atomic operation is a data race and undefined behaviour. - Alignment: the object must be aligned to at least
std::atomic_ref<T>::required_alignment. For many types this equalsalignof(T), but for some (for exampledoubleor 64-bit integers on certain 32-bit ABIs, or trivially-copyable structs of 8 or 16 bytes) it may be larger. - Type:
Tmust be trivially copyable (C++20).Tmust not be cv-qualified in the const-volatile sense required for modification (see pitfalls forconst 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:
T | Extra operations | Since |
|---|---|---|
| Any trivially copyable | load, store, exchange, compare_exchange_*, wait/notify_* | C++20 |
| Integral | fetch_add/sub/and/or/xor, ++, --, +=, -=, &=, |=, ^= | C++20 |
| Floating point | fetch_add, fetch_sub, +=, -= | C++20 |
Pointer U* | fetch_add, fetch_sub, ++, --, +=, -= | C++20 |
| Integral | fetch_max, fetch_min | C++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
#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.
#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
#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:
#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)
#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 andstd::aligned_allocor over-aligned allocators for buffers whenrequired_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_freewithstatic_assertin performance-critical code. A non-lock-freeatomic_refmay 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_refis 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
xdirectly while another thread doesatomic_ref<int>(x).fetch_add(1)is a data race, even if the plain read looks harmless. - Insufficient alignment. Constructing an
atomic_refover an under-aligned object is undefined behaviour. This commonly bites withdouble/int64_tin packed structs orcharbuffers reinterpreted as wider types. - Dangling references. The referenced object (for example an element of a
std::vector) must not be destroyed or reallocated while anatomic_refexists.push_backon a vector during an atomic phase invalidates everything. - Overlapping objects. Different
atomic_refs of different types over overlapping storage (for exampleatomic_ref<int>andatomic_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_refover memory shared between processes is only safe ifis_always_lock_freeis true. - Padding bits in structs.
compare_exchangeon 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_addordering. 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::atomicandstd::atomic_flag(C++11): owning atomic types and the full operation setstd::memory_order(C++11): ordering semantics for the operations abovestd::barrierandstd::latch(C++20): phase synchronisation that pairs naturally withatomic_refstd::jthread(C++20): thread type used in the examplesstd::condition_variable: blocking synchronisation whenwait/notifyon a single word is not enough