CodeGym /Courses /C++ SELF /Operations std::atomic: load/ store/ exchange/ fetch_*

Operations std::atomic: load/ store/ exchange/ fetch_*

C++ SELF
Level 72, Lesson 1
Available

1. Why you need a “dictionary of operations” for std::atomic

If you open the reference for <atomic>, you can quickly get the feeling that you accidentally enrolled in a faculty of spellcasting: fetch_add, exchange, compare_exchange_weak, some “expected”, some “spurious”… And that's fine. In this lecture we will do exactly one thing: build a clear map of operations and learn how to choose them by intent, without turning code into a magical ritual.

To keep examples grounded, we will continue our small teaching project JobRunner: a console application that runs several worker threads and processes “tasks”. The task queue and waiting can already be implemented with std::mutex + std::condition_variable (that is simpler and more honest for queues), but statistics and small state flags we will implement with atomics today.

load() and store() — atomic read and write

When you first see std::atomic<int>, the brain tries to simplify: “Well, it's just an int, only honest.” Alas (or fortunately), that's almost true: an atomic is a variable whose reads and writes are synchronized between threads and do not create a data race. So the basic operations are read (load) and write (store). They look boring, but the rest of the construct rests on them.

Example: atomic "stopping" flag

Imagine that our JobRunner runs tasks in several threads, and the main thread at some point says “stop”. The simplest option is an atomic flag.

#include <atomic>

int main() {
    std::atomic<bool> stop{false};

    stop.store(true);                 // stored "stopping"
    bool need_stop = stop.load();     // read
    (void)need_stop;
}

The main idea: store is an atomic write, load is an atomic read. That means two threads can safely do load() in parallel, and one thread can do store() while another does load(). For a plain bool that would be a data race.

Nuance: assignment and implicit conveniences

std::atomic<bool> and std::atomic<int> have operators that look like ordinary ones:

#include <atomic>

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

    x = 10;          // basically store(10)
    int v = x;       // basically load()
    (void)v;
}

In tutorial code that's fine, but in practice many prefer load()/store(), because it clearly shows: “this is an atomic, I'm doing an atomic operation here.” In multithreaded code explicitness is not pedantry, it's insomnia prevention.

2. RMW operations: fetch_add and friends

After load/store the next important class of operations is read-modify-write (RMW): “read → modify → write” as a single atomic operation. This is the key thing: you cannot compose atomicity from two atomic operations manually if another thread can intervene between them.

Anti-example: increment via load + store

Here is code that looks logical but often breaks counting in multithreading:

#include <atomic>

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

    int old = counter.load();
    counter.store(old + 1); // another thread could also increment between load and store
}

The problem is that another thread could do the same between your load() and store(). As a result one of the increments is lost. This is called “lost update”, and it's a classic mistake.

Correct variant: fetch_add

fetch_add(1) atomically does “add 1”. And an important bonus: it returns the old value.

#include <atomic>
#include <iostream>

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

    int before = processed.fetch_add(1);
    std::cout << before << '\n';           // 0 (if this was the first increment)
    std::cout << processed.load() << '\n'; // 1
}

If you need to “take a task number”, a “ticket”, or “get a unique id”, fetch_add is one of the simplest tools.

Which fetch_* exist and what they do

The fetch_* family depends on the type T. For integers typically available:

Operation Meaning Returns
fetch_add(v)
add v old value
fetch_sub(v)
subtract v old value
fetch_and(mask)
bitwise AND old value
fetch_or(mask)
bitwise OR old value
fetch_xor(mask)
bitwise XOR old value

Editorial notes to the draft standard point out that fetch_* operations sit close to compare_exchange, because they can be viewed as a special case of conditional state update. This is a good mental model: fetch_add is like a CAS that “always tries to add until it succeeds”.

Mini integration into our JobRunner: stats without a mutex

Suppose each worker increments a processed counter after handling a task, and on error increments failed.

#include <atomic>

