False sharing
advanced · occasionally asked
Two threads, two separate counters, one cache line — and adding the second thread makes it slower than one. Pad the struct and watch it recover.
The problem it solves
Two threads. Two separate counters. Neither touches the other’s. Each increments its own as fast as it can.
Adding the second thread makes the program slower than it was with one.
Nothing is shared, logically. The variables are distinct, no lock is involved, no atomic is contended. What is shared is the sixty-four bytes they happen to sit in — because cache coherence works in lines, not variables, and the hardware has no idea that your two counters are unrelated.
This is false sharing, and it is worth a page for three reasons: the slowdown is an order of magnitude rather than a few percent, nothing in the source code hints at it, and the fix is a memory layout change with no effect on what the program means.
The mechanism
A cache line is the unit of coherence, typically 64 bytes. When core A writes to any byte in a line, the protocol invalidates every other core’s copy of that whole line. Core B, which cares about a different variable in the same line, must now re-fetch it from A’s cache before it can proceed — and then B’s write invalidates A’s copy, and the line bounces back.
Each of those transfers is roughly the cost of a cache miss that has to go to another core’s cache: on the order of 80 nanoseconds, against about 1 nanosecond for an L1 hit. Two threads writing to the same line do not run twice as fast; they serialise on the line, and each pays a coherence round trip per write.
The consequence is counter-intuitive and worth stating plainly: throughput on a shared line is roughly constant regardless of thread count, at about one write per coherence round trip. Adding cores adds contention without adding capacity.
The fix is padding: place each variable in its own cache line, so the invalidations never collide. Wasteful of memory by construction — you are deliberately leaving fifty-six bytes empty — and worth it by orders of magnitude when the variables are hot.
What the enumeration shows
This is a model page rather than an interleaving space, because every ordering gives the same throughput; what varies is a layout.
At one thread, both lines sit at the same value — nothing to contend with. At two, the shared-line series drops below the single-thread figure, which is the result worth staring at. The second core did not add throughput; it added coherence traffic that both now pay.
Drag the thread count up and the shared line stays flat while the padded line climbs linearly. At eight threads the gap is roughly 640×. That number is not a typo and it is not a micro-optimisation: it is the difference between using your cores and not.
The constants are named in the source — 1ns for an L1 hit, 80ns for a coherence miss — so the curve is reproducible rather than asserted.
The numbers worth carrying
- 64 bytes is the cache line on x86 and ARM. Apple Silicon uses 128 for some caches; some prefetchers work in 128-byte pairs, which is why conservative padding uses 128.
- L1 hit ~1ns; coherence miss ~80ns. Roughly an 80× difference per operation.
- Eight threads, shared line versus padded: ~640× throughput difference in this model.
- An array of per-thread counters —
long[] countsindexed by thread id — puts eight counters in one line on a 64-bit machine. This is the single most common accidental instance, and it looks like the obvious way to write it.
Where it breaks down
It only matters for writes. Multiple cores can hold a line in shared state and read it simultaneously with no traffic at all. False sharing is a write-invalidation phenomenon, which is why read-mostly data does not suffer from it.
Padding costs memory. 64 bytes per counter instead of 8 is an eightfold increase. For sixteen thread-local counters that is a kilobyte and obviously worth it; for a million objects it is not, and the answer there is usually to restructure so the hot fields are grouped rather than to pad every object.
True sharing looks identical in a profile. If the threads genuinely contend on the same variable, padding fixes nothing — the line bounces because the data bounces. Distinguishing the two requires knowing whether the addresses differ, and that is what the hardware counters tell you.
It moves. Padding is a fact about layout, and layout is decided by the compiler, the allocator and the JIT. A JVM may rearrange fields; an allocator may place two padded objects adjacently anyway. This is why @Contended and alignas exist rather than manual dummy fields — they express the intent to the thing that actually decides.
What people get wrong
“Nothing is shared, so there is no contention.” The variables are not shared. The cache line is. That distinction is the whole page.
“It is a micro-optimisation.” An 80× per-operation penalty on a hot path is not micro. It is the reason a parallel loop can be slower than a sequential one.
“Adding threads cannot make it slower.” It can, and this is the cleanest example. Two threads on a shared line are worse than one.
“I will pad with a few extra fields.” The compiler may reorder or eliminate them. Use the platform’s mechanism — @jdk.internal.vm.annotation.Contended in Java (with -XX:-RestrictContended), alignas(64) in C++, #[repr(align(64))] in Rust, _ [64]byte padding in Go structs.
In production
Where it appears in real code: per-thread statistics arrays, where the natural counts[threadId] layout packs eight counters into one line; lock objects stored adjacently, so threads taking different locks still contend — which is a real hazard when striping a lock as on the lock granularity page; head and tail pointers of a queue, touched by producer and consumer respectively and traditionally declared next to each other; and hot fields beside cold ones in an object, where a frequently written counter shares a line with immutable configuration read by everybody.
The tooling: perf c2c on Linux is built specifically for this and will identify the contended line and the offending offsets. VTune has a false-sharing analysis. Failing those, the diagnosis by symptom is “parallel version is slower than sequential, CPU is high, no lock is contended”, and the experiment is to pad and re-measure.
The libraries that already solved it are worth reaching for first: LongAdder in Java is a padded, striped counter and is dramatically faster than an AtomicLong under contention; Disruptor pads its sequence numbers and documents why; crossbeam’s CachePadded in Rust is a wrapper type.
The follow-up questions
“Two threads, two separate counters, and it is slower than one thread. Why?” — Same cache line. If the answer is “contention”, the follow-up is “on what?”, and the answer is the line rather than the data.
“How do you fix it?” — Pad to a cache line, using the platform mechanism rather than dummy fields. Say what it costs in memory.
“How would you confirm it?” — perf c2c, or pad and measure. Guessing is how people pad things that were never contended.
“Where does it show up by accident?” — Per-thread counter arrays, adjacent locks, queue head and tail. Naming one unprompted is a strong signal.
In an interview
A real interview question and a real profiling story: the fix is a memory layout change with no effect on what the code means.
- false sharing
- cache line
- padding
- coherence
Run these next
- Visibility and store buffersA write is not visible when it happens, it is visible when it is published. This is where the interleaving model runs out and a memory model is required.
- Amdahl's lawThe speedup ceiling is 1 divided by the serial fraction, and the core count does not appear in it. Most of what parallelism will ever give you has arrived long before you run out of cores.
- Lock granularityA lock makes its critical section a serial fraction, so this is Amdahl’s law with a mutex in it. Splitting the lock widens the queue; shortening the critical section is usually cheaper and always simpler.