The memory model

An atomic operation is indivisible, and that says nothing about the other variables around it. The memory model is the second half of the promise: which writes one thread makes are visible to another, and in what order.

The rule is called happens-before. A release store and an acquire load of the same atomic create it: everything the first thread wrote before the release is visible to the second after the acquire. Lesson 108 used the default order; this lesson chooses.

The default order

Every atomic operation has a memory order argument, and the default is std::memory_order_seq_cst: sequentially consistent, the strongest and the easiest to reason about. It behaves as if all threads agreed on one order of all the atomic operations.

The program counts with the default order and prints 200000. The counter needs nothing stronger than atomicity, but the default is what an atomic gives when the argument is left out.

The stronger order costs a little on some processors and nothing on x86 for a store. Starting with the default and relaxing it after a measurement is the right direction; the other one, adding order after a bug, is guesswork.

#include <atomic> #include <iostream> #include <thread> std::atomic<int> counter{0}; int main() { std::thread a{[] { for (int i = 0; i < 100000; ++i) { counter.fetch_add(1); } }}; std::thread b{[] { for (int i = 0; i < 100000; ++i) { counter.fetch_add(1); } }}; a.join(); b.join(); std::cout << counter.load() << '\n'; }

g++ -std=c++23 -Wall -Wextra -pthread default.cpp -o default_order
./default_order

200000

Relaxed is enough for a counter

std::memory_order_relaxed keeps the atomicity and drops the ordering: the operations on this one object still happen in some order that all threads agree on, and no promise is made about the other variables.

The four threads add 10000 each with the relaxed order and the result is 40000. Nothing else is shared, so there is nothing for the missing ordering to break.

This is the case the relaxed order exists for: a statistic, a ticket counter, an index. The moment the count decides whether some other data is ready, the order has to come back, which is the next section.

#include <atomic> #include <iostream> #include <thread> #include <vector> std::atomic<int> counter{0}; int main() { std::vector<std::thread> threads; for (int i = 0; i < 4; ++i) { threads.emplace_back([] { for (int j = 0; j < 10000; ++j) { counter.fetch_add(1, std::memory_order_relaxed); } }); } for (auto& thread : threads) { thread.join(); } std::cout << counter.load(std::memory_order_relaxed) << '\n'; }

g++ -std=c++23 -Wall -Wextra -pthread relaxed.cpp -o relaxed
./relaxed

40000

A release store publishes the data

The producer writes a string and then stores the flag with memory_order_release. The release guarantees that the write to the string is visible to any thread that reads the flag with memory_order_acquire and sees true.

The consumer spins on the acquire load and prints the string. The two orders form a pair on the same atomic, and the payload write cannot be reordered after the release or the read before the acquire.

With a relaxed store and a relaxed load the program would still compile and usually print the right string, which is what makes this class of bug expensive. The order is a statement about other variables, and the compiler and the CPU are allowed to move them without it.

#include <atomic> #include <iostream> #include <string> #include <thread> std::string payload; std::atomic<bool> ready{false}; int main() { std::thread producer{[] { payload = "forty-two"; ready.store(true, std::memory_order_release); }}; while (!ready.load(std::memory_order_acquire)) { } std::cout << payload << '\n'; producer.join(); }

g++ -std=c++23 -Wall -Wextra -pthread publish.cpp -o publish
./publish

forty-two

acq_rel on a reference count

Some operations are both a read and a write, and std::memory_order_acq_rel is the order for them. The fetch_sub of a reference count uses it: the decrement must see everything the other owners did and must publish its own work to the one that frees the object.

Two threads decrement a count that starts at 2. Exactly one of them sees the old value 1 and prints the message, and that thread is the one that destroys the object. The program prints the line once.

This is the pattern inside std::shared_ptr of lesson 092, where the count is atomic and the last owner runs the destructor. A hand-written version has to get the orders right, which is why the standard one is worth using.

#include <atomic> #include <iostream> #include <thread> std::atomic<int> owners{2}; void release() { if (owners.fetch_sub(1, std::memory_order_acq_rel) == 1) { std::cout << "destroyed\n"; } } int main() { std::thread a{release}; std::thread b{release}; a.join(); b.join(); std::cout << owners.load() << '\n'; }

