Hello! In our last lesson, we finalized the core logic of our SPSC queue by implementing non-blocking checks for full and empty conditions. Our queue is now functionally correct and safe. We even explored using cached indices to reduce the frequency of expensive atomic operations.
However, a hidden performance bottleneck remains, one that operates at the hardware level. In this lesson, we'll dive into the world of CPU caches to uncover and defeat a phenomenon known as false sharing. Your goal is to optimize cache behavior with aligned buffer sizes and padding to prevent false sharing. This is a crucial step for squeezing every last drop of performance out of our lock-free data structure, moving us closer to a truly zero-overhead telemetry system.
How CPUs Access Memory: The Cache Line
To understand false sharing, we first need to understand how modern CPUs access main memory (RAM). Accessing RAM is extremely slow compared to the speed of the CPU itself. To bridge this speed gap, CPUs use several layers of small, fast memory called caches (L1, L2, L3).
When your program needs to read a variable from memory, the CPU doesn't just fetch that single variable. Instead, it fetches a whole chunk of adjacent memory and stores it in the cache. This chunk is called a cache line. On most modern x86-64 processors, a cache line is 64 bytes.
The idea behind this is spatial locality: if you access one piece of data, you are very likely to access nearby data soon after. By pre-loading a whole cache line, the CPU anticipates your needs and avoids many slow trips to RAM.
This presentation slide provides a concise explanation of cache lines and includes a simple benchmark that powerfully illustrates their effect. It shows that accessing every 16th integer in a large array is no slower than accessing every single one, because they all fall within the same fetched cache lines.
Please review the two slides titled 'Fun with Cache lines' (slides 24 and 25 of the PDF). Focus on the core concepts: memory is accessed in cache lines (typically 64 bytes), not individual variables, due to spatial locality.
The Problem: When Caches Collide
In a multi-core system, each core often has its own private L1 and L2 cache. This raises a question: what happens if Core A and Core B both have a copy of the same memory in their caches, and Core A modifies it? To prevent Core B from using old, stale data, the CPU architecture has a cache coherency protocol.
When Core A writes to a cache line, the protocol invalidates that same cache line in all other cores. The next time Core B wants to read from that line, it will have a "cache miss" and be forced to fetch the updated line from Core A's cache or from main memory—a slow process.
This is where false sharing emerges. The coherency protocol operates on entire cache lines, not individual variables.
False Sharing occurs when:
- Two or more threads are accessing different variables.
- These variables happen to reside on the same cache line.
- At least one thread is writing to its variable.
Even though the threads are not sharing data from a programming perspective, the hardware treats it as a conflict. Each write from one thread invalidates the cache for the other threads, causing constant, expensive cache misses. The threads are "falsely" sharing a cache line.

This effect can be so severe that it completely negates the benefits of parallel execution.
To see just how damaging false sharing can be, let's watch a practical demonstration from the video 'Parallel C++: False Sharing' by Nick. He sets up a benchmark where four threads increment their own separate atomic counters. Intuitively, this should be perfectly parallel, but the performance tells a different story.
Watch from 07:36 to 12:33. Notice how the performance of the 'false sharing' example with separate counters is nearly as slow as the 'direct sharing' example with a single counter. The speaker then uses performance analysis tools to prove that all four counters are on the same cache line, causing massive contention.
The Solution: Alignment and Padding
Now that we understand the problem, the solution is straightforward: we must ensure that variables accessed by different threads are placed on different cache lines. We can achieve this using memory alignment.
C++ gives us the alignas specifier to control the memory alignment of a variable or struct. To prevent false sharing, we should align our data to the size of a cache line.
But what is the cache line size? While it's commonly 64 bytes, it can vary. Hardcoding 64 is not portable. Since C++17, the standard library provides a constant for exactly this purpose: std::hardware_destructive_interference_size. This constant represents the recommended alignment to avoid performance degradation due to contention between threads.
Let's see how to apply this to fix the problem from the video.
Let's continue with the same video to see the solution in action. The speaker wraps the atomic counter in a struct and uses alignas to enforce cache-line alignment.
Watch from 13:22 to 16:39. Pay close attention to the AlignedAtomic struct and how the alignas(64) specifier is used. The resulting performance improvement is dramatic, showcasing a nearly perfect 4x speedup on four threads.

Applying the Fix to Our SPSC Queue
Where is the false sharing hiding in our SPSC ring buffer? It's right at the heart of our implementation: the head_ and tail_ indices!
- The consumer thread continuously reads and writes to
head_. - The producer thread continuously reads and writes to
tail_.
If we declare them side-by-side in our class, they are almost guaranteed to fall on the same cache line:
class SPSCQueue {
private:
// ...
std::atomic<size_t> head_; // Written by consumer
std::atomic<size_t> tail_; // Written by producer - potential false sharing!
T* buffer_;
};
Every time the producer pushes an item and updates tail_, it invalidates the cache line containing head_ for the consumer thread. And every time the consumer pops an item and updates head_, it invalidates the cache line for the producer. This constant back-and-forth traffic between the cores' caches, known as "cache line ping-pong," can cripple our queue's performance.
Let's watch an expert implement the fix for this exact problem in an SPSC queue and observe the performance impact.
Single Producer Single Consumer Lock-free FIFO From the Ground Up - Charles Frasch - CppCon 2023
In this segment from his CppCon 2023 talk, Charles Frasch demonstrates the massive performance gain from eliminating false sharing in an SPSC queue.
Please watch from 26:43 to 34:05. The speaker first explains that the default sequentially consistent atomics have a performance cost. Then, he identifies false sharing as another major issue. He introduces std::hardware_destructive_interference_size and applies it using alignas to the push_cursor and pop_cursor. Notice the incredible performance jump from ~12M ops/sec to ~50M ops/sec after applying acquire-release semantics and fixing false sharing.
To implement this cleanly in our code, we can define a simple templated struct that handles the padding for us.
#include <new> // For std::hardware_destructive_interference_size
template <typename T>
struct CacheLinePadded {
alignas(std::hardware_destructive_interference_size) T value;
};
Now, we can update our SPSCQueue class definition:
class SPSCQueue {
private:
// ...
CacheLinePadded<std::atomic<size_t>> head_;
CacheLinePadded<std::atomic<size_t>> tail_;
// We also need to pad our cached indices from the last lesson!
CacheLinePadded<size_t> head_cached_;
CacheLinePadded<size_t> tail_cached_;
T* buffer_;
size_t mask_;
};
By wrapping our atomic and cached indices in this struct, the compiler will automatically add enough padding to each one to ensure it occupies its own cache line, completely eliminating the false sharing problem between the producer and consumer threads.
Test your understanding!
Imagine you have a struct with two std::atomic<int> members, varA and varB. Thread 1 only ever writes to varA, and Thread 2 only ever writes to varB.
struct Counters {
std::atomic<int> varA;
char padding[60]; // Assuming int is 4 bytes
std::atomic<int> varB;
};
Is this manual padding a robust way to prevent false sharing? What is a potential problem with this approach?
Show answer
This is not a robust solution. While it might work on a system where int is 4 bytes and the cache line is 64 bytes, it has two major flaws:
- Not Portable: It relies on hardcoded sizes. If you compile this on a system with a different cache line size (e.g., 128 bytes) or a different size for
int, the padding will be incorrect, and false sharing will re-emerge. - Compiler Reordering: The C++ standard allows the compiler to reorder member variables within a class/struct for optimization purposes (unless you are using specific C++20 features to control layout). The compiler could potentially place
varAandvarBnext to each other, rendering thepaddingarray useless.
The alignas(std::hardware_destructive_interference_size) approach is the correct, modern, and portable solution because it directly instructs the compiler on the alignment requirement, which it must respect.
Conclusion
You have now tackled one of the most important and subtle performance issues in modern concurrent programming. Understanding how the hardware actually works is fundamental to writing high-performance systems code. By aligning our SPSC queue's head and tail pointers to the cache line size, we've ensured that our producer and consumer threads can operate truly independently without stepping on each other's toes at the hardware level.
Key Takeaways:
- CPUs read memory in cache lines (e.g., 64 bytes) to improve performance.
- Cache coherency protocols ensure data consistency across multiple cores' caches but operate on entire cache lines.
- False sharing occurs when different threads modify different variables that happen to be on the same cache line, causing severe performance degradation due to cache invalidations.
- The solution is to use
alignas(std::hardware_destructive_interference_size)to force each shared variable onto its own unique cache line. - In our SPSC queue, the
head_andtail_indices are the primary culprits for false sharing and must be aligned.
What's Next?
Our SPSC queue is now extremely well-optimized at the hardware level. In the next lesson, we will implement one final algorithmic optimization: batch operations. Instead of pushing and popping one item at a time, we will modify our queue to allow the producer and consumer to transfer multiple items with a single set of atomic operations. This will further reduce synchronization overhead and maximize the throughput of our data pipeline.