Skip to main content

Welcome to a Grasp lesson

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

Lesson 30

Understanding Memory Orderings

Recommended resources

Hello! Welcome back to your deep dive into C++ concurrency.

In our last lesson, you built a high-performance spin-lock from scratch. In doing so, you got hands-on experience using specific memory orders: memory_order_acquire to safely obtain the lock, memory_order_release to publish your changes, and memory_order_relaxed to efficiently poll the lock's state. This was a practical application, but the choices were guided.

Today, we're going to generalize that knowledge. This lesson is about developing a mental framework to identify when to use different memory orderings to balance safety and performance. You'll learn to move from simply using memory orders to choosing them deliberately based on the specific synchronization needs of your code. This skill is paramount for writing correct and fast low-level systems, which is the core of your telemetry engine project.

The Spectrum of Guarantees

Think of C++ memory orders as a spectrum. On one end, you have maximum safety and ease of reasoning; on the other, you have maximum performance and compiler freedom. Your job as a systems programmer is to pick the right point on this spectrum for the task at hand.

Memory Order Guarantees Performance Cost Analogy
memory_order_seq_cst Total global order across all threads. Easiest to reason about. Highest. Can be orders of magnitude slower. A global meeting where everyone agrees on the minutes.
memory_order_acq_rel A combination of acquire and release for RMW operations. High. A full memory barrier. Announcing your status and listening for others' at the same time.
memory_order_acquire / release Pairwise synchronization. A release synchronizes-with an acquire. Medium. "Half-fences" that are often cheaper. Sending a certified letter (release) that requires a signature (acquire).
memory_order_relaxed Atomicity only. No ordering guarantees for other memory operations. Lowest. Gives the compiler/CPU maximum freedom. Shouting an update into a room with no expectation of when or how it's heard.

We will not cover memory_order_consume, as it's notoriously difficult to use correctly and is currently treated as memory_order_acquire by all major compilers.


Choosing the Right Tool for the Job: Common Scenarios

Let's walk through the most common scenarios you'll encounter and determine the right memory order for each.

Scenario 1: Independent Operations (e.g., Counters)

The Situation: You need to perform an atomic operation, like incrementing a counter, but this operation does not synchronize any other data. The only thing that matters is that the operation itself is not torn. This is common for collecting statistics, like counting events or work items.

The Choice: memory_order_relaxed

The Rationale: Since no other memory access depends on the order of this atomic operation, you don't need any synchronization barriers. relaxed provides the bare minimum—atomicity—at the lowest possible performance cost. It tells the compiler and CPU, "Just make this single operation atomic; I don't care what you do with the surrounding code."

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

Let's watch a segment from Fedor Pikus's CppCon talk where he discusses a practical use case for memory_order_relaxed.

Watch from 1:03:15 to 1:06:56. Pay close attention to the answer to the audience question about practical use cases. He explains that if you are only interested in the value of the atomic variable itself (like a simple counter), and it's not being used to coordinate access to other data, relaxed is the right choice.

Here's what that looks like in code, from the "Multithreading in Modern C++" article:

// from https://dev.to/cear/multithreading-in-modern-c-lock-free-programming-memory-ordering-and-atomics-4cek
std::atomic<int> counter{0};