g++ -std=c++23 -Wall -Wextra -pthread refcount.cpp -o refcount
./refcount

destroyed
0

A plain int has no load

The memory order is an argument of an atomic operation. A plain int has no such operations, so calling load on one is a compile error, and the message says the member does not exist on the type.

The program declares an ordinary int and calls load with the acquire order. The compiler answers that the request for the member is invalid because the type is not a class, and the note points at the declaration.

The fix is to declare the variable as std::atomic<int>, or to read the plain int directly when no other thread writes it. Adding an order to a variable that no one shares is the sign that the atomic is missing, not the order.

int main() { int value = 0; return value.load(std::memory_order_acquire); }

g++ -std=c++23 -Wall -Wextra plain.cpp -o plain

plain.cpp: In function 'int main()':
plain.cpp:5:18: error: request for member 'load' in 'value', which is of non-class type 'int'
5 | return value.load(std::memory_order_acquire);
| ^~~~
plain.cpp:5:28: error: 'memory_order_acquire' is not a member of 'std'
5 | return value.load(std::memory_order_acquire);
| ^~~~~~~~~~~~~~~~~~~~

Exercises

  1. Change the relaxed counter to seq_cst and compare the output; explain why it does not change.
  2. Add a second payload to the publish program and check that both are visible after the acquire.
  3. Write a reference count that starts at 1 and is released by a single thread, and check the message.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does happens-before mean?

    Answer

    That the writes before the first operation are visible to the code after the second

    It is a visibility and ordering guarantee between two operations, usually a release and an acquire.

  2. What does memory_order_relaxed guarantee?

    Answer

    Atomicity and one agreed order for that object, with no ordering of other variables

    The object is still updated indivisibly; the order only concerns that object, not the data around it.

  3. What does a release store do?

    Answer

    It makes the writes before it visible to a thread that acquires the same atomic

    The release publishes the earlier writes to whoever performs the matching acquire.

  4. What does an acquire load prevent?

    Answer

    Later reads and writes from moving before it

    The acquire is a barrier for the operations after it in program order.

  5. What does this program print? #include <atomic> #include <iostream> #include <thread> std::atomic<int> counter{0}; int main() { auto work = []{ for (int i = 0; i < 10000; ++i) counter.fetch_add(1, std::memory_order_relaxed); }; std::thread a(work), b(work), c(work), d(work); a.join(); b.join(); c.join(); d.join(); std::cout << counter.load(std::memory_order_relaxed); }

    Answer

    40000

    The relaxed order keeps atomicity, so all 40000 increments are counted.

  6. What does this program print? #include <atomic> #include <iostream> #include <thread> std::string payload; std::atomic<bool> ready{false}; int main() { std::thread producer([]{ payload = "forty-two"; ready.store(true, std::memory_order_release); }); while (!ready.load(std::memory_order_acquire)) {} std::cout << payload; producer.join(); }

    Answer

    forty-two

    The acquire load sees the release store and with it the write to the string.

  7. Which memory order is the default for an atomic operation?

    Answer

    std::memory_order_seq_cst

    Leaving the argument out gives the sequentially consistent order, the strongest one.

  8. What does this program print? #include <atomic> #include <iostream> #include <thread> std::atomic<int> owners{2}; void release() { if (owners.fetch_sub(1, std::memory_order_acq_rel) == 1) std::cout << "destroyed\n"; } int main() { std::thread a(release), b(release); a.join(); b.join(); std::cout << owners.load(); }

    Answer

    destroyed / 0

    Exactly one thread decrements to zero and prints, and the final count is 0.

  9. Why is the relaxed order enough for a counter that nothing else depends on?

    Answer

    Because only the counter's own value matters, and that value is still atomic

    The order concerns other variables; a lone statistic has no other variable to publish.

  10. What does the compiler answer to int value = 0; value.load(std::memory_order_acquire);?

    Answer

    That the member load is requested on a type that is not a class

    load is a member of the atomic template, not of int, so the expression is invalid.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container. The programs whose section shows a compiler error were compiled until that error and were not run.

Article author: Arthur Isaev