Hello! Welcome back to our deep dive into C++ concurrency.
In our last lesson, we explored the C++ memory model, focusing on how std::atomic memory ordering (relaxed, acquire-release, seq_cst) provides a contract between the programmer and the machine to control the visibility of memory operations. We learned that these rules are essential for writing correct lock-free code.
Today, we'll investigate a subtle but severe performance pathology that arises directly from the hardware mechanics we've been studying: false sharing. This issue occurs when your code is logically correct, and you're not sharing data, but performance suffers dramatically because of how CPU caches work. This lesson is a perfect example of the "under the hood" knowledge you're seeking, as it connects high-level C++ code directly to low-level cache behavior in multi-core systems.
Your goal for this lesson is to learn how to identify, analyze, and mitigate false sharing by understanding the behavior of CPU cache lines.
Part 1: Observing the Phenomenon
False sharing often manifests as a mysterious lack of scalability. You write a concurrent algorithm, throw more threads at it, and performance either doesn't improve or gets worse.
Let's start by observing a real-world example of this strange behavior.
code::dive conference 2014 - Scott Meyers: Cpu Caches and Why You Care
In this classic talk, renowned C++ expert Scott Meyers presents a concurrent programming puzzle from Herb Sutter that demonstrates how a simple code change leads to drastically different scalability. This is the quintessential example of false sharing in action.
Watch from the beginning up to 10:41. Pay close attention to the two versions of the concurrent counting algorithm and their shocking performance graphs. The key is to understand the problem before we dive into the solution.
As you saw, the first attempt to parallelize the counter resulted in terrible performance. The second version, which simply used a local variable for each thread's count, achieved perfect scalability. Both versions are logically correct, but their performance is worlds apart. This mystery is what we're here to solve, and the answer lies in CPU caches.
Part 2: The Root Cause - Cache Lines and Coherency
To understand false sharing, we must first understand how modern CPUs access memory. As you know from your OS studies, there's a significant speed gap between the CPU and main memory (DRAM). To bridge this gap, CPUs use multiple levels of small, fast memory called caches (L1, L2, L3).
The crucial detail is that data isn't moved from main memory to the cache one byte at a time. It's moved in fixed-size chunks called cache lines. On most modern x86-64 processors, a cache line is 64 bytes.

