Skip to main content

Welcome to a Grasp lesson

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

Lesson 55

Understanding Memory Orderings and Performance

Recommended resources

Hello! Welcome back to our series on C++ internals.

In our last lesson, we introduced std::atomic as a powerful tool for lock-free programming, allowing us to build thread-safe components without the risk of deadlock. We saw how atomic operations provide indivisibility and ensure synchronization. We also briefly used std::memory_order_relaxed, with a promise to explore memory ordering in more detail.

Today, we fulfill that promise. This lesson is crucial for writing correct, high-performance concurrent code. We will analyze the different C++ memory ordering options—specifically memory_order_relaxed, memory_order_acquire/memory_order_release, and memory_order_seq_cst. Your goal is to understand not just what they do, but why they exist, how they constrain the compiler and CPU, and what their performance implications are. This goes right to the heart of your goal: understanding how C++ features map to the machine level.

Part 1: Why We Need Memory Ordering Rules

In a single-threaded world, the compiler and CPU are free to perform aggressive optimizations, such as reordering instructions, as long as the final result is what the code would produce if executed sequentially. This is known as the "as-if" rule.

However, in a multithreaded program, these optimizations can break our code in subtle and catastrophic ways. The two main sources of reordering are:

  1. Compiler Reordering: The compiler may reorder instructions at compile time to optimize register usage or instruction scheduling.
  2. CPU Reordering: Modern CPUs use techniques like out-of-order execution and store buffers to hide memory latency. A write operation might be temporarily held in a core's local store buffer and not be immediately visible to other cores.

Let's see this in action.

C++ Memory Model: from C++11 to C++23 - Alex Dathskovsky - CppCon 2023

To understand why we need memory ordering rules, we first need to appreciate the optimizations that compilers and CPUs perform. This video from CppCon 2023 explains the key hardware and compiler optimizations that make the C++ memory model a necessity.

Watch from 2:06 to 12:40. Focus on the concepts of compiler reordering, CPU out-of-order execution, and store buffers. These are the 'tricksters' that memory ordering rules are designed to control.

The key takeaway is that without explicit instructions, there is no guarantee that other threads will see your memory writes in the order you wrote them in your source code. To control this, C++ provides std::memory_order.


Part 2: The C++ Memory Model Guarantees

The C++ memory model can be thought of as a contract. If we, as programmers, write data-race-free code using synchronization tools like std::atomic, the language guarantees how memory operations in one thread become visible to another. Memory orders are the fine-print of this contract, allowing us to choose the strength of the guarantees we need.

Let's explore the three most important memory ordering levels, from strongest to weakest.

C++ Memory Ordering Options Flowchart
This flowchart gives a high-level overview of the different memory orders available for atomic load and store operations.

1. memory_order_seq_cst (Sequentially Consistent)

This is the default for all atomic operations and the strongest guarantee C++ offers. It's the easiest to reason about because it establishes a single, global, total order of all sequentially consistent atomic operations across all threads.

  • Guarantee: All threads will agree on the order in which seq_cst operations occurred. No seq_cst operation can be reordered around another seq_cst operation.
  • Analogy: Imagine all threads placing their atomic operations onto a single, global timeline.
  • Use Case: When you need the simplest and most robust synchronization, or when multiple atomic variables are used to coordinate logic between threads.
  • Cost: This is the most expensive memory order. On many architectures, it requires the compiler to emit full memory fences, which can stall the CPU and prevent many optimizations.

2. memory_order_release & memory_order_acquire (The Acquire-Release Protocol)

This is the workhorse for most lock-free communication. Instead of a single global order, it creates pairwise synchronization between specific threads.

  • memory_order_release (for stores): A thread performing a release store is like a publisher. This operation ensures that all memory writes (atomic and non-atomic) that happened before it in the same thread are made visible to other threads.
  • memory_order_acquire (for loads): A thread performing an acquire load is like a subscriber. If this load reads the value written by a release store, it guarantees that it can now see all the memory writes that the releasing thread made before its release.

This pairing establishes a "happens-before" relationship between the code before the release and the code after the acquire.

Acquire-Release Memory Ordering Visualization
This diagram perfectly illustrates the acquire-release protocol. If thread 2's acquire operation on `var2` sees the value set by thread 1's release, then thread 2 is guaranteed to see the values of `var0` and `var1` that were written before the release.
  • Analogy: A release is like git push, making your local commits available to the remote. An acquire is like git pull, fetching those commits and making them visible locally.
  • Use Case: This is ideal for message passing, where one thread prepares data and then "releases" a flag or pointer that another thread "acquires" to safely access the data.
  • Cost: Cheaper than seq_cst. On many architectures, it maps to lighter-weight barrier instructions or is sometimes even free (like on x86 for stores).

Read-modify-write operations can use memory_order_acq_rel, which combines the guarantees of both an acquire and a release. This is common for operations that both read and write state, like in a lock-free queue.

3. memory_order_relaxed