void incrementCounter() {
    for (int i = 0; i < 100000; ++i) {
        // We only care that the counter is correctly incremented.
        // No other data is being synchronized by this operation.
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

Scenario 2: Producer-Consumer Synchronization

The Situation: This is the most common synchronization pattern. One thread (the "producer") prepares some data and then needs to signal to another thread (the "consumer") that the data is ready. This is the core logic you will use in your project's SPSC (Single-Producer, Single-Consumer) ring buffer.

The Choice: memory_order_release for the producer's signal, and memory_order_acquire for the consumer's check.

The Rationale: This pair creates a "happens-before" relationship.

  • store(..., memory_order_release): Guarantees that all memory writes that happened before this store in the producer thread are visible to the consumer. It acts like a barrier preventing prior writes from being reordered past it. You are "releasing" your changes to the world.
  • load(..., memory_order_acquire): Guarantees that all memory reads that happen after this load in the consumer thread will see the data published by the producer. It acts like a barrier preventing subsequent reads from being reordered before it. You are "acquiring" the changes from the producer.

Arvid Norberg: The C++ memory model: an intuition

Arvid Norberg gives a fantastic intuitive explanation of this acquire-release pairing. His mental model of 'publishing' and 'pulling in' memory is extremely helpful.

Watch from 2:44 to 7:45. Focus on the diagrams showing Thread A performing a release-store and Thread B performing a load-acquire. This visualizes how the non-atomic memory writes are synchronized via the atomic variable.

The producer-consumer pattern is clearly illustrated in this code example.

Multithreading in Modern C++: Lock-Free Programming, ...

The article "Multithreading in Modern C++" provides a canonical code example for the producer-consumer pattern.

Read the section titled 'Release-Acquire: Synchronization Between Threads'. Study the producer and consumer functions. Note how the regular write to data happens before the release-store to ready, and the read from data happens after the acquire-load of ready.

Acquire-Release and Happens-Before Example
This diagram illustrates a possible interleaving with acquire-release. Thread `write_x` releases data that thread `read_x_then_y` can acquire. This establishes a synchronization point between the two threads for the variable `x`.

Scenario 3: Read-Modify-Write Operations as Fences

The Situation: You need to perform a read-modify-write (RMW) operation, like fetch_sub or compare_exchange, that needs to both acquire changes from other threads and release its own. A classic example is decrementing a reference count. When you decrement, you release your own modifications to the shared object, but if your decrement brings the count to zero, you must acquire all other threads' modifications before you can safely delete the object.

The Choice: memory_order_acq_rel

The Rationale: This ordering combines the properties of acquire and release into a single, powerful operation. It creates a full memory fence, preventing any memory reordering in either direction across the atomic operation.

concurrency-primer.pdf - Bit Bashing

The concurrency-primer.pdf provides a concise example using reference counting.

Read section 10.3, 'Acquire-Release'. Notice how inc() can use relaxed because no action is taken, but dec() must use acq_rel because it might need to delete the data, which requires seeing all previous writes (acquire) and publishing the decrement (release).


Scenario 4: Maximum Safety and Complex Algorithms

The Situation: You are implementing a complex algorithm with multiple interacting atomic variables, and reasoning about the partial ordering of acquire-release is too difficult or error-prone. Or, you're just starting out and want to prioritize correctness over performance.

The Choice: memory_order_seq_cst

The Rationale: seq_cst is the default for a reason: it's the most intuitive. It establishes a single global order of all sequentially consistent operations that all threads agree on. This eliminates many subtle reordering bugs that can occur with weaker memory models, making your code easier to reason about. However, this safety comes at a steep performance cost.

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

Fedor Pikus's talk includes a dramatic demonstration of this performance cost.

Watch from 49:28 to 51:30. The graph comparing a relaxed write to a sequential-consistency write is striking. It shows a performance difference of over 10x. This highlights why you should only use seq_cst when you truly need its strong guarantees.

The rule of thumb is: start with seq_cst if you are unsure. If profiling later reveals the atomic operation to be a bottleneck, you can then carefully analyze the synchronization requirements and relax the ordering to acquire/release or even relaxed if it is safe to do so.


A Decision-Making Heuristic

You can use the following questions to guide your choice:

  1. Is this atomic operation synchronizing other memory?

    • No: It's likely just a counter or a simple flag. Use memory_order_relaxed.
    • Yes: Proceed to the next question.
  2. Is this a "one-way" synchronization, where a producer is publishing data to a consumer?

    • Yes: Use memory_order_release on the producer's store and memory_order_acquire on the consumer's load. This is your future SPSC queue.
    • No: Proceed to the next question.
  3. Is this a read-modify-write operation that needs to both read others' changes and publish its own?

    • Yes: This is a two-way synchronization point. Use memory_order_acq_rel.
    • No: Proceed to the next question.
  4. Is the algorithm complex, involving multiple atomics, or are you prioritizing safety and ease of reasoning over absolute performance?

    • Yes: Default to memory_order_seq_cst. Write correct code first, then optimize if necessary.
Lock-Free Programming Decision Flowchart
This flowchart by Preshing on Programming shows a more detailed decision process for lock-free techniques. The path on the right about "One writer, one or more readers" relates directly to our producer-consumer pattern with acquire/release.
Test your understanding!

You are implementing the push method for a single-producer, single-consumer (SPSC) queue. This queue uses an array and two atomic indices: writePos (only touched by the producer) and readPos (only touched by the consumer).

Fill in the /* memory order here */ placeholders with the most appropriate (i.e., most performant yet safe) memory orders and justify your choices.

template<typename T, size_t Size>
class SPSCQueue {
private:
    std::array<T, Size> buffer_;
    std::atomic<size_t> writePos_{0};
    std::atomic<size_t> readPos_{0};

public:
    bool push(const T& value) {
        size_t currentWrite = writePos_.load( /* memory order here */ );
        size_t nextWrite = (currentWrite + 1) % Size;

        // Check if the queue is full by reading the consumer's position.
        if (nextWrite == readPos_.load( /* memory order here */ )) {
            return false; // Queue is full
        }

        buffer_[currentWrite] = value; // Write the data

        // Publish the new write position.
        writePos_.store(nextWrite, /* memory order here */ );
        return true;
    }
    // ... pop method ...
};
Show answer

Here is the correctly implemented push method:

bool push(const T& value) {
    // 1. We can use relaxed for this load. The `writePos_` is only ever modified
    //    by this thread (the single producer), so we don't need to synchronize
    //    with any other thread to read its current value.
    size_t currentWrite = writePos_.load(std::memory_order_relaxed);
    size_t nextWrite = (currentWrite + 1) % Size;

    // 2. We must use acquire when reading the consumer's position (`readPos_`).
    //    This is because the consumer thread updates `readPos_` with a release
    //    operation. Using acquire ensures we get the latest value and prevents
    //    reordering of this load with subsequent operations.
    if (nextWrite == readPos_.load(std::memory_order_acquire)) {
        return false; // Queue is full
    }

    buffer_[currentWrite] = value; // Write the data

    // 3. We must use release when publishing the new write position. This creates
    //    a happens-before relationship. It guarantees that the write to
    //    `buffer_[currentWrite]` is visible to the consumer *before* the consumer
    //    sees the updated `writePos_`.
    writePos_.store(nextWrite, std::memory_order_release);
    return true;
}

This precise selection of memory orders is what makes lock-free data structures both correct and highly performant.


Conclusion

You've now established a robust mental model for choosing memory orders. This isn't just an academic exercise; it's a fundamental skill for building the high-frequency telemetry system you've set out to create. Every atomic operation in your lock-free ring buffer will require a deliberate choice of memory ordering to ensure both correctness and zero-overhead performance.

Key Takeaways:

  • relaxed: For non-synchronizing atomic operations like simple counters. Highest performance, lowest safety guarantees.
  • acquire/release: The workhorse for producer-consumer patterns. Use release to publish data and acquire to consume it. This is the heart of your SPSC queue.
  • acq_rel: For read-modify-write operations that act as a two-way synchronization point.
  • seq_cst: The safest default. Use it when correctness is hard to reason about or when performance is not critical. It's better to be correct and slow than fast and wrong.
  • Choosing a memory order is a form of communication. It tells other programmers (and your future self) about your intentions for synchronization.

What's Next?

You now understand when to use the different memory orderings. In the next lesson, we will dig deeper into the why. We will formally distinguish between sequential consistency and relaxed memory models, exploring the specific kinds of instruction reordering that the hardware and compiler are allowed to perform under a relaxed model. This will give you a much more profound appreciation for the guarantees you are giving up in the pursuit of performance.