Skip to content
C++

std::atomic — Lock-Free Basics

Sharing data between threads without synchronization is one of the most common sources of undefined behaviour in C++. A mutex solves this but introduces blocking: threads stall waiting for a lock. For simple shared state — counters, flags, indices — there is a better option. std::atomic wraps a value in operations that are guaranteed to be indivisible from every other thread's point of view, with no locks required. Understanding what "atomic" actually means, and which operations qualify, is the foundation for writing correct concurrent code.

The problem: reads and writes are not atomic by default

When two threads read and write to the same variable without synchronization, the result is a data race — undefined behaviour in the C++ standard, meaning anything can happen: corrupted values, crashes, or code that appears to work in debug builds but silently misbehaves in release. A plain ++counter on an int is not atomic — the processor must load the value, increment it, and store it back. If two threads interleave these three micro-steps, increments are lost.

// ✗ WRONG: data race — undefined behaviour
int counter = 0;

void increment() {
    for (int i = 0; i < 100'000; ++i)
        ++counter;           // NOT atomic: load → add → store
}

// Running increment() on two threads:
// Expected: 200'000
// Actual:   could be anything — some increments are lost

The fix is to make the operation atomic. The C++ standard library provides std::atomic<T> in the <atomic> header. An atomic operation is one that completes without any thread ever seeing a partially-updated state. No other thread can observe the variable between the "before" and "after" of an atomic read-modify-write.

#include <atomic>
#include <thread>

std::atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 100'000; ++i)
        ++counter;           // atomic fetch_add — indivisible
}

// Two threads both calling increment():
// Result is always exactly 200'000

The C++ memory model: sequential consistency by default

C++11 introduced a well-defined memory model — the rules for when a write to a shared variable in one thread becomes visible to reads in other threads. Before C++11, multithreading was not part of the language at all; every threading library was operating in a zone of undefined behaviour. The memory model has three dimensions: which operations are atomic (indivisible), in what order operations are partially ordered across threads, and when writes become visible.

By default, all std::atomic operations use sequential consistency (memory order seq_cst), which provides the strongest guarantees: the instructions of every thread appear to execute in source-code order, and there is a single global ordering of all atomic operations visible to all threads. This is the safe default. Other memory orderings (relaxed, acquire, release, acq_rel) exist for performance-critical code where you can prove weaker guarantees are sufficient — but they require expert knowledge to use correctly and are not covered in this lesson.

// Default: sequential consistency — safe and predictable
std::atomic<int> x{0};
x.store(42);                   // memory_order_seq_cst by default
int v = x.load();              // always reads a consistent value

// Advanced: explicit memory order (not for beginners)
x.store(42, std::memory_order_relaxed);   // no ordering guarantees
int v2 = x.load(std::memory_order_acquire); // pairs with release

std::atomic_flag — the one guaranteed lock-free atomic

std::atomic_flag is the simplest atomic type: a boolean flag with only two operations. It is the only type in the standard library that is unconditionally guaranteed to be lock-free on every platform. All other atomic types may or may not be lock-free depending on the hardware — on platforms that lack native atomic instructions for a given size, the implementation is allowed to use a mutex internally. atomic_flag has no such escape hatch.

In C++20, atomic_flag gained a test() member (to read the current value without setting it) and the wait(), notify_one(), and notify_all() members for blocking until a flag changes. Before C++20, the flag had to be initialized with ATOMIC_FLAG_INIT; in C++20 and later, a default-constructed flag starts in the cleared (false) state.

#include <atomic>

// C++20: default-constructed starts cleared (false)
std::atomic_flag flag;

// C++11/14/17: must use ATOMIC_FLAG_INIT
std::atomic_flag flag2 = ATOMIC_FLAG_INIT;

// test_and_set: sets flag to true, returns the OLD value
bool was_set = flag.test_and_set();   // was_set = false, flag is now true
was_set = flag.test_and_set();        // was_set = true, flag still true

flag.clear();                         // reset to false
bool current = flag.test();           // C++20: read without modifying

