Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 48

Memory Barriers for Ordering

Recommended resources

Hello! Welcome back to our series on C threads.

In our last lesson, we explored the unsettling reality of instruction reordering. You learned that both the compiler and the CPU can reorder memory operations, breaking our intuitive, sequential understanding of code and causing subtle concurrency bugs. We demonstrated this with the classic "litmus test" where two threads could produce a result (r1=0, r2=0) that seems sequentially impossible.

Today, we'll learn how to control this chaos. Our learning outcome is to use memory barriers (fences) to enforce specific memory ordering guarantees. These fences are the fundamental tools we need to command the compiler and CPU, ensuring our lock-free algorithms behave correctly.

The Problem Revisited and a Direct Solution

Let's start by directly addressing the reordering problem we saw in the previous lesson. We need a way to tell the processor, "All memory operations before this point must be visible to other cores before any memory operations after this point are executed." This instruction is a memory barrier or memory fence.

A great way to see this in action is with a concrete C example. The following resource provides an article and a C program that does exactly what we need: it reproduces the reordering bug and shows how to fix it with a fence.

Memory Fence | [Lab] Lock Free Programming Lab

Please read the article 'Memory Fence' from the Lock Free Programming Lab. It provides a C program, ordering.c, that you can inspect to see a practical demonstration of the reordering issue and its solution.

Read the entire article. Pay close attention to: The setup of the experiment in ordering.c (section 4), which uses global variables and POSIX semaphores to coordinate two threads. The use of asm volatile("" ::: "memory"); to prevent compiler reordering, isolating the CPU's behavior. The #if USE_CPU_FENCE block, which introduces the mfence instruction. This is an x86/x64-specific instruction for a full memory fence.

As you saw in the article, mfence is a hardware instruction that acts as an impenetrable wall for memory operations. No loads or stores from before the fence can be reordered to appear after it, and vice versa. This brute-force approach effectively restores sequential ordering around the fence, fixing the bug.

The video you saw in our last lesson demonstrates this fix perfectly.

Advanced Topics: Hardware Memory Barriers

Let's re-watch the final part of the 'Hardware Memory Barriers' video. It shows the litmus test program being fixed with the _mm_mfence intrinsic, which corresponds to the mfence assembly instruction.

Watch from 14:06 to 18:00. Observe how inserting the fence prevents the r1=0, r2=0 result from ever occurring, proving that we have successfully constrained the hardware's reordering behavior.

While powerful, using mfence directly via inline assembly is not portable. Code with mfence will only compile and work correctly on x86/x64 CPUs. We need a portable, standardized way to express these ordering constraints.


The C11/C++11 Memory Model: Portable Control

The C11 standard introduced a comprehensive memory model that provides portable control over memory ordering. Instead of hardware-specific instructions, we use memory order enumerations with atomic operations.

This might sound complex, but the concepts are very clear once you see them. A fantastic video by Fedor Pikus explains this, and while it uses C++ syntax, the concepts and enumeration names are identical in C11.

CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?”

This clip from Fedor Pikus's talk, 'C++ atomics, from basic to advanced,' is one of the clearest explanations of memory orders available. It explains how memory barriers are integrated with atomic operations.

Please watch from 41:42 to 48:02. The speaker will introduce the key memory orders: memory_order_relaxed: No ordering guarantees, just atomicity. memory_order_acquire: A barrier for operations that follow. Often used when reading a flag or pointer. memory_order_release: A barrier for operations that precede it. Often used when setting a flag or publishing a pointer. The Acquire-Release Protocol: How these two work together to safely pass information between threads.

Acquire-Release Semantics

The acquire-release pattern is the most common and important ordering semantic in lock-free programming. It forms a one-way channel for synchronizing memory.

  • A thread performing a release operation ensures that all its prior memory writes are made visible.
  • A thread performing an acquire operation on the same atomic variable ensures it sees those writes.

This creates a "happens-before" relationship. The writes before the release in the producer thread are guaranteed to happen before the reads after the acquire in the consumer thread.

Release-Acquire Memory Ordering Synchronization
This diagram perfectly visualizes the acquire-release protocol. The `release` store in Thread 1 **synchronizes-with** the `acquire` load in Thread 2. This guarantees that any memory operations before the release are visible to any memory operations after the acquire.
Test your understanding!

You are fixing the producer-consumer code from the last lesson.