When multiple cores are involved, a problem arises: if Core 1 modifies a piece of data in its local cache, how does Core 2 know that its own copy of that data is now stale? This is the cache coherence problem.
Modern CPUs solve this with a cache coherence protocol. The most common one is MESI (or a variant thereof). This protocol associates a state with each cache line in each core's cache.
Cache Coherence: How the MESI Protocol Keeps Multi ...
To understand why false sharing happens, we need to look at the underlying hardware protocol. Most modern CPUs use a protocol like MESI to maintain cache coherence. This article explains how it works and explicitly calls out false sharing as a limitation.
Read the sections 'MESI Protocol' and 'Limitations of MESI'. You don't need to memorize every state transition, but focus on the purpose of the four states (Modified, Exclusive, Shared, Invalid) and how a write operation by one core can force a cache line to be invalidated in other cores. The 'Limitations' section directly connects this mechanism to false sharing.
The key insight from the MESI protocol is that it operates at the granularity of a cache line. If Core 1 writes to any byte within a cache line, that entire line is marked as 'Modified'. If Core 2 has a copy of that same line (even if it's interested in a completely different variable on that line), its copy must be invalidated. If Core 2 then wants to access its variable, it incurs a cache miss and must fetch the updated line from Core 1. This "ping-ponging" of the cache line between cores is the essence of false sharing.
Let's see Scott Meyers connect these ideas to the concurrent counter example.
code::dive conference 2014 - Scott Meyers: Cpu Caches and Why You Care
Now, let's connect the concepts of cache lines and coherency back to the performance puzzle. Scott Meyers explains why the first concurrent counter performed so poorly.
Watch the segment from 32:11 to 37:50. This is the crucial part where he explains how writes to independent data on the same cache line cause invalidations and performance loss. He then applies this directly to the Herb Sutter example, revealing why the array of counters caused false sharing.
Part 3: Identifying and Mitigating False Sharing in C++
Now that we understand the theory, let's bring it into the practical world of C++ programming.
Identifying False Sharing
- Symptom: Your multi-threaded code doesn't scale as expected, or even slows down, as you add more threads.
- Confirmation: Use a profiling tool to measure cache-related performance counters. A hallmark of false sharing is an abnormally high rate of L1 data cache misses (L1-dcache-load-misses) under a multi-threaded load, which disappears in a single-threaded run.
Mitigating False Sharing
The solution is to ensure that independent data accessed by different threads resides on different cache lines. The most common way to achieve this in C++ is through alignment and padding.
Since C++11, we have the alignas() specifier, which gives us direct control over a variable's memory alignment.
// A structure susceptible to false sharing if two threads
// access counterA and counterB respectively.
struct Counters {
std::atomic<int> counterA;
std::atomic<int> counterB;
};
// A structure designed to prevent false sharing.
// C++23 offers std::hardware_destructive_interference_size for portability.
// Before C++23, 64 is a common, safe value for x86.
struct PaddedCounters {
alignas(64) std::atomic<int> counterA;
alignas(64) std::atomic<int> counterB;
};
By using alignas(64), we instruct the compiler to place counterA at the beginning of a 64-byte boundary. Because counterB also has this requirement, the compiler must place it on the next 64-byte boundary, guaranteeing they cannot be on the same cache line.
The following video provides a fantastic, hands-on demonstration of this entire process.
Advanced Topics: False Sharing
Let's put this theory into practice with C++ code. This video demonstrates false sharing with a simple benchmark using std::atomic, shows how to measure the performance impact with perf stat, and implements the standard fix using alignas.
Watch from 3:47 until the end. This is a very practical walkthrough. Pay close attention to: The four benchmark cases: single-threaded, direct sharing, false sharing, and the fixed 'no sharing' version. The use of alignas(64) to mitigate the problem. The before-and-after performance numbers. How to use perf stat to see the dramatic increase in L1D cache misses.
Test your understanding!
You have a struct that will be used in an array, where each thread i will only modify thread_data[i].
struct ThreadData {
uint64_t value;
std::atomic<bool> flag;
};
std::vector<ThreadData> thread_data(num_threads);
Assuming a 64-byte cache line, is this code susceptible to false sharing? How would you fix it?
Show answer
Yes, this code is highly susceptible to false sharing. The ThreadData struct is only 16 bytes (uint64_t is 8, std::atomic<bool> is typically 1, but padded to 8 for alignment). This means up to four ThreadData objects can fit on a single 64-byte cache line. When thread 0 writes to thread_data[0].flag and thread 1 writes to thread_data[1].flag, they will likely be modifying data on the same cache line, causing it to bounce between their respective cores.
To fix it, you should ensure each ThreadData object occupies its own cache line using alignas:
struct alignas(64) ThreadData {
uint64_t value;
std::atomic<bool> flag;
// The compiler will add padding here to ensure the total
// size is a multiple of 64, so sizeof(ThreadData) will be 64.
};
std::vector<ThreadData> thread_data(num_threads);
Now, each element in the vector will be on its own cache line, eliminating the false sharing problem.
Part 4: Advanced Detection with perf c2c
While perf stat is great for spotting the symptoms of false sharing (high cache misses), Linux provides a more powerful diagnostic tool specifically for this problem: perf c2c (Cache-to-Cache).
This tool records events related to cache line contention between cores. Its report highlights cache lines that are frequently being accessed by multiple cores, particularly those that are hit while in a "Modified" state in another core's cache.
Detecting False Sharing with Perf C2C
perf c2c is an advanced tool that's extremely valuable for performance engineering. This article explains how to use it to get a definitive diagnosis of false sharing.
Read the sections 'Perf C2C' and 'Using Perf C2C'. Focus on understanding what a 'Hitm' (Hit in Modified cache line) event signifies and how the 'Shared Data Cache Line Table' in the report helps you identify the exact cache line that is bouncing between cores.
Knowing how to use a tool like perf c2c is a significant step toward deep systems debugging and performance analysis, aligning perfectly with your career goals.
Conclusion
Today, we've peeled back another layer of the machine to understand a crucial performance issue in concurrent programming. False sharing is a classic problem that demonstrates that writing correct parallel code isn't enough; you must also write code that is " mechanically sympathetic" to the underlying hardware.
Key Takeaways:
- Cause: False sharing occurs when logically independent variables, modified by different threads, happen to reside on the same physical cache line.
- Mechanism: The CPU's cache coherence protocol (e.g., MESI) operates on entire cache lines. A write by one core invalidates that line in all other cores, forcing expensive cache misses and data transfers, even if the threads are accessing different bytes.
- Identification: The primary symptom is poor scalability. It can be confirmed by observing a high L1 data cache miss rate (
perf stat) or directly diagnosed by looking for high "Hitm" counts on specific cache lines (perf c2c). - Mitigation: The solution is to structure your data to prevent this unintentional sharing. The most direct method in C++ is to use
alignas()to pad your data structures to the size of a cache line (e.g., 64 bytes).
What's Next?
With this lesson, you've gained a deep understanding of a hardware-level performance hazard. In our next lesson, we will shift from these low-level concerns back to a higher-level C++ abstraction for managing asynchronous tasks: std::promise and std::future. These tools help us reason about the results of operations that run in the background. Your knowledge of issues like false sharing will remain valuable, as it informs how you should structure the data that these asynchronous tasks operate on.