The classic use of atomic_flag is building a spinlock — a mutex that does not put the thread to sleep but instead spins in a tight loop until the lock is available. This is efficient when the lock is held for very short durations (a few nanoseconds) but wasteful otherwise, as the waiting thread burns CPU time doing nothing useful.

class SpinLock {
    std::atomic_flag flag;
public:
    void lock() {
        while (flag.test_and_set())   // spin until we acquire the flag
            std::this_thread::yield();
    }
    void unlock() {
        flag.clear();
    }
};

SpinLock sl;
void critical_section() {
    sl.lock();
    // ... protected work (keep very short) ...
    sl.unlock();
}

std::atomic<T> — the workhorse

std::atomic<T> is a class template with specializations for different categories of types. The primary template handles bool and user-defined types (trivially copyable, no virtual functions). Full specializations exist for every integral type (int, long, unsigned, etc.) and, since C++20, for floating-point types. Partial specializations handle raw pointers and (C++20) smart pointers like std::shared_ptr. The specialization you get determines which operations are available on top of the fundamental interface.

Every specialization shares a fundamental interface: load, store, exchange, and compare-exchange. On top of that, numeric specializations add arithmetic (fetch_add, fetch_sub), floating-point adds only addition and subtraction, pointer specializations add increment/decrement, and integral specializations additionally support bitwise operations (fetch_and, fetch_or, fetch_xor).

#include <atomic>

std::atomic<int>    counter{0};     // full integral specialization
std::atomic<double> temperature{0.0}; // float specialization (C++20)
std::atomic<bool>   ready{false};   // bool specialization

// All share the fundamental interface:
counter.store(10);            // atomically write
int v = counter.load();       // atomically read
int old = counter.exchange(20); // atomically write 20, return old value (10)

// Integral adds arithmetic:
counter.fetch_add(1);         // atomic ++, returns old value
counter.fetch_sub(1);         // atomic --, returns old value
++counter;                    // shorthand — same as fetch_add(1)
counter += 5;                 // returns new value (unlike fetch_add)

Core API reference

The table below lists the operations available on std::atomic<T> and which specializations support them. One subtlety worth noting: the compound assignment operators (+=, |=, etc.) return the new value, while the corresponding fetch_ operations return the old value. This matters when you need to know what the value was before the operation.

OperationDescriptionAvailable on
is_lock_free()Checks if this atomic object is lock-free (runtime)All (except atomic_flag)
T::is_always_lock_freeCompile-time check: always lock-free?All (except atomic_flag)
load()Atomically read the valueAll (except atomic_flag)
store(val)Atomically write the valueAll (except atomic_flag)
exchange(val)Write val, return old value — atomic swapAll (except atomic_flag)
compare_exchange_strong(exp, des)CAS: if *this==exp, write des and return true; else write *this to exp and return falseAll (except atomic_flag)
compare_exchange_weak(exp, des)Like strong but may spuriously fail — use in a loopAll (except atomic_flag)
fetch_add / fetch_subAtomic add/subtract — returns old valueIntegers, floats, pointers
fetch_and / fetch_or / fetch_xorAtomic bitwise ops — returns old valueIntegers only
++, --Pre/post increment and decrementIntegers, pointers
+=, -=Compound add/sub — returns new valueIntegers, floats, pointers
|=, &=, ^=Compound bitwise — returns new valueIntegers only
wait(old) (C++20)Block until value changes from oldAll
notify_one/all() (C++20)Unblock threads waiting on this atomicAll

compare_exchange_strong: the key to lock-free algorithms

Compare-and-swap (CAS) is the primitive that makes lock-free algorithms possible. compare_exchange_strong(expected, desired) does the following atomically: if the current value equals expected, it writes desired and returns true. If not, it writes the current value into expected (updating your local copy) and returns false. The key insight is that the comparison and the swap happen as a single indivisible operation — no other thread can change the value in between.

The weak variant (compare_exchange_weak) is identical except it is permitted to fail spuriously even when the comparison succeeds, to allow more efficient implementation on architectures (like ARM) that implement CAS as a load-linked/store-conditional pair. Because of this, compare_exchange_weak must always be used inside a loop; compare_exchange_strong can be used in an if.

