Skip to main content

Welcome to a Grasp lesson

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

Lesson 26

Memory Order Relaxed: No Ordering Constraints

Recommended resources

Hello! Welcome back to our course on building a high-performance telemetry engine.

In our last lesson, we established the critical importance of the happens-before relationship. You learned that it is the formal guarantee that memory writes from one thread are visible to another, and that without it, you risk data races and undefined behavior. We briefly saw that std::memory_order_relaxed does not create this relationship, which naturally leads to the question: what is it good for?

This lesson dives into the deep end of performance and control. We will dissect the weakest and fastest memory ordering available in C++: memory_order_relaxed. Your learning outcome is to be able to apply memory_order_relaxed for operations without ordering constraints. This is a key skill for wringing out the last drops of performance in systems programming, a central theme of your project.

1. What is memory_order_relaxed? The "Rules Don't Apply" Model

As its name suggests, memory_order_relaxed imposes the fewest restrictions on the compiler and the CPU. When you use it, you are telling the system: "I only care that this specific operation is atomic. I make no other promises about the ordering of memory operations around it."

This has profound consequences:

  • No "Happens-Before" Relationship: Relaxed operations do not synchronize with each other. This means they cannot be used to guarantee that a write to some other, non-atomic data is visible in another thread.
  • Arbitrary Reordering: The compiler and CPU are free to reorder other memory operations around a relaxed atomic operation.
  • Inconsistent Views: Different threads might observe the sequence of relaxed atomic modifications in different orders.

Let's watch a segment that visualizes this with a "total chaos" analogy and a concrete code example that demonstrates the surprising outcomes it can produce.

Beyond Sequential Consistency - Leveraging Atomics for Fun & Profit - Christopher Fretz - C++ on Sea

The talk 'Beyond Sequential Consistency' by Christopher Fretz gives an excellent, intuitive explanation of the C++ memory models. This segment focuses specifically on memory_order_relaxed.

Watch from 23:15 to 27:25. Pay close attention to the train station analogy for relaxed and how the code example can produce the seemingly impossible result of both threads printing '42'.

The key takeaway is that without ordering constraints, operations can be reordered in ways that defy our single-threaded intuition. A write to y might be reordered before a load from x in one thread, while the OS schedules the threads in an "unlucky" order, leading to results that seem impossible.


2. The Guarantees It Does Provide

While relaxed gives up ordering guarantees, it's not complete anarchy. It still provides two fundamental promises:

  1. Atomicity: The operation itself is indivisible. No thread will ever see a "torn" read or write. For an int, this means you'll always see a complete integer value, not a mix of bytes from two different writes.

  2. Modification Order: For any single atomic variable, all threads will agree on the sequence of its modifications. If a thread observes a variable change from value A to B, it can never later observe it as A. It ensures values for a single variable don't go "back in time".

This is subtle. While different threads may see the total stream of modifications at different times, they all agree on the order of that stream for a given variable.


3. The Performance Payoff: Why Bother with Chaos?

If relaxed is so dangerous, why does it exist? The answer is performance. Every ordering guarantee you request can potentially translate into a memory fence—a special instruction that tells the CPU to stop reordering and make sure memory operations are synchronized across cores. These fences can be expensive.

memory_order_relaxed is designed to avoid these fences entirely. It compiles down to the simplest possible atomic instruction on the target CPU.

This image helps visualize why this matters. A CPU doesn't write directly to main memory. It writes to a local "store buffer". A memory fence forces the CPU to flush this buffer and wait for other CPUs to see the changes. A relaxed operation allows the CPU to just place the write in its buffer and continue executing, which is much faster.

CPU Memory Hierarchy with Store Buffers
This diagram shows that each CPU has its own store buffer. Writes can be queued here before being committed to the shared cache. Relaxed memory ordering allows the CPU to avoid waiting for this buffer to flush, maximizing throughput at the cost of immediate visibility to other cores.

The performance difference is not trivial. For your goal of building a high-performance engine, understanding this trade-off is crucial. Let's see just how significant it can be.

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

In his talk 'C++ atomics, from basic to advanced', Fedor Pikus demonstrates the performance cost of different memory orders. This clip highlights the dramatic difference between relaxed and the default, seq_cst.

Watch from 49:28 to 51:30. Focus on the graph comparing the throughput of writes with different memory orders. Note the performance penalty incurred by stronger ordering.

A performance gain of over 10x is a powerful incentive. This is why we learn to manage the chaos: for situations that demand maximum speed. The difference is most pronounced on weakly-ordered architectures like ARM, where ordering requires explicit fence instructions (dmb). On x86, the hardware provides stronger guarantees by default, but even there, stronger C++-level guarantees like seq_cst require more expensive instructions (xchg or mfence).


4. Correct Use Cases for memory_order_relaxed

So, when is it safe to use? The rule is simple: use memory_order_relaxed when the atomic operation does not synchronize access to any other data.

The most common and safest use case is for simple, non-synchronizing counters, like for statistics or telemetry.

