Skip to main content

Welcome to a Grasp lesson

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

Lesson 27

Acquire-Release Memory Ordering

Recommended resources

Hello! Welcome back to your journey into building a high-performance telemetry engine.

In our last lesson, we explored memory_order_relaxed, the fastest but least-guaranteed memory order. We established that it's great for non-synchronizing tasks like simple counters, but it critically does not create a happens-before relationship. This means it's unsuitable for situations where one thread needs to signal to another that some shared data is ready.

This brings us to the central challenge of lock-free programming: How do we safely pass data between threads without the heavy cost of locks or the strict ordering of memory_order_seq_cst?

Today's lesson answers that question. Your learning outcome is to use memory_order_acquire and memory_order_release to synchronize memory between threads. This acquire-release protocol is the fundamental mechanism you'll use to build the lock-free Single-Producer, Single-Consumer (SPSC) ring buffer, which is the spine of your telemetry project.

1. The Core Problem: Your Intuition Is a Trap

In a single-threaded world, code executes in the order you write it. In a multithreaded world, this is a dangerous assumption. Both the compiler and the CPU are aggressive optimizers, and they are permitted to reorder memory operations if they believe it won't change the outcome for a single thread. When another thread is watching, this can lead to disaster.

Let's start with a fantastic article that builds this concept from first principles using a simplified SPSC queue.

Making Sense of Acquire-Release Semantics

The blog post 'Making Sense of Acquire-Release Semantics' by Dave Kilian provides one of the clearest explanations of why memory ordering is necessary. It uses a toy queue to demonstrate how CPU reordering can break seemingly correct code.

Please read the first two main sections, starting from the introduction down to 'Memory isn’t linearizable!'. Focus on understanding the toy_queue implementation and the bug where poll() can return true but the entry is nullptr.

As the article demonstrates, the producer thread's two operations:

  1. entries[i] = entry;
  2. tail++;

can be reordered by the CPU. If tail++ happens first, the consumer thread sees that the queue has an item, tries to read it, but gets a nullptr because the data hasn't actually been written to the entries array yet. We need a way to forbid this specific reordering.


2. The Solution: The Acquire-Release Handshake

The solution is to establish a clear "handoff" of data ownership. The thread that has the data must release it, and the thread that needs it must acquire it. This is not just a conceptual model; it maps directly to C++ memory orders.

  • memory_order_release: Used for a write operation. It acts as a barrier for past operations. It guarantees that all memory reads and writes that appear before it in your code are completed before this write becomes visible to other threads. It effectively "publishes" the data along with all prior modifications.

  • memory_order_acquire: Used for a read operation. It acts as a barrier for future operations. It guarantees that all memory reads and writes that appear after it in your code will happen after this read has taken place. If this read sees the value written by a release operation, it is guaranteed to also see all the memory changes that happened before that release. It "subscribes" to the published data.

This diagram provides a concise visual mental model:

Acquire and Release Semantics Definitions
This diagram defines acquire and release semantics. A **release** operation ensures all preceding memory operations are complete before the release itself. An **acquire** operation ensures all subsequent memory operations happen after the acquire. When paired, they synchronize memory between threads.

A store with memory_order_release on an atomic variable synchronizes-with a load with memory_order_acquire on the same variable. This pairing is what creates the happens-before relationship we need.


3. A Deeper Mental Model

Let's watch a video that provides a great visual intuition for how this synchronization works. Imagine each thread has its own local view of memory that needs to be explicitly synchronized with the main memory shared between threads.

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

In his talk 'The C++ memory model: an intuition', Arvid Norberg uses a simple but powerful mental model to explain how acquire and release semantics work to make memory changes visible between threads.

Watch from 2:33 to 8:47. Pay close attention to the animation showing Thread A's memory changes being 'released' to main memory, and then 'acquired' by Thread B, making them visible.

The key insight from the video is that the synchronization is a two-part process:

  1. The producer thread prepares data locally and then performs a release-store. This action "flushes" its local changes to shared memory.
  2. The consumer thread performs an acquire-load. When it reads the value written by the producer, it also "pulls in" all the associated memory changes that were flushed.

Without this matched pair of operations on the same atomic variable, there is no guarantee of visibility.


4. Acquire-Release in Practice

Let's solidify this with a concrete C++ example. This pattern is one of the most common in lock-free programming.

#include <atomic>
#include <thread>
#include <vector>
#include <string>
#include <cassert>
#include <iostream>

// Shared data and atomic flag
std::vector<std::string> shared_data;
std::atomic<bool> data_ready{false};

void producer() {
    std::cout << "Producer is preparing data...\n";
    shared_data.push_back("Hello");
    shared_data.push_back("World");
    
    // All memory writes before this line (i.e., modifying shared_data)
    // are guaranteed to be visible to any thread that acquires this store.
    // This acts as a memory barrier, preventing reordering of the push_backs
    // to happen after the store.
    data_ready.store(true, std::memory_order_release);
    std::cout << "Producer has released the data.\n";
}

void consumer() {
    std::cout << "Consumer is waiting for data...\n";
    // The acquire semantics prevent memory operations below this line
    // from being reordered to happen before this load.
    // Once this load returns 'true', we are guaranteed to see all memory
    // writes that happened-before the corresponding release store.
    while (!data_ready.load(std::memory_order_acquire)) {
        // spin-wait
    }
    
    std::cout << "Consumer acquired the data.\n";
    // It is now safe to access shared_data.
    assert(shared_data.size() == 2);
    std::cout << "Data: " << shared_data[0] << " " << shared_data[1] << std::endl;
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

In this code, the release in the producer and the acquire in the consumer form a synchronization point. This guarantees that the consumer will not read from shared_data until after the producer has finished writing to it.

The following image shows a similar, but slightly more complex, chain of synchronization. It demonstrates that these relationships are transitive: if A synchronizes with B, and B synchronizes with C, then A's writes are visible to C.

C++ Memory Ordering: Acquire-Release Synchronization Example
This example illustrates a producer-delivery-consumer pipeline. The producer's `release` synchronizes with the delivery thread's `acquire`. The delivery thread's subsequent `release` synchronizes with the consumer's `acquire`, ensuring the original data from the producer is safely visible to the final consumer.
Test your understanding!

Imagine you modify the producer and consumer functions from the code example above. What would happen in each of these scenarios?

  1. The producer uses memory_order_release, but the consumer uses memory_order_relaxed.
  2. The producer uses memory_order_relaxed, but the consumer uses memory_order_acquire.
  3. You swap them: the producer uses store(..., std::memory_order_acquire) and the consumer uses load(..., std::memory_order_release).
Show answer
  1. Release-Relaxed: This is a data race and results in undefined behavior. A release operation must be paired with an acquire operation to establish a happens-before relationship. While the producer ensures its writes are published, the consumer offers no guarantees about when it will see them or about the ordering of its own operations. It might read data_ready as true but still see an empty or partially-filled shared_data vector because its read of the vector was reordered to happen before its read of the flag.

  2. Relaxed-Acquire: This is also a data race and undefined behavior for the same reason. The handshake is broken. The producer provides no guarantee that its writes to shared_data are visible before its write to data_ready. The CPU could reorder the store to data_ready to happen before shared_data is modified.

  3. Acquire-Store, Release-Load: This code will not compile. The C++ standard enforces that memory_order_acquire and memory_order_acq_rel can only be used on operations that perform a read, while memory_order_release and memory_order_acq_rel can only be used on operations that perform a write. A store is a write-only operation, and a load is a read-only operation, so the memory orders are invalid for those functions.


5. Application to Your Project: The SPSC Ring Buffer

Now, let's apply this directly to the SPSC ring buffer you will build. The producer and consumer threads need to coordinate on the buffer state using the head and tail indices.

  • The producer writes data and updates the tail pointer.
  • The consumer reads data and updates the head pointer.

Here is how acquire-release semantics are correctly used to manage this.

Lock-Free Single-Producer - Single Consumer Circular ...

The article 'Lock-Free Single-Producer - Single Consumer Circular FIFO' on CodeProject gives a well-explained C++ implementation that uses fined-grained memory orders for performance.

Read the sections 'Rules for the Acquire-Release Memory Model and Operations' and 'Code Explained: Acquire-Release/Relaxed Circularfifo'. Pay close attention to the commented push() and pop() functions, noting which operations use release, which use acquire, and which can get away with relaxed.

Let's break down the logic from the article's push function:

// In Producer Thread
bool push(const Element& item)
{       
  const auto current_tail = _tail.load(std::memory_order_relaxed); // (A)
  const auto next_tail = increment(current_tail);                   
  if(next_tail != _head.load(std::memory_order_acquire)) // (B)        
  {     
    _array[current_tail] = item;                                 // (C)
    _tail.store(next_tail, std::memory_order_release);           // (D)
    return true;
  }
  return false; // full queue
}
  • (A): The producer loads its own _tail index. Since it's the only thread that ever writes to _tail, it can use relaxed. There's nothing to synchronize here.
  • (B): The producer loads the consumer's _head index to check for space. It uses acquire to ensure it gets an up-to-date view of what the consumer has been doing. This prevents reordering and ensures that any writes the consumer performed before updating _head are visible to the producer.
  • (C): The data is written into the array.
  • (D): The producer stores the new _tail index using release. This publishes the data. It guarantees that the write to the array at (C) is visible to the consumer before the consumer can see this new _tail value.

The pop function uses the same principles in reverse. This careful mix of memory orders is key to creating a correct, high-performance lock-free data structure.


Conclusion

You have now learned the most important tool for lock-free programming: the acquire-release protocol. Unlike relaxed, which offers no synchronization, and seq_cst, which can be overkill, acquire-release provides a powerful, directed way to ensure memory visibility between threads.

Key Takeaways:

  • Memory operations can be reordered by the compiler and CPU, breaking naive concurrent code.
  • A store with memory_order_release publishes memory changes to other threads. It prevents prior operations from being reordered past it.
  • A load with memory_order_acquire consumes published changes. It prevents subsequent operations from being reordered before it.
  • An acquire-load that reads the value from a release-store creates a happens-before relationship, guaranteeing memory visibility.
  • This mechanism is the foundation for correct and efficient lock-free data structures like the SPSC queue.

What's Next?

We've covered the two extremes (relaxed and seq_cst, briefly) and the workhorse in the middle (acquire-release). In the next lesson, we will formally distinguish between sequential consistency and relaxed memory models, solidifying your understanding of the entire spectrum. This will help you reason about when to use each model and justify your choices—a critical skill for a systems programmer.