When Sharing Goes Wrong
We know caches transfer data in 64-byte chunks (cache lines). Now consider what happens in a multi-core system when two different threads on two different CPU cores try to modify independent variables that just happen to sit next to each other within the same 64-byte cache line.
The Scenario:
- Thread A on Core 0 wants to increment variable
counterA. The CPU loads the 64-byte cache line containingcounterAinto Core 0's L1 cache and marks it as Modified (using the MESI cache coherence protocol—Modified, Exclusive, Shared, Invalid). - Thread B on Core 1 wants to increment variable
counterB. Unfortunately,counterBis right next tocounterAin memory—within the same 64-byte cache line. - For Core 1 to write to that line, the MESI protocol requires Core 0 to flush its modified copy back to a shared cache level (L3 or RAM) and transition the line to Invalid. Core 1 then loads the line and marks it as Modified.
- Now Core 0 wants to increment
counterAagain. The same invalidation dance happens in reverse. - They get stuck in a loop of constantly invalidating each other's caches—a "ping-pong" effect.
The Result: Even though the threads aren't logically sharing any data—counterA and counterB are completely independent—the hardware sees them fighting over the same physical cache line. Each write forces a round-trip to L3 cache or beyond (~40–100 cycles), turning what should be a 1-cycle L1 write into a 50–100 cycle ordeal. This is False Sharing, and it can make a multi-threaded program run slower than a single-threaded one.
How to Detect False Sharing
On Linux, the perf c2c (cache-to-cache) subcommand is purpose-built for this: it records cache-line-level access patterns across cores, then reports which lines are being bounced between them and which variables live on those lines. Look for lines with high HITM (Hit In a Modified state) counts—these are the false sharing victims.
The Fix: Padding to Separate Cache Lines
The standard fix is to insert padding bytes between the conflicting variables to ensure they land on different 64-byte cache lines:
// Before: false sharing — both counters share one 64-byte cache line
struct Counters {
volatile long counter_a; // bytes 0–7
volatile long counter_b; // bytes 8–15 ← same cache line as counter_a
};
// After: padded to separate cache lines
struct Counters {
volatile long counter_a; // bytes 0–7
char padding[56]; // bytes 8–63 ← fills the rest of the cache line
volatile long counter_b; // bytes 64–71 ← now on a different cache line
};
In Java, the @jdk.internal.vm.annotation.Contended annotation (with the -XX:-RestrictContended JVM flag) achieves the same effect automatically. In Go, insert a _ [64]byte field between the conflicting fields. In C++17 and later, use alignas(64) on the struct or individual fields.
Key Point: False sharing is insidious because it doesn't show up in code reviews or algorithmic analysis. The code looks perfectly correct and well-parallelized. The only symptom is that adding more threads makes things slower instead of faster. If you ever see a multi-threaded workload where doubling the thread count yields zero speedup (or negative speedup), false sharing should be one of the first suspects—right alongside lock contention and Amdahl's Law limitations.