This is the weakest and fastest memory order.

  • Guarantee: It only guarantees the atomicity (indivisibility) of the operation itself. It provides no ordering constraints on other memory accesses. A relaxed operation on variable A does not synchronize with any operations on variable B.
  • Analogy: This is like a single atomic transaction that doesn't tell you anything about the order of other transactions.
  • Use Case: Perfect for things like simple event counters, where you only care about the final value and don't use the counter to synchronize access to other data.
  • Cost: Typically free. It usually compiles down to a simple instruction, with atomicity guaranteed by the hardware or cache-coherence protocol without any extra fences.

HFT 03 - How you could invent the C++ Memory model

This article from the brocbyte newsletter provides an excellent, intuitive explanation of these concepts, focusing on happens-before relationships and using a practical SPSC queue example to show where different memory orders are needed.

Read 'Part 3: Building a SC-DRF Memory Model' and 'Weak memory models'. Focus on how the 'happens-before' relation is built and how acquire-release and relaxed orderings weaken the default seq_cst guarantees to gain performance.


Part 3: Performance Implications and Hardware Mapping

Your main interest is in the "how," so let's connect these C++ concepts to the hardware. The performance cost of a memory order comes from the machine instructions the compiler must generate to enforce its guarantees.

  • Stronger orders (seq_cst) often require full memory fences (MFENCE on x86, or pairs of DMB on ARM) or instructions with a LOCK prefix on x86. These are expensive because they can stall the CPU's execution pipeline and force synchronization across cores.
  • Weaker orders (acquire-release) might require lighter-weight, directional barriers or map to special load-acquire (LDAR on ARMv8) / store-release (STLR on ARMv8) instructions.
  • relaxed often requires no special barrier instructions at all.

This means the performance impact varies significantly by CPU architecture.

  • x86-64: Has a strong memory model (Total Store Order). Stores are not reordered with other stores, and loads are not reordered with other loads. Because of this, acquire loads and release stores are often "free"—they compile to a standard MOV instruction. However, a seq_cst store requires a more expensive instruction like XCHG or another instruction with a LOCK prefix, which acts as a full fence.
  • ARM: Has a weak memory model. Reordering is much more common, so acquire and release operations explicitly require special instructions (LDAR/STLR on ARMv8) or memory barriers (DMB on ARMv7) to prevent reordering. These are more expensive than a simple load/store but cheaper than the full barrier required for seq_cst.

This difference is why you should always express your intent with the correct memory order in C++ and not rely on the behavior of a specific architecture.

Performance Impact of Memory Ordering in Concurrent C++

The following master's thesis provides a deep and comprehensive analysis of the C++ memory model and its performance impact. We will focus on the sections that directly show how memory orders are implemented on the hardware and the performance data.

First, study Table 2, Table 3, and Table 5 in section 3.11 ('Instruction Generation', pages 55-57). This table is a fantastic resource showing how C++ loads, stores, and CAS operations map to assembly on x86-64 and ARM. This directly answers the question of 'how it works'. Next, skim the benchmark results in section 5.4 ('Results', pages 86-88). You don't need to read the whole section, but look at Figure 28 and the summary table. Notice how the performance cost of acquire-release vs. seq_cst differs dramatically between the Intel (x86) and Allwinner (ARMv7) systems. This provides concrete evidence for the performance implications.

Test your understanding!

You are implementing a simple thread-safe counter that is used purely for collecting statistics (e.g., number of web requests processed). The value of this counter is never used to decide if another thread can access some other data. Which memory order is most appropriate for incrementing this counter, and why?

Show answer

std::memory_order_relaxed is the most appropriate choice. Since the counter's value does not synchronize access to any other memory, we do not need any ordering guarantees between the counter's modification and other memory operations. We only need the increment itself to be atomic (indivisible), which relaxed provides. This will yield the best performance as it generates the least restrictive machine code.


Conclusion

Today we've delved into one of the most complex but powerful areas of C++. Understanding memory ordering is what separates a proficient C++ programmer from an expert in high-performance computing. You now have the conceptual framework and the low-level knowledge to make informed decisions about concurrency.

Key Takeaways:

  • Memory ordering is necessary to prevent compiler and CPU instruction reordering from breaking concurrent logic.
  • memory_order_seq_cst provides a global order, is the easiest to reason about, but is the most expensive. It acts as a full memory fence.
  • memory_order_acquire / release provides pairwise synchronization, establishing a "happens-before" relationship to safely publish data between threads. It is the workhorse of lock-free data structures.
  • memory_order_relaxed guarantees only atomicity with no ordering constraints, making it the fastest option for simple independent atomic variables like counters.
  • The performance cost of a memory order is directly tied to the machine instructions (fences, locked instructions, special loads/stores) it requires, and this cost varies significantly across architectures like x86 and ARM.

What's Next?

With a solid grasp of std::thread, mutexes, condition variables, and now the intricacies of the memory model with std::atomic, we have almost all the low-level concurrency tools we need. In the next lesson, we will move to a higher level of abstraction and explore how to manage asynchronous operations and their results using std::promise and std::future. These tools will be invaluable as we start designing the API for our thread pool project.