Skip to content
← All articles

False sharing: correct code, ten times slower

Two counters that share nothing can still fight, because the hardware's unit of sharing is not the variable — it is the 64-byte cache line. Why this is real, why it can be a 10x effect, and why no test in this course can catch it.

Everything in this track so far has been about correctness. This one is about a program that is completely correct, has no data race, no lock, no deadlock — and runs an order of magnitude slower than it should, for a reason that is invisible in the source.

It also cannot be graded here, and saying why is part of the lesson: the effect is timing-only. The fast version and the slow version compute identical results. Grading on wall time would make every test flaky on a busy laptop, so this course does not, and so this is an article.

The hardware’s unit of sharing is not your variable

Caches do not track individual bytes. They track cache lines — 64 bytes on x86-64 and on most ARM cores, 128 on Apple Silicon’s performance clusters. A line is the smallest thing a core can own.

The coherence protocol works at that granularity too. For a core to write anything in a line, it must acquire that line exclusively, which means invalidating every other core’s copy. When the write completes and another core wants the line, it must be transferred back.

Now consider:

let counters: Vec<AtomicU64> = (0..4).map(|_| AtomicU64::new(0)).collect();
// four threads, thread i only ever touches counters[i]

Four u64s are 32 bytes. They are all on the same cache line. Each thread touches only its own counter, shares nothing logically, and yet every increment by any thread invalidates the line for all the others. The line ping-pongs between cores, and each fetch_add waits for a cache-line transfer instead of hitting L1.

That is false sharing: contention with no sharing. The word “false” is doing real work — there is genuinely nothing shared at the level you wrote your program, and the hardware does not care.

What it costs

Orders of magnitude are plausible, and the direction is reliable even though the number is not. An L1 hit is a couple of cycles. A cache line bounced from another core’s L1 is tens to low hundreds. Put four threads in a tight loop on adjacent counters and you can be an order of magnitude slower than the same code with the counters spread out — sometimes slower than a single thread doing all the work.

The worst part is the shape of the symptom: adding threads makes it slower, which sends people looking at their locks. There are no locks.

Two fixes

Pad to a cache line. Give each counter the whole line:

#[repr(align(64))]
struct Padded(AtomicU64);

#[repr(align(64))] forces both the alignment and the size to a multiple of 64, so consecutive elements of a Vec<Padded> land on different lines. You are trading memory (64 bytes for an 8-byte counter) for the absence of coherence traffic. crossbeam-utils ships this as CachePadded, which also knows about the platforms where the answer is 128.

Or share nothing at all. Accumulate into a plain local, and merge once at the end:

let mut local = 0u64;                 // a register. no cache traffic at all.
for x in chunk { local += f(x); }
total.fetch_add(local, Relaxed);      // one atomic per thread, not per item

This is almost always the better answer, and it is the same instinct as everywhere else in this track: the fastest shared state is the shared state you do not have. One atomic operation per thread instead of one per element removes the false sharing and the true contention, and needs no repr attribute anyone has to understand later.

💡Under contention, an Arc<Mutex<u64>> counter can be dramatically slower than N independent counters merged at the end — but so can Arc<AtomicU64>. Why is switching the mutex for an atomic not the fix? click to reveal

Because the cost is the sharing, not the primitive.

Every thread incrementing one shared location — mutex, atomic, whatever — forces the same cache line to migrate between cores on every update. The line can only be in one core’s cache in the exclusive state at a time, so the updates are serialised by physics, not by the API. An atomic removes the blocking; it does not remove the line transfer.

Under heavy contention an atomic counter and a mutex counter converge, because both are bounded below by the same coherence traffic. The step change comes from removing the shared location: per-thread accumulators, merged once. That turns N million line transfers into N.

Which is the reason to be suspicious of “just use an atomic, it is lock-free” as a performance argument. Lock-free changes the progress guarantee. It does not change how many times a cache line has to cross the die.

How you would find it

Not from the source — this is invisible in the source, which is what makes it worth a page.

You find it with a profiler that reads hardware performance counters: perf c2c on Linux is built for exactly this and will name the cache line and the two instructions fighting over it. On macOS, Instruments’ counter templates. On Intel, VTune’s memory-access analysis.

The heuristic that usually gets there faster: if adding threads makes it slower and there are no locks, look at what is adjacent in memory.

What to actually do

  1. Default to per-thread accumulation and a single merge. It is simpler and faster.
  2. Reach for padding when you genuinely need per-thread state that others must read live — statistics, progress counters, sharded structures.
  3. Do not scatter #[repr(align(64))] around speculatively. It costs memory and cache footprint, and a padded struct that was not contended is just bigger.
  4. Measure. Every number on this page depends on core count, cache topology and contention level, and the ratios reverse under different mixes. The reasoning is portable; the numbers are not.