struct Stats {
    std::atomic<int> processed{0};
    std::atomic<int> failed{0};
};

void on_task_done(Stats& s, bool ok) {
    s.processed.fetch_add(1);
    if (!ok) {
        s.failed.fetch_add(1);
    }
}

This is a typical correct scenario for atomics: independent counters, no complex invariants “between them”, just statistics.

4. exchange() — atomic replace with return of old value

Sometimes you want not to “increment”, but to “replace the value” so that nobody slips in between the read and the write, and at the same time you want to know what was before the replace. For that there is exchange(new_value): it atomically writes new_value and returns the old value.

Example: “who enabled the mode first”

Suppose in JobRunner there's a “verbose logging” mode, and we want the message "Verbose enabled" to be printed exactly once, even if several threads try to “enable” it.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<bool> verbose{false};

    bool was = verbose.exchange(true);
    if (!was) {
        std::cout << "Verbose enabled\n"; // Verbose enabled
    }
}

If was was false, you are the first to set true. If was was already true, someone enabled it earlier.

Why this is better than if (!load()) store(true)

Because code like this is not atomic as a “check-and-set”:

#include <atomic>

void enable(std::atomic<bool>& flag) {
    if (!flag.load()) {   // another thread could change it between the lines
        flag.store(true);
    }
}

If two threads see false simultaneously, both will do store(true) and both will think they are “first”. exchange solves this neatly and succinctly.

5. CAS: compare_exchange_* — conditional update

If fetch_add is a convenient “+1” button, then CAS (compare-and-swap) is a universal building block. It allows you to tell the atomic: “Update the value only if it is currently equal to what I expect.” This is needed for concurrent updates where we don't want to lose another thread's changes.

Basic idea

CAS is an operation of the form:

  • we have an atomic x
  • we have a variable expected (the expected value)
  • we have desired (the value we want to set)

Then CAS performs roughly: “if x == expected, then x = desired and success; otherwise failure”.

Crucial nuance: expected is both input and output

The parameter expected is passed by reference. And that's not a quirk: on failure CAS writes into expected the actual current value of the atomic. This is very convenient for loops.

#include <atomic>
#include <iostream>

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

    int expected = 0;
    if (state.compare_exchange_strong(expected, 1)) {
        std::cout << "changed\n";           // changed
    } else {
        std::cout << "not changed, actual=" << expected << '\n';
    }
}

If CAS failed, expected will contain “what was actually in state”, and you can decide: retry, exit, or choose a different desired.

compare_exchange_strong vs compare_exchange_weak

There are two versions:

  • compare_exchange_strong — “strong”: if it fails, it is almost always because the value truly didn't match.
  • compare_exchange_weak — “weak”: may sometimes fail even if the value matched (a so-called spurious failure).

That sounds like sabotage, but on some architectures “weak” can be cheaper and map better to hardware. A practical rule in C++: weak is usually used in a loop, and that's fine.

Typical CAS loop: increment manually

Let's show increment via CAS. In reality people usually use fetch_add, but for learning CAS is an excellent example.

#include <atomic>
#include <iostream>

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

    int old = x.load();
    while (!x.compare_exchange_weak(old, old + 1)) {
        // if CAS failed, old is already updated to the current x
    }

    std::cout << x.load() << '\n'; // 1
}

Note the psychologically odd point: the variable old in the loop “changes by itself” on CAS failure — because that's the contract of expected.

CAS as a state machine: state transition

In JobRunner you can have an execution state: for example, 0 — “Idle”, 1 — “Running”, 2 — “Stopping”. And we want to allow the transition “Idle → Running” to only one thread.

#include <atomic>

bool try_start(std::atomic<int>& st) {
    int expected = 0;                      // waiting for Idle
    return st.compare_exchange_strong(expected, 1); // set Running
}

If two threads call try_start at the same time, only one will get true. The second will get false, and its expected will hold the current state (most likely 1).

CAS pattern: atomic max