#include <atomic>
#include <iostream>

// A global counter for tracking the number of events processed.
std::atomic<long> event_counter{0};

// This function might be called from many different threads.
void process_event() {
    // We just want to increment the counter atomically.
    // The value of the counter is not used to guard access
    // to any other shared data. We just care that, eventually,
    // the count is correct.
    event_counter.fetch_add(1, std::memory_order_relaxed);
    
    // ... do other work ...
}

In the code above, we don't care about the exact moment other threads see the updated event_counter. We only care that the increments are atomic (so we don't lose any) and that by the end of the program, the total is correct. There is no other shared data event_counter is protecting, so no happens-before relationship is needed.

For an expert's take on practical uses, let's return to Fedor Pikus's talk.

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

This Q&A section directly addresses the question of when memory_order_relaxed is useful, providing several clear, practical examples.

Watch from 1:03:15 to 1:06:56. The speaker gives several examples, including simple counters and multi-stage atomic algorithms where only the final step needs a stronger ordering.

Test your understanding!

Consider a scenario where you are profiling two parts of a system, A and B, that run on different threads. You want to count how many times each part executes.

std::atomic<int> countA{0};
std::atomic<int> countB{0};

// Thread 1
void workA() {
    countA.fetch_add(1, std::memory_order_relaxed);
    // ...
}

// Thread 2
void workB() {
    countB.fetch_add(1, std::memory_order_relaxed);
    // ...
}

// Main Thread (later)
void print_stats() {
    int a = countA.load(std::memory_order_relaxed);
    int b = countB.load(std::memory_order_relaxed);
    std::cout << "A ran " << a << " times, B ran " << b << " times.\n";
}

Is memory_order_relaxed a correct and safe choice here? Why or why not?

Show answer

Yes, this is a perfect use case for memory_order_relaxed.

The two atomic counters, countA and countB, are independent of each other. The value of countA is not being used to signal that some other data is ready for Thread 2, and vice-versa. Each counter is simply a piece of statistical data.

Using relaxed ensures that the increments are atomic (no counts are lost) while generating the most efficient code possible, which is exactly what you want for a low-overhead profiler. The print_stats function doesn't need a perfectly up-to-the-nanosecond view; a slightly delayed view is acceptable for statistical reporting.


5. Application to Your Project: The SPSC Ring Buffer

Let's bring this home to your telemetry engine. You will be building a single-producer, single-consumer (SPSC) ring buffer. High-performance implementations of this data structure are a masterclass in using different memory orderings.

Here is a simplified push operation from a high-performance ring buffer.

Optimizing a Ring Buffer for Throughput

The article 'Optimizing a Ring Buffer for Throughput' by Erik Rigtorp shows a practical, high-performance C++ implementation. We will examine its push function to see memory_order_relaxed in a real-world context.

Read the first two code blocks, which define the ringbuffer struct and its push function. Focus on the first line inside push where writeIdx_ is loaded.

Let's analyze the push function from the article:

bool push(int val) {
  // (1) Load the producer's own index.
  auto const writeIdx = writeIdx_.load(std::memory_order_relaxed);
  
  auto nextWriteIdx = writeIdx + 1;
  // ... (handle wrap-around) ...

  // (2) Check if there is space by loading the consumer's index.
  if (nextWriteIdx == readIdx_.load(std::memory_order_acquire)) {
    return false; // Full
  }

  data_[writeIdx] = val; // Write the data

  // (3) Make the new data available to the consumer.
  writeIdx_.store(nextWriteIdx, std::memory_order_release);
  return true;
}

In step (1), the producer thread is loading its own write index. No other thread modifies writeIdx_, so there's no contention. More importantly, this load is just for a private calculation to determine the next slot. It's not synchronizing with the consumer thread at all. Using memory_order_relaxed here is a pure optimization—it's fast and completely safe.

The actual synchronization happens in steps (2) and (3), where acquire and release are used to safely coordinate with the consumer. This is a perfect demonstration of mixing memory orders for maximum performance and correctness.


Conclusion

You have now learned about the fastest, but most restrictive, C++ memory order. While it can be misused to create subtle and catastrophic bugs, it is a vital tool for high-performance programming when applied correctly.

Key Takeaways:

  • std::memory_order_relaxed guarantees atomicity but provides no ordering guarantees and creates no happens-before relationship.
  • It should only be used for operations that do not synchronize other memory accesses.
  • The canonical use case is for simple atomic counters used for statistics or event counting.
  • It offers significant performance benefits by avoiding expensive memory fence instructions, especially on weakly-ordered CPUs like ARM.

What's Next?

In the SPSC queue example, you saw a glimpse of the other side of the coin: memory_order_acquire and memory_order_release. These are the memory orders you will use to build the synchronization bridges between your threads. In the next lesson, we will explore this pair in detail. They are the fundamental tools you'll use to ensure the data your producer thread writes into the ring buffer is safely and correctly read by the consumer thread.