// Implementing atomic multiplication using CAS
// (fetch_mul doesn't exist, but we can build it)
template <typename T>
T fetch_mult(std::atomic<T>& shared, T multiplier) {
    T old_value = shared.load();
    while (!shared.compare_exchange_weak(old_value, old_value * multiplier))
        ;   // loop: spurious failure or another thread changed value
    return old_value;   // returns old value, like other fetch_ operations
}

std::atomic<int> value{5};
int before = fetch_mult(value, 4);
// before == 5, value.load() == 20
// Pattern: update only if current value is what you expect
std::atomic<int> max_seen{0};

void update_max(int candidate) {
    int current = max_seen.load();
    while (candidate > current) {
        // If max_seen is still 'current', replace with 'candidate'
        if (max_seen.compare_exchange_weak(current, candidate))
            break;  // success — current was updated by CAS on failure, retry
    }
}

Checking lock-freedom

Despite the name, std::atomic<T> is not always lock-free. If the hardware lacks a native atomic instruction for the size of T, the standard library is permitted to implement atomicity using an internal mutex. The result is correct but not truly lock-free — a thread holding that internal lock can still be preempted, causing other threads to block. Only std::atomic_flag is unconditionally lock-free. For all other types, you can check:

std::atomic<int>    a_int;
std::atomic<double> a_double;

// Runtime check (for this specific object)
a_int.is_lock_free();    // likely true on x86_64

// Compile-time check (for the type in general)
std::atomic<int>::is_always_lock_free    // true on x86_64
std::atomic<double>::is_always_lock_free // true since C++20 on most platforms

// Practical atomic sizes on x86_64:
// atomic<char/short/int/long/long long> — always lock-free (8, 16, 32, 64 bit)
// atomic<__int128> — may NOT be lock-free (128-bit requires CMPXCHG16B)
// atomic<large struct> — almost certainly NOT lock-free

Common patterns

Atomics shine in a handful of well-known patterns. Shared counters (page hits, events counted, work items completed) are the simplest: increment atomically, read whenever needed. Ready flags let one thread signal another without a mutex. The thread-safe lazy initialization pattern (double-checked locking, now safe in C++11) uses an atomic boolean to avoid running initialization more than once.

Shared counter
std::atomic<int> requests_served{0};

// Multiple threads:
++requests_served;            // safe from any thread

// Main thread or monitoring:
std::println("served: {}", requests_served.load());
Ready flag (producer-consumer signalling)
std::atomic<bool> data_ready{false};
std::string data;

// Producer thread:
data = compute_heavy_thing();
data_ready.store(true);       // signal: data is ready

// Consumer thread:
while (!data_ready.load())    // spin-wait (or use wait() in C++20)
    std::this_thread::yield();
use(data);                    // safe: producer set data before setting flag
C++20: wait/notify instead of spinning
std::atomic<bool> data_ready{false};

// Producer:
data = compute_heavy_thing();
data_ready.store(true);
data_ready.notify_one();      // wake up one waiting thread

// Consumer: blocks efficiently (OS-level) instead of spinning
data_ready.wait(false);       // wait until value != false
use(data);

When atomics are not enough

Atomics are the right tool when you need to share a single value between threads and the updates are simple (increment, swap, compare-and-swap). They are the wrong tool when you need to keep multiple related values consistent together. Making each individual field of a struct atomic does not make operations on the struct atomic as a whole — there is no way to atomically update x and y of a point simultaneously with std::atomic. For compound state, you need a mutex. As a rule: if you can express the operation as a single load, store, exchange, or CAS, use an atomic. If you need a transaction over several variables, use a mutex.

One variable, simple operations — use atomic

Counter, flag, index, generation counter. fetch_add / exchange / compare_exchange cover most cases.

Multiple related variables — use mutex

A struct with x, y, z that must stay consistent; a map plus its size; any invariant spanning two fields.

Check is_always_lock_free before trusting performance

atomic<large_struct> is probably using a mutex internally. atomic_flag is the only guaranteed lock-free type.

Start with sequential consistency (the default)

Relaxed memory orderings are subtle and error-prone. Only reach for them with a profiler and expert review.

Sign in to track progress