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.

Implementing the Non-Blocking pop Operation
Let's build the pop function. The logic is the mirror image of push:
- Atomically load the current
head_index. Since only this thread modifieshead_, no special ordering is needed here. - 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 usememory_order_acquire. - If the queue is not empty, copy the data from the buffer. This is safe because the
acquireload has guaranteed the data is visible. - Finally, update the
head_index to signal that the slot is now free. This update must be areleaseoperation 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:
-
head_.load(std::memory_order_relaxed): The consumer thread is the only thread that writes tohead_. When it readshead_, it is only checking its own state. There is no other thread to synchronize with for this value, so arelaxed(i.e., no ordering guarantees) operation is correct and the most performant choice. -
tail_.load(std::memory_order_acquire): This is the heart of thepopoperation. Thetail_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 useacquiresemantics. Thisacquireload pairs with thereleasestore in thepushfunction, establishing the happens-before relationship that makes the data visible. -
item = buffer_[current_head & mask_]: This is a simple, non-atomic read. It's safe because theacquireoperation in the previous step acted as a barrier, guaranteeing that this read happens after the visibility of the producer's write has been established. -
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'spushfunction performs anacquireload onhead_to check if the buffer is full. Ourreleasestore here synchronizes with the producer'sacquireload, 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
popoperation completes the acquire-release handshake initiated by thepushoperation. - An
acquireload synchronizes with areleasestore on the same atomic variable, ensuring visibility of prior writes. - In
pop, we usememory_order_acquirewhen loading thetail_index to ensure we see the data published by the producer. - We use
memory_order_releasewhen storing the newhead_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.