Another classic example: “remember the maximum from many threads”. There is no fetch_max in the standard, but you can build it via CAS.

#include <atomic>

void update_max(std::atomic<int>& m, int value) {
    int cur = m.load();
    while (cur < value && !m.compare_exchange_weak(cur, value)) {
        // if it failed, cur will be updated with the current value of m
    }
}

The important logic here: if cur is already >= value, we exit immediately. Otherwise we try to set value. If another thread raised the maximum, CAS fails, cur is updated, and we check the condition again.

You can mentally picture this as a small flowchart:

flowchart TD
    A[load cur] --> B{cur < value?}
    B -- no --> E[exit]
    B -- yes --> C{"CAS(cur -> value) successful?"}
    C -- yes --> E
    C -- no --> A

Nuance: atomic<float> and comparisons

For integers compare_exchange is usually intuitive: you compared numbers — matched/didn't match. But with float/double there are nuances like NaN and subtle representation differences (e.g., -0.0 and 0.0). Editorial notes to the standard specifically mention that there were questions and clarifications about compare_exchange for floating-point because of such cases.

A practical recommendation for a beginner: if you really need lock-free updates of a double, first design very clearly what “equality” means in your protocol. Often it's simpler to store an integer in “milli-units”, or to protect everything with a mutex.

How to choose the right operation and avoid a CAS cult

After getting to know compare_exchange many start a romance with CAS: “Wow, so you can do everything without mutexes!” At this point it's useful to take a breath and remember that mutexes were invented by engineers tired of fixing nerves. Atomics are good when you really work with a single variable or a very simple protocol.

If you just need to count events, fetch_add gives concise and clear code. If you need to “enable once”, exchange is usually more readable than CAS. If you need a conditional state update or to implement an atomic max, then CAS is the right tool. But if your invariant is “two fields must change consistently” (for example, count and sum, or a queue plus indices), then at the basic level std::mutex often wins, because it protects a critical section, not a single variable.

One more practical rule: if your CAS loop has grown large, contains complex computations, container accesses, function calls with side effects — most likely you are trying to do with atomics what is simpler and safer to express with a lock.

6. Common mistakes when working with load/store/exchange/fetch_*

Mistake #1: increment via store(load()+1) and lost updates.
This is one of the most frequent logical traps: both calls are atomic, but together they do not form an atomic “increment”. Between load() and store() another thread can change the value, and you will overwrite its work. If you need a counter — use fetch_add or a CAS loop.

Mistake #2: forgetting to initialize expected before compare_exchange_*.
CAS compares the current value with what you passed in expected. If expected is not set, you compare the atomic with garbage, and then wonder why it “never succeeds”. expected should always start with a meaningful value: “what I expect to see”.

Mistake #3: not understanding that expected changes on CAS failure.
Many beginners write CAS and then in the else branch assume that expected is still the old expected value. In fact, on failure expected is overwritten with the current value of the atomic. This is not a bug but a feature: it's exactly how CAS loops are conveniently built.

Mistake #4: using compare_exchange_weak without a loop.
weak can fail “just like that”, even if the value matched. Therefore a single call to compare_exchange_weak can sometimes create extremely strange, rare, “non-reproducible” bugs. If you use weak, almost always do a loop. If you don't need a loop and want simpler semantics — use strong.

Mistake #5: doing heavy work inside a CAS loop.
A CAS loop can repeat many times under contention. If you print to console inside the loop, access containers, call heavy functions or, God forbid, sleep — you can get catastrophic performance degradation and very hard-to-explain behavior. Keep only short math and the compare_exchange inside a CAS loop.

1
Task
C++ SELF, level 72, lesson 1
Locked
Atomic Capsule
Atomic Capsule
1
Task
C++ SELF, level 72, lesson 1
Locked
Mode switch
Mode switch
1
Task
C++ SELF, level 72, lesson 1
Locked
Ticket queue
Ticket queue
1
Task
C++ SELF, level 72, lesson 1
Locked
Height record
Height record
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION