Atomics and Lock Free Operations
Advanced Concurrency, SIMD, and Performance

9.5 Atomics and Lock Free Operations

Mutexes are a good default for protecting shared invariants. An uncontended lock is often implemented entirely in user space; contention may block a thread and involve the operating system scheduler. Measure the contention pattern before treating a mutex as the bottleneck.

Atomics are useful when one small, well-defined state can be synchronized without a mutex. They do not automatically make a design faster, and not every atomic type or operation is lock-free, check is_lock_free() when that property matters.

The Cost of Mutex Context Switches

A context switch can disturb locality because the incoming work uses cache capacity and translation entries; processors do not simply clear all cache lines belonging to a thread. The cost depends on the OS, CPU, working sets, and scheduling behavior.

Atomics: Hardware Level Thread Safety

Modern CPU architectures (like x86_64 and ARM) support atomic operations directly at the silicon level using hardware instructions (such as Compare And Swap, or CAS). C++ exposes this hardware capability using std::atomic (from the <atomic> header).

An atomic operation is indivisible with respect to the C++ memory model. An implementation may use one instruction, a loop of compare-and-exchange instructions, or an internal lock depending on the operation and target.

Hardware Instruction Reordering

Compilers and CPUs may reorder independent memory operations when the language and hardware memory models permit it. Without synchronization, Thread B might observe writes from Thread A in an order you did not intend.

Memory Orderings

By default, std::atomic uses std::memory_order_seq_cst, the strongest and easiest ordering to reason about. Its generated instructions and cost are architecture- and operation-dependent; do not reduce ordering merely to chase a benchmark.

std::memory_order_relaxed preserves atomicity but adds no synchronization relationship for surrounding data. The next step is acquire/release: publish data with a release store and consume it with an acquire load. Use weaker orderings only with a written invariant and a test that exercises the protocol.

#include <iostream>
#include <thread>
#include <atomic>

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

void incrementAtomic() {
    for (int i{0}; i < 1000; ++i) {
        ++atomicCounter;
    }
}

int main() {
    std::thread t1{incrementAtomic};
    std::thread t2{incrementAtomic};
    t1.join();
    t2.join();
    std::cout << "Atomic Counter: " << atomicCounter << '\n'; // Guaranteed 2000
    return 0;
}
A correct atomic counter; measure whether it is appropriate for the workload.

Acquire and release: publishing data between threads

A release store on an atomic flag ensures prior ordinary writes are visible to another thread that reads the flag with acquire. This is the standard one-producer pattern before reaching for a mutex on a single flag.

#include <atomic>
#include <thread>
#include <iostream>

std::atomic<bool> ready{false};
int payload{0};

void producer() {
    payload = 42;
    ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
        // spin or wait on a condition variable in production code
    }
    std::cout << payload << '\n'; // Guaranteed to see 42
}

int main() {
    std::thread t1{producer};
    std::thread t2{consumer};
    t1.join();
    t2.join();
    return 0;
}
Publish an int with release/acquire ordering.

Memory Barriers and Cache Sync

Atomics define visibility and ordering guarantees for participating threads; cache coherence is a hardware mechanism underneath them, not a cache flush performed by every atomic write. They also do not make unrelated non-atomic accesses safe: a data race remains undefined behavior.

Finished reading this lesson?