Atomics and the Memory Model

Atomic Variables

A data race occurs if two or more threads read/write to the same memory location and at least one of the accesses is a write and neither of the accesses is synchronized with the other. This causes undefined behavior. C++ provides the template std::atomic in order to perform operations on a single object without data races with respect to that specific object.

An atomic object is neither copyable nor movable, its copy and move operations are deleted. The type used with std::atomic must be TriviallyCopyable. The most used specializations are the ones for the integral types.

There are two fundamental operations: load and store.

#include <atomic>
#include <iostream>

int main()
{
    // Use atomic storage for concurrent access to this value
    std::atomic<int> value{42};
    int x = value.load();
    value.store(100);
    std::cout << x << ' ' << value.load() << '\n';
    return 0;
}

The object value is constructed with the initial value 42. Calling value.load() atomically reads the current value and returns it; the returned integer is stored in a regular variable x. Calling value.store(100) atomically replaces the current contents of value by 100.

The final insertion prints the former and the current value.

Both operations are free of data races on value itself.

For integral atomic types there is also a family of read modify write operations. The member function fetch_add(int n) adds n to the value of the atomic object and returns the previous value.

#include <atomic>
#include <iostream>

std::atomic<unsigned> next_id{1};

unsigned allocate_id()
{
    return next_id.fetch_add(1);
}

int main()
{
    std::cout << allocate_id() << '\n';
    std::cout << allocate_id() << '\n';
    return 0;
}

The atomic variable next_id is initialized to 1. Every time the function allocate_id is called, fetch_add(1) is executed. On the first call, it reads 1, and writes 2, on the second call, it reads 2, and writes 3. As the addition is done atomically, a call to allocate_id by two concurrent threads never leads to two threads getting the same id.

A full program which increments a counter in several threads appears below.

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

int main()
{
    std::atomic<int> counter{0};

    // Capture this value by reference in the lambda
    auto worker = [&counter]()
    {
        for (int i = 0; i < 100000; ++i)
            counter.fetch_add(1);
    };

    {
        std::vector<std::jthread> threads;

        for (int i = 0; i < 4; ++i)
            threads.emplace_back(worker);
    }

    std::cout << counter.load() << '\n';
    return 0;
}

Four threads each run the lambda worker function. One hundred thousand times on each of these threads, a counter.fetch_add(1) is executed. After all threads finish, counter.load() returns the total sum, which is precisely 400,000, no increments are lost, as all operations modifying it are atomic.

It can be tested at compile time or runtime whether a specific atomic type is lock free.

#include <atomic>
#include <iostream>

int main()
{
    std::cout << std::atomic<int>::is_always_lock_free << '\n';

    std::atomic<int> counter{0};
    std::cout << counter.is_lock_free() << '\n';

    return 0;
}

The static member function is_always_lock_free tests whether all specializations of the atomic type are lock free for the current platform. The regular member function is_lock_free is called on a specific object and indicates if the atomic object at this particular point is lock free, meaning no blocking by the OS through mutexes, as it performs the operation purely with hardware instructions.

Atomic operations only guarantee atomicity of the operation on that specific object. If an invariant applies to multiple objects, it cannot be enforced by having several atomic variables.

#include <atomic>
#include <iostream>

// Define the members that belong to this type
struct Point
{
    std::atomic<int> x{0};
    std::atomic<int> y{0};
};

int main()
{
    Point p;
    p.x.store(10);
    p.y.store(20);
    std::cout << p.x.load() << ' ' << p.y.load() << '\n';
    return 0;
}

Each store is atomic with respect to its own variable. But nothing ensures that another thread reading one pair will never read (10, 0) or (0, 20). No snapshot of the pair of variables is guaranteed ever to exist. For groups of values that are to be accessed coherently, one must use a mutex or other high level synchronization mechanisms.

The lifetime of an atomic object must exceed the lifetime of any thread that may access it. No construction nor destruction is atomic: the object must be fully constructed when other threads start using it, and all concurrent uses must have been terminated when it is destroyed.

Therefore, atomic variables are not a substitute for coarser synchronization primitives when invariants spanning more than one memory location are relevant. They are intended only for updating simple, discrete state.

0 comments

No comments yet. Be the first.

Add a comment

About CppLearn

CppLearn is an interactive, free reference for modern C++. In each lesson, you'll find out how the language works, rather than just lines of code that you can copy and paste. We cover concepts starting from beginner lessons on fundamentals and proceed to templates, move semantics, smart pointers, and areas that usually trap or scare C++ learners.