// Shared global variables
_Atomic int value = 0;
_Atomic int is_ready = 0;

// Producer Thread
void producer() {
    atomic_store_explicit(&value, 42, memory_order_relaxed);
    atomic_store_explicit(&is_ready, 1, /* which memory order? */);
}

// Consumer Thread
void consumer() {
    if (atomic_load_explicit(&is_ready, /* which memory order? */)) {
        printf("The value is %d\n", atomic_load_explicit(&value, memory_order_relaxed)); 
    }
}

Which memory orders should you use for is_ready to ensure the consumer never prints 0?

Show answer

The producer should use memory_order_release, and the consumer should use memory_order_acquire.

// Producer
atomic_store_explicit(&is_ready, 1, memory_order_release);

// Consumer
if (atomic_load_explicit(&is_ready, memory_order_acquire)) { ... }

The release on the store ensures that the write to value (and all other preceding writes) becomes visible before or at the same time as the write to is_ready. The acquire on the load ensures that if the consumer sees is_ready == 1, it will also see the correct value of 42 written by the producer.

The Full Spectrum of Memory Orders

Acquire and release are the workhorses, but there are others. Here's a quick summary:

Memory Order Guarantee Typical Cost
memory_order_relaxed Atomicity only. No ordering guarantees. The compiler/CPU are free to reorder around it. Cheapest
memory_order_acquire Prevents memory operations that follow it (in code) from being reordered to happen before it. Moderate
memory_order_release Prevents memory operations that precede it (in code) from being reordered to happen after it. Moderate
memory_order_acq_rel Combines acquire and release. Used for read-modify-write operations (like atomic_exchange) that both read and write a value. Expensive
memory_order_seq_cst The strongest. All sequentially consistent operations form a single total order. Prevents almost all reordering. This is the default for atomic operations if not specified. Most Expensive

Relying on the default seq_cst is safe but often carries a significant performance penalty. Explicitly choosing the weakest memory order required for your algorithm is key to high-performance lock-free code.


Standalone Fences: atomic_thread_fence

Sometimes, you need to enforce an ordering constraint separately from an atomic operation. For this, C11 provides atomic_thread_fence. This is the portable equivalent of instructions like mfence.

Acquire and Release Fences

The article 'Acquire and Release Fences' by Jeff Preshing explains this concept very well. It shows how to use standalone fences and how they relate to the memory orders we just learned about.

Please read the first three sections, down to the end of the 'A Walkthrough...' section. Focus on: The C11 syntax: atomic_thread_fence(memory_order_acquire); The key idea: A relaxed atomic operation plus a fence can achieve the same goal as an atomic operation with a stronger memory order. The producer-consumer example using fences and relaxed atomics.

Using a standalone fence gives you more flexibility. For instance, in the consumer example from the article, the expensive acquire fence is only executed if the ready flag is set, saving cycles in the more common "not ready" case.

You can also create a full fence, like mfence, with atomic_thread_fence(memory_order_acq_rel). This prevents all reordering across the fence.

Memory Fence Reordering Guarantees
This image visualizes a full fence (`memory_order_acq_rel`). On top, without a fence, operations are free to be reordered. On the bottom, with the fence, no loads before it can move down, and no stores after it can move up.

Conclusion

You have now bridged the gap between the chaos of reordering and the order required for correct concurrent programming. By mastering memory barriers, you've gained fine-grained, portable control over the CPU and compiler.

Key Takeaways:

  • Memory barriers (fences) are instructions that constrain compiler and CPU instruction reordering.
  • Hardware-specific fences like mfence are powerful but not portable.
  • C11 provides portable control through memory orders used with _Atomic types (stdatomic.h).
  • The acquire-release protocol is the fundamental pattern for safely communicating between threads without locks. A release store publishes data, and an acquire load receives it.
  • Standalone fences (atomic_thread_fence) allow you to decouple ordering constraints from specific atomic operations, offering more structural flexibility.
  • Choosing the correct memory order is a trade-off between safety and performance. memory_order_relaxed is fastest, while memory_order_seq_cst is safest (and the default).

Preview of the Next Lesson:
With atomicity and memory ordering now under your belt, you're ready to tackle one of the most important primitives in lock-free programming. In our next lesson, we will explore the compare-and-swap (CAS) operation. You will learn how it enables optimistic concurrency and how the memory orders we discussed today are crucial for using it correctly.