atomic<>: The C++ Memory Model#
Foreword#
A memory model is an attept to find a common denominator across atomicity and memory order features of different CPUs
enum memory_order
{
memory_order_relaxed,
memory_order_consume,
memory_order_acquire,
memory_order_release,
memory_order_acq_rel,
memory_order_seq_cst
};
Integer Increments (Single Threaded)#
#include <array>
#include <print>
int main()
{
const unsigned long numtimes = 1000*1000*1000UL;
unsigned long counter = 0;
auto inc = [&counter]() {
unsigned long n = numtimes;
while (n--)
counter++;
};
inc();
std::println("expected {}, real {}", 1*numtimes, counter);
return 0;
}
$ ./c++11-memory-model-load-modify-store-single
expected 1000000000, real 1000000000
Integer Increments?#
counter++is not atomicBreaks down into multiple instructions
⟶ Load/Modify/Store (Read/Modify/Write)
Source code |
What becomes of it |
counter++;
|
auto reg = counter; // <-- load into register
reg++; // <-- increment register
counter = reg; // <-- write back to memory
|
Integer Increment: The Mother Of Race Conditions#
#include <thread>
#include <array>
#include <print>
int main()
{
const unsigned long numtimes = 1000*1000*1000UL;
unsigned long counter = 0;
auto inc = [&counter]() {
unsigned long n = numtimes;
while (n--)
counter++;
};
std::array threads{
std::thread(inc),
std::thread(inc),
};
for (auto& t: threads)
t.join();
std::println("expected {}, real {}", threads.size()*numtimes, counter);
return 0;
}
$ ./c++11-memory-model-load-modify-store
expected 2000000000, real 1004072808
Load/Modify/Store Conflict#
Physical parallelism: 2 CPUs
Virtual parallelism: single CPU + scheduler context switch
⟶ extremely rare
⟶ extremely nasty
CPU1 |
CPU2 |
auto reg = counter;
reg++;
.
.
.
counter = reg;
|
.
.
auto reg = counter;
reg++;
counter = reg;
.
|
Anti-Conflict: Mutex#
Downside: context switch on contention ⟶ try to minimize contention
Upside: always safe - “If in doubt use a mutex!”*
#include <thread>
#include <mutex>
#include <array>
#include <print>
int main()
{
const unsigned long numtimes = 1000*1000*1000UL;
unsigned long counter = 0;
std::mutex lock; // <-- used to protect unsafe operator++
auto inc = [&counter, &lock]() {
unsigned long n = numtimes;
while (n--) {
lock.lock(); // <-- "acquire"
counter++;
lock.unlock(); // <-- "release"
}
};
std::array threads{
std::thread(inc),
std::thread(inc),
};
for (auto& t: threads)
t.join();
std::println("expected {}, real {}", threads.size()*numtimes, counter);
return 0;
}
Anti-Conflict: std::atomic<>#
Upside: all kinds of fast atomicity on flags, counters, …
Downside: only fast for certain kinds of data types, under certain circumstances. If not fast ⟶ internally, a mutex is used to implement atomicity
#include <atomic>
#include <thread>
#include <array>
#include <print>
int main()
{
const unsigned long numtimes = 1000*1000*1000UL;
std::atomic<unsigned long> counter = 0; // <-- operator++ is atomic
auto inc = [&counter]() {
unsigned long n = numtimes;
while (n--)
counter++;
};
std::array threads{
std::thread(inc),
std::thread(inc),
};
for (auto& t: threads)
t.join();
std::println("expected {}, real {}", threads.size()*numtimes,
static_cast<unsigned long>(counter)); // <-- operator unsigned long
return 0;
}
Sequential Consistency#
Note
Guarantee given
A READ from a location must return the value of the last WRITE to that location
Possible implementation: flush all memory caches through to main memory (read on)
There are sure better implementations
Not free though
Is std::atomic<> Lock Free?#
Standard says: only
std::atomic_flagis guaranteed to be lock free (see std::atomic_flag)Other
std::atomic<>specialization could be (tested onx86_64)
#include <atomic>
#include <print>
struct foo
{
int a;
int b;
int c;
int d;
int e;
int f;
};
int main()
{
std::atomic<int> i;
std::println("int: {}", i.is_lock_free()); // <-- true
std::atomic<int*> pi;
std::println("int*: {}", pi.is_lock_free()); // <-- true
std::atomic<foo> f;
std::println("foo: {}", f.is_lock_free()); // <-- false
return 0;
}
Modern CPUs Are More Complex#
Reads are fast
Writes can take
⟶ Store buffer
⟶ Cache hierarchies
Towards Out-Of-Order Memory Access: What Matters? (Cache/Memory)#
Full detail |
Cache And Memory |
Cache And Memory Is Transparent |
And Store Buffer? |
Towards Out-Of-Order Memory Access: What Matters? (CPU/Store Buffer)#
Store buffer is where reordering is done
Cache and memory just works
Publishing Something#
#include <thread>
#include <print>
struct data
{
int foo;
int bar;
};
data* shared_data = nullptr;
int main()
{
// **produce**
data* local_data = new data; // <-- write #1
local_data->foo = 42; // <-- write #2
local_data->bar = 666; // <-- write #3
// **publish: shared_data valid from here on**
shared_data = local_data; // <-- write #4
// **consume**
std::println("foo {}, bar {}", shared_data->foo, shared_data->bar);
return 0;
}
Store Dependencies#
A reordering engine (Intel conservative, ARM/RISC-V aggressive) can consider the following store dependencies.
![]() local_data = new data; // <-- write #1 (stack)
local_data->foo = 42; // <-- write #2 (heap)
local_data->bar = 666; // <-- write #3 (heap)
shared_data = local_data; // <-- write #4 (global/publish)
|
Store Reordering#
3!==6possible store orderings; for example …Linear, as in the code
local_data = new data; local_data->foo = 42; local_data->bar = 666; shared_data = local_data; // <-- publish
Another legal order. For some reason: “Stores to global memory currently preferred because other stores to the same cache line are already waiting”,
local_data = new data; shared_data = local_data; // <-- publish local_data->foo = 42; local_data->bar = 666;
Store Reordering On Multicore#
Single: not a problem at all
⟶ One core will see the effect of its own writes immediately
But multi …
#include <thread>
#include <print>
struct data
{
int foo;
int bar;
};
data* shared_data;
int main()
{
std::thread announcer([](){
sleep(2);
data* local_data = new data; // <-- write #1
local_data->foo = 42; // <-- write #2
local_data->bar = 666; // <-- write #3
shared_data = local_data; // <-- write #4
});
while (true)
if (shared_data) {
std::println("foo {}, bar {}", shared_data->foo, shared_data->bar);
break;
}
announcer.join();
delete shared_data;
return 0;
}
$ ./c++11-memory-model-publish-multi-unsafe
foo 42, bar 666 # <-- hopefully
And Volatile?#
volatile data* shared_data;
Prevents the compiler from reordering across any access to
shared_dataHowever in the single threaded case this is irrelevent
As-if rule: compiler must not reorder in a way that could change the observed behavior (see here
⟶
volatileonly enforces this once more
CPU reads what it last wrote
Ideally, recently written data is taken from the cache closest to the CPU
Maybe not even out in main memory yet
⟶ only breaks performance
Multicore
Not that we are discussing hardware based (CPU side) reordering
Compiler does similar things, at a different stage
volatileis long gone⟶ does not help at this point either
Note
Any use of volatile (other than to keep the compiler away from
device registers) is pointless at most.
Anti-Reordering: Mutex#
Remember: “If in doubt use a mutex!”
Important: delineation if critical section
#include <thread>
#include <mutex>
#include <print>
struct data
{
int foo;
int bar;
};
data* shared_data;
std::mutex shared_data_lock;
int main()
{
std::thread announcer([](){
sleep(2);
{
std::scoped_lock _(shared_data_lock); // <-- protect all and more
data* local_data = new data;
local_data->foo = 42;
local_data->bar = 666;
shared_data = local_data;
}
});
while (true)
{
std::scoped_lock _(shared_data_lock); // <-- protect all and more
if (shared_data) {
std::println("foo {}, bar {}", shared_data->foo, shared_data->bar);
break;
}
}
announcer.join();
delete shared_data;
return 0;
}
Anti-Reordering: Delineating Critical Section#
Actual critical section is much smaller
In effect: load and store of
shared_data⟶ “The act of publication”
#include <thread>
#include <mutex>
#include <print>
struct data
{
int foo;
int bar;
};
data* shared_data;
std::mutex shared_data_lock;
int main()
{
std::thread announcer([](){
sleep(2);
data* local_data = new data;
local_data->foo = 42;
local_data->bar = 666;
{
std::scoped_lock _(shared_data_lock); // <-- protect store
shared_data = local_data;
}
});
while (true)
{
data* local_data;
{
std::scoped_lock _(shared_data_lock); // <-- protect load
local_data = shared_data;
}
if (local_data) {
std::println("foo {}, bar {}", local_data->foo, local_data->bar);
break;
}
}
announcer.join();
delete shared_data;
return 0;
}
Why Does This Work?#
We use a mutex to linearize access to
shared_dataActually relying on something unobvious: the mutex’s flag
⟶ Barriers
Sequential Consistency Without A Mutex#
Observation
We are protecting
data*with aboolflag (the mutex?)⟶ Why not wrap
data*instd::atomic<data*>?⟶ load and store atomic by definition
shared_data = local_data;
shared_data.store(local_data, std::memory_order_seq_cst); // <-- same
local_data = shared_data;
local_data = shared_data.load(std::memory_order_seq_cst); // <-- same
#include <thread>
#include <atomic> // <--
#include <print>
struct data
{
int foo;
int bar;
};
std::atomic<data*> shared_data; // <-- data type: std::atomic<>
int main()
{
std::thread announcer([](){
sleep(2);
data* local_data = new data;
local_data->foo = 42;
local_data->bar = 666;
shared_data.store(
local_data,
std::memory_order_seq_cst); // <-- much like i++
});
while (true)
{
data* local_data = shared_data.load(
std::memory_order_seq_cst); // <-- much like i++
if (local_data) {
std::println("foo {}, bar {}", local_data->foo, local_data->bar);
break;
}
}
announcer.join();
delete shared_data;
return 0;
}
Isn’t Sequential Consistency Too Strong For Publish?#
Observation
Publisher: unlike
std::atomic<int>::operator++(), we only store ⟶ let-goConsumer: unlike “A READ from a location must return the value of the last WRITE to that location”, we only want
Once shared_data is valid, I want all stores preceding that (
fooandbar) to be valid⟶ Memory orders: release and acquire (here)
#include <thread>
#include <atomic>
#include <print>
struct data
{
int foo;
int bar;
};
std::atomic<data*> shared_data;
int main()
{
std::thread announcer([](){
sleep(2);
data* local_data = new data;
local_data->foo = 42;
local_data->bar = 666;
shared_data.store(
local_data,
std::memory_order_release); // <-- let-go
});
while (true)
{
data* local_data = shared_data.load(
std::memory_order_acquire); // <-- all there?
if (local_data) {
std::println("foo {}, bar {}", local_data->foo, local_data->bar);
break;
}
}
announcer.join();
delete shared_data;
return 0;
}
Links#
C++ and Beyond 2012: Herb Sutter - atomic Weapons 1 of 2
A Relaxed Guide to memory_order_relaxed - Paul E. McKenney & Hans Boehm - CppCon 2020
Lightning Talk: A Spinlock Implementation - Fedor Pikus - CppNow 2022
CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”
![digraph G {
w_local_data[label="local_data = \nnew data;"]
w_local_data_foo[label="...->foo = 42;"]
w_local_data_bar[label="...->bar = 666;"]
w_shared_data[label="shared_data =\nlocal_data;", style=filled, fillcolor="#69db7c"]
w_shared_data -> w_local_data;
w_local_data_foo -> w_local_data;
w_local_data_bar -> w_local_data;
}](../../../../../../../_images/graphviz-1d1e9d8c54868bc60c63b1cc45ad5fb8f6745af0.png)