Skip to main content

Welcome to a Grasp lesson

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

Lesson 35

Implementing Non-Blocking Pop with `memory_order_acquire`

Recommended resources

Hello again! In our last lesson, we built the producer's side of our lock-free queue, implementing the push function. We saw how std::memory_order_release acts as a "publish" operation, ensuring that all data written to the buffer is made visible before the tail_ index is updated.

Today, we'll implement the other half of this critical data structure: the consumer's pop operation. This is where we'll use std::memory_order_acquire to safely "consume" the data that the producer has published. By the end of this lesson, you will have a fully functional, high-performance, single-producer, single-consumer (SPSC) ring buffer, the foundational data pipeline for your telemetry engine.

The Acquire-Release Handshake

Let's quickly revisit the core concept. The producer and consumer need to communicate. The producer says, "Here is new data," and the consumer says, "I've taken the data, the slot is now free." This is a handshake.

In our last lesson, we implemented the producer's side of this handshake:

  • The producer writes data to the buffer.
  • Then, it performs a tail_.store(..., std::memory_order_release).

This release operation guarantees that the data write is visible to any other thread that successfully synchronizes with this store.

Now, we will implement the consumer's side. The consumer needs to perform a corresponding acquire operation to complete the handshake and safely see the data.

An acquire operation on a load (e.g., variable.load(std::memory_order_acquire)) creates an incoming memory barrier. It guarantees that no memory operations that appear after it in your code can be reordered by the compiler or CPU to happen before it. This ensures that you first confirm new data exists (acquire), and only then do you try to read it.

The pairing of a release store in one thread with an acquire load in another thread on the same atomic variable establishes a synchronizes-with relationship. This is the cornerstone of how we ensure visibility of non-atomic data between threads without using locks.

Memory Order Release-Acquire Synchronization
This diagram illustrates the core guarantee. Thread 1 writes to a non-atomic variable `x` and then performs a `release` store on an atomic `flag`. Thread 2 performs an `acquire` load on that same `flag`. Once Thread 2's load sees the value stored by Thread 1, a happens-before relationship is established. This guarantees that the write to `x` in Thread 1 is visible to Thread 2.

Implementing the Non-Blocking pop Operation

Let's build the pop function. The logic is the mirror image of push:

  1. Atomically load the current head_ index. Since only this thread modifies head_, no special ordering is needed here.
  2. Check if the queue is empty. To do this, we must get the latest value of the tail_ index from the producer. This is our synchronization point, and where we'll use memory_order_acquire.
  3. If the queue is not empty, copy the data from the buffer. This is safe because the acquire load has guaranteed the data is visible.
  4. Finally, update the head_ index to signal that the slot is now free. This update must be a release operation to publish the "free slot" information back to the producer.

Now, let's look at a professional implementation of this logic.

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

The article 'Lock-Free Single-Producer - Single Consumer Circular...' that we used previously provides a perfectly clear implementation of the pop function. We will study its code and detailed explanation.

In the article, find the section titled 'Code Explained: Acquire-Release/Relaxed Circularfifo' and scroll down to the subsection for 'bool pop(const Element& item)'. Read through the code and the numbered explanations.

Let's break down that code and adapt it to our class structure.

bool pop(T& item)
{
    // 1. Load head with relaxed memory order.
    const auto current_head = head_.load(std::memory_order_relaxed);

    // 2. Load tail with acquire memory order to synchronize with the producer.
    if(current_head == tail_.load(std::memory_order_acquire))
    {
        return false; // The buffer is empty.
    }

    // 3. Read the data from the buffer. This is now safe.
    item = buffer_[current_head & mask_];

    // 4. Store the new head with release memory order to publish the freed slot.
    head_.store(current_head + 1, std::memory_order_release);

    return true;
}

Code Breakdown:

  1. head_.load(std::memory_order_relaxed): The consumer thread is the only thread that writes to head_. When it reads head_, it is only checking its own state. There is no other thread to synchronize with for this value, so a relaxed (i.e., no ordering guarantees) operation is correct and the most performant choice.

  2. tail_.load(std::memory_order_acquire): This is the heart of the pop operation. The tail_ index is written by the producer. To get the most up-to-date value and ensure that we see the data the producer wrote, we must use acquire semantics. This acquire load pairs with the release store in the push function, establishing the happens-before relationship that makes the data visible.

  3. item = buffer_[current_head & mask_]: This is a simple, non-atomic read. It's safe because the acquire operation in the previous step acted as a barrier, guaranteeing that this read happens after the visibility of the producer's write has been established.

  4. head_.store(current_head + 1, std::memory_order_release): This step completes the two-way communication. We are now "releasing" this buffer slot, making it available for the producer to use again. The producer's push function performs an acquire load on head_ to check if the buffer is full. Our release store here synchronizes with the producer's acquire load, ensuring it correctly sees the newly freed slot.

Test your understanding!

In the pop function, we perform head_.store(..., std::memory_order_release). What could go wrong if we used std::memory_order_relaxed here instead?

Show answer

If we used std::memory_order_relaxed for the store to head_, we would break the acquire-release handshake with the producer.

The producer's push function calls head_.load(std::memory_order_acquire) to check if the buffer is full. This acquire load needs a corresponding release store to synchronize with. If our pop function only does a relaxed store, there is no "happens-before" guarantee.

This could lead to a situation where the producer doesn't see the updated head_ value in a timely manner. It might incorrectly believe the buffer is full when it is not, causing it to fail to push new data. This would reduce the effective throughput and correctness of the queue.


Conclusion

Excellent work! You have now implemented both sides of the lock-free SPSC queue. By carefully applying acquire-release semantics, you've built a data structure that is not only thread-safe but also highly performant, avoiding the overhead of both mutexes and sequentially consistent atomics. This is a significant milestone and a fundamental building block for your high-performance telemetry engine.

Key Takeaways:

  • The pop operation completes the acquire-release handshake initiated by the push operation.
  • An acquire load synchronizes with a release store on the same atomic variable, ensuring visibility of prior writes.
  • In pop, we use memory_order_acquire when loading the tail_ index to ensure we see the data published by the producer.
  • We use memory_order_release when storing the new head_ index to publish the newly freed slot back to the producer.
  • This creates a complete, two-way, lock-free communication channel between the producer and consumer threads.

What's Next?

Our SPSC queue is now functionally correct from a memory ordering perspective. However, there's another, more subtle performance demon that lurks in multi-core systems: false sharing. In our next lesson, we will explore how CPU caches work and why false sharing can silently degrade performance. We will then learn how to defeat it using memory alignment and padding, which is the final optimization for our core ring buffer implementation.