arthrop0d

Brief articles on varied topics.

Memory Barriers and Concurrent Memory Ordering

concurrencycomputer science

A memory barrier, also called a memory fence, is an ordering constraint that prevents selected memory operations from moving across a point in a thread’s execution. It can constrain both the compiler and the processor, so other threads observe related reads and writes in the intended order.

A barrier does not make a sequence of operations indivisible. It does not turn a multi-step update into one operation, and it does not by itself prevent another thread from running between those steps.

The problem: each thread can observe a different order

Consider a thread publishing some data:

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

// Writer
 data = 42;
ready.store(true);

// Reader
if (ready.load()) {
    std::cout << data << '\n';
}

The intended meaning is clear: the reader should see 42 after it sees ready == true. But without an appropriate memory-ordering guarantee, that relationship is not established.

There are two sources of surprising behavior:

The processor may still preserve each thread’s own apparent sequence while allowing another thread to observe a different one. Thus, the reader could observe ready == true before it can reliably observe the new value of data.

A memory barrier tells the compiler not to perform certain transformations across the barrier. At runtime, it may also compile to a CPU instruction that restricts how loads and stores are issued or become visible. On an architecture with relatively strong ordering, a language-level barrier may need little or no special CPU instruction, while still being important to the compiler.

Release and acquire: publishing in order

Most programs do not place a bare fence beside every shared variable. Instead, they use atomic operations with ordering modes. In C++, a release operation publishes earlier writes, and an acquire operation prevents later reads and writes from being moved before it.

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

// Writer
 data = 42;
ready.store(true, std::memory_order_release);

// Reader
if (ready.load(std::memory_order_acquire)) {
    std::cout << data << '\n'; // guaranteed to see the published write
}

When the acquire load reads the value written by the release store, the release and acquire synchronize: the language defines an ordering relationship between them. The write to data, which occurred before the release, happens-before the read of data after the acquire. Happens-before is the programming language’s formal guarantee that one operation is ordered before another in a way that makes the first operation’s effects available to the second.

The atomic ready variable is the communication channel. The ordering is not created merely because the two threads both execute a barrier. A standalone fence without a protocol that connects the threads does not tell the reader which writes it should observe.

A language may also expose an explicit fence. In C++, for example, std::atomic_thread_fence(std::memory_order_release) can be placed after the ordinary data writes, with a corresponding acquire fence on the reading side. The exact synchronization rules depend on how those fences are paired through an atomic operation, so release/acquire operations are usually easier to read and harder to misuse.

This is why a log line about a fence, memory ordering, or publication often appears in concurrency failures. The issue is usually not that a CPU randomly lost a write. It is that the program never established an allowed order in which another thread could rely on that write.

What a barrier does not guarantee

A barrier orders operations; it does not combine them. For example, this is still not a safe concurrent counter:

int count = 0;

// Both threads do this
int old = count;
std::atomic_thread_fence(std::memory_order_seq_cst);
count = old + 1;

Even with fences, both threads can read the same old value, calculate the same new value, and then overwrite each other’s result. The read, calculation, and write are separate operations. A barrier does not reserve count for one thread or stop the other thread from performing the same sequence.

The operation needed here is an atomic read-modify-write, such as std::atomic<int>::fetch_add, which performs the read and update as one indivisible operation from the perspective of competing threads. A mutex can provide a larger indivisible critical section by allowing only one thread at a time to enter it.

Atomicity and ordering are therefore separate properties:

An atomic counter can be updated safely while still using relaxed ordering if no other data is being published through it. Conversely, a release/acquire relationship can publish a group of ordinary writes, but it does not make a later multi-step update atomic.

The practical rule

Use the synchronization mechanism provided by the language or library: release/acquire atomics for publication, atomic read-modify-write operations for shared counters, and locks for compound invariants. Treat a memory barrier as a precise ordering tool, not as a general substitute for mutual exclusion.

The useful question is not simply “did we add a fence?” It is: which operation communicates with which other operation, what order does that communication establish, and does the whole update also need to be atomic?

← All articles