Memory Models & Visibility

False Sharing: Different Variables, Same Cache Line

Two threads update two different counters and never touch each other's data. The counters sit in the same cache line, so every update takes the line away from the other core. Correctness is untouched; the speedup you added a thread for never appears.

▶ Run the lab

The question this answers

The question

Two threads never touch the same variable — why did adding the second thread make it slower?

The work

Each of N worker threads increments its own counter in a shared array: counts[threadId]++, millions of times.

What is shared

Nothing at the program level — each thread owns its own array slot exclusively. At the hardware level, several slots share one cache line.

The invariant — what must stay true under every interleaving

Each counter equals its own thread's increment count. Correctness holds under every interleaving; only throughput is destroyed.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

One line, several owners, no sharing

A cache line is the unit the hardware moves and tracks — commonly 64 bytes on x86-64 and 128 bytes on Apple silicon, and always more than one 8-byte counter. Coherence is maintained per line, not per variable. So when core 0 writes counts[0], it must take exclusive ownership of the whole line, which includes counts[1] through counts[7], which core 1 is writing at the same time.

The result is a ping-pong. Core 0 takes the line, core 1 takes it back, core 0 takes it again — every increment, forever. Neither core is waiting on a lock, neither is blocked, and both are at 100% CPU. They are spending it moving one line back and forth. The program is correct, the profiler shows the increment line as hot, and the obvious conclusion — "incrementing is slow" — is wrong.

The name is exact and worth taking literally. There is no sharing in the program. The sharing is an artefact of the layout, which means the fix is also a layout change and not a synchronization change. Adding a lock here would make it slower and no more correct.

Two cores, two variables, one line. The line is the unit of ownership.
counts[0]++counts[1]++needs the whole line, exclusiveneeds the whole line, exclusivecoherence traffic on every incrementseparate the variablesCore 0 — writes counts[0]Core 1 — writes counts[1]Core 0 L1 cacheCore 1 L1 cacheONE 64-byte line: counts[0..7]Exclusive ownership moves on every writeFIX: one counter per line (padding), or per-thread locals
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

The speedup that fails to appear

This is the signature: a workload that is embarrassingly parallel, with no locks, no shared state and no I/O, that gets *slower* as you add threads. Every intuition says it should scale linearly, which is why the diagnosis usually takes days — engineers look for a lock that is not there.

The curve below models the shape. One thread is the baseline. Two threads are already worse than one on a per-thread basis, and by eight the aggregate throughput can sit below the single-threaded number. The padded version, where each counter has its own line, tracks close to ideal because there is genuinely nothing shared.

Read this as a shape, not as numbers you should expect. The magnitude depends on the line size, the interconnect, whether the cores share an L2 or an L3, and how tight the loop is. What is robust across machines is the *sign*: false sharing makes added threads unhelpful or harmful, in a workload where you can prove no data is shared.

Modelled aggregate throughput: counters packed into one line versus one counter per line.SIMULATED
1 workerdashed = linear speedup8 workers · max 8.0×
Modelled to show the shape, not measured. The packed points fall below 1.0 — adding threads makes the program slower than single-threaded — which is the diagnostic signature. Magnitudes depend entirely on line size, cache topology and loop tightness; the direction does not.

Padding, per-thread locals, and how to confirm it

Two fixes, and the second is usually better. Padding gives each counter its own line, typically with an alignment attribute. It works, it is explicit, and it costs memory proportional to line size times thread count — which is trivial for a handful of counters and wasteful for a large array.

The better fix in most cases is to stop writing to shared memory in the hot loop at all. Accumulate into a plain local variable and write the result once at the end. The local lives in a register, there is no coherence traffic whatsoever, and the code is simpler than the padded version. This also removes the atomic if there was one. Reach for padding when the value genuinely must be readable by others *during* the loop; otherwise accumulate locally.

Confirming the diagnosis matters, because the symptom — a hot instruction and poor scaling — has other causes. The decisive test is a one-line experiment: change the stride so each thread writes counts[threadId * 16] instead of counts[threadId]. If throughput jumps, it was false sharing. That experiment takes a minute and settles the question, which is worth far more than reasoning about it.

Packed counters: no shared data, no locks, and it does not scale.
1// 8 counters, 8 bytes each = 64 bytes = exactly one cache line.
2// Every thread's increment steals the line from every other thread.
3uint64_t counts[8];
4
5void worker(int id) {
6 for (uint64_t i = 0; i < 100'000'000; ++i) {
7 counts[id]++; // hot in the profile, and the profile misleads
8 }
9}
Accumulate locally, publish once. No coherence traffic in the loop at all.
1uint64_t counts[8];
2
3void worker(int id) {
4 uint64_t local = 0; // lives in a register
5 for (uint64_t i = 0; i < 100'000'000; ++i) {
6 local++; // no memory traffic, no coherence, no sharing
7 }
8 counts[id] = local; // one write, once
9}
10
11// If the value MUST be readable during the loop, pad instead:
12struct alignas(64) PaddedCounter { std::atomic<uint64_t> n{0}; };
13PaddedCounter padded[8]; // one line each; costs 64 bytes per counter

Neither version changes the synchronization, because there was never a synchronization problem — both are correct in every interleaving. The change is purely about where the bytes sit. Local accumulation is preferred because it removes the memory traffic entirely rather than spreading it out, and because it costs no memory. Padding is for the case where other threads must observe the value while the loop runs.

Key points

  • Coherence is tracked per cache line, not per variable, so two independent variables in one line contend as if they were one.
  • The signature is a lock-free, share-nothing workload that gets slower as threads are added — often below the single-threaded baseline.
  • It is a performance bug only. Every value stays correct under every interleaving, which is why no correctness tool finds it.
  • The best fix is usually to accumulate in a local and write once, not to pad — padding spreads the traffic, locals eliminate it.
  • The decisive diagnostic is a one-line stride change. If spacing the writes 16 slots apart fixes throughput, it was false sharing.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • The hardware maintains coherence at the granularity of a cache line, which is larger than most individual variables.
  • A core writing any byte of a line must obtain exclusive ownership of the entire line.
  • When two cores write different bytes of the same line, each acquisition invalidates the other core's copy.
  • The line migrates between cores on every write, so each increment pays an interconnect round trip instead of hitting L1.
  • Nothing blocks and no lock is involved, so the cores stay at full utilisation while doing almost no useful work.
Interleavings that matter
  • Core 0 writes counts[0] (takes the line exclusive); core 1 writes counts[1] (takes it back); core 0 writes counts[0] again (takes it back). Repeat forever. Both values remain correct throughout.
  • With one counter per line: core 0 and core 1 each hold their own line exclusively and never invalidate each other. Same program, same results, near-linear scaling.
  • With local accumulation: no line is written in the loop at all. Both cores run entirely out of registers and L1.
  • A related instance: a lock and the data it protects in the same line, so a thread spinning to acquire invalidates the line the holder is writing through. Padding the lock separately is the standard fix.
  • Another instance: a producer writing a head index and a consumer writing a tail index in adjacent words of a ring buffer — a queue that is logically contention-free and physically is not.
What it guarantees — and does not
  • Promises: nothing about correctness changes. Every value is exactly what the program computed, under every schedule.
  • Promises: padding to line size removes the effect between the padded objects, on a machine whose line is no larger than the padding.
  • Does NOT promise: that padding is portable. A 64-byte pad does not separate variables on a 128-byte-line machine.
  • Does NOT promise: that the effect is present. Adjacency is necessary and not sufficient — the variables must also be written concurrently and frequently.
  • Does NOT promise: that a profiler will point at it. The hot instruction is the increment, which is not the problem.
  • Does NOT promise: that adding synchronization helps. A lock over this makes it strictly slower and no more correct.
Where contention appears
  • This is contention with no lock and no waiting: the resource is exclusive ownership of one line, and it is contended on every write.
  • The cost per increment goes from an L1 hit to an interconnect round trip, which is a large multiple even on a single socket.
  • It scales badly in the worst way: more cores means more invalidations per unit work, so throughput can decrease monotonically with thread count.
  • Cross-socket is dramatically worse than within a socket, which makes the effect NUMA-sensitive. See NUMA: Not All Memory Costs the Same.
How it fails
  • Negative scaling — the program is slower with eight threads than with one, in a workload with provably no shared data.
  • Misdiagnosis as an atomic-operation cost, leading to a rewrite that keeps the layout and changes nothing.
  • Misdiagnosis as a locking problem, leading to a lock being added or removed with no effect.
  • Regression on a data-structure change — inserting a field, reordering members, or changing an array of structs to a struct of arrays can create or remove it invisibly.
  • Platform-dependent regression — code padded for a 64-byte line ships to a 128-byte-line machine and the effect returns.
When it helps
  • Knowing the pattern turns a multi-day scaling investigation into a one-minute stride experiment.
  • It is the standard explanation for why per-thread statistics arrays underperform, and per-thread stats are extremely common.
  • It generalises: any concurrently written data structure should be reviewed for adjacency of independently written fields — queue indices, lock words, sharded counters.
When it hurts
  • When padding is applied prophylactically to large arrays, multiplying memory use and destroying spatial locality for the single-threaded case.
  • When it becomes the assumed cause of every scaling problem, displacing measurement.
  • When alignment attributes are added without knowing the target's line size, which is a guess wearing the clothes of a fix.
How you would know
  • The stride test: change counts[id] to counts[id * 16] and re-run. A large throughput jump is a positive diagnosis and takes a minute.
  • Scaling curve: measure aggregate throughput at 1, 2, 4 and 8 threads. Any point below the single-threaded number in a share-nothing workload is this or NUMA.
  • Hardware counters, where available: coherence-miss and cross-core-invalidation events attributed to the increment instruction. perf c2c on Linux is built specifically for this and reports the offending line and the sharing threads.
  • Check the layout directly: print the addresses of the contended objects and confirm whether they fall inside one line-sized aligned block.
  • Watch for it as a regression signal after any struct-layout change, since the effect appears and disappears with field ordering. See Always-On Profiling, and the Diff That Finds Regressions.
Complexity it introduces
  • Padding puts a hardware constant into the source, which is a portability liability that needs a comment explaining what it is for.
  • Local accumulation changes the observability contract: the shared counter is now stale until the loop ends, which downstream code may depend on.
  • The bug is invisible to every correctness tool, so avoiding it depends on someone knowing the pattern — a knowledge dependency, not a tooling one.
  • Layout becomes something reviewers must think about when adding fields to concurrently written structures.
Simpler alternatives
  • Accumulate in a local and write once. Simpler and faster than padding, and the right default. See Reduction Ordering: The Sum Changed When the Worker Count Did for the aggregation step.
  • Per-thread or per-core state combined at a join point, which removes the adjacency question entirely. See Copy or Share?.
  • std::hardware_destructive_interference_size in C++17 instead of a hard-coded 64, where the toolchain provides a usable value — note that some standard libraries emit an ABI warning for it.
  • Structure-of-arrays to array-of-structures or vice versa, chosen so that concurrently written fields land in different lines.
  • Sharding with a stride larger than a line — a counter array where each thread's slot is line-aligned by construction.

Two counters, no lock, one cache line

Two counters, no lock, one cache line
Each thread increments its own counter. Nothing is shared in the source: different variables, no mutex, no atomics between them. The hardware shares things at a coarser granularity than your variable names do.
struct Counters {
    long a;   // byte 0..7
    long b;   // byte 8..15   <- same 64-byte line as a
};            // sizeof == 16
64-byte cache line
a
b
·
·
·
·
·
·
One line. Every write by either thread takes exclusive ownership of the whole line, so the other thread’s next write has to take it back.
1 workerdashed = linear speedup8 workers · max 8.0×
Speedup over one thread. The dashed line is what perfectly independent work would give — and this work is perfectly independent.
speedup at 8 threads
2.03×
ideal
counters per line
2
padded would give
7.78×
8 threads, 8 independent counters, zero locks — and 2.03× instead of 8×. The speedup does not arrive, and every tool you would reach for says the code is fine: no lock to profile, no contention counter to read, no shared variable to point at. The threads contend on a 64-byte cache line that happens to hold both counters, because sharing is decided by address, not by intent. Add the padding and the same code gives 7.78×. The lesson for this domain is a diagnostic one: when parallel code does not speed up and there is no lock in sight, ask what else the threads are sharing — a line, an allocator, a queue head, a metrics counter, the first slot of an array indexed by thread id. The fix is layout, not synchronization. And it is not free: padding costs memory and cache footprint, and per-thread accumulators cost a reduction step at the end, so pay it where a measurement told you to, not everywhere.
ILLUSTRATIVEThe curve is a composed model with false sharing entered as a per-worker synchronization cost, not a measurement. The size of the effect depends entirely on the processor, the cache-line size and how hot the loop is. Why sharing a line costs anything — the coherence protocol and its invalidation traffic — is Computer Architecture material and is deliberately not taught here.

Why is 8 cores only 4.5×?

Why is 8 cores only 4.5×?
Amdahl is one term of four. Turn each effect off and watch which part of the curve straightens.
1 workerdashed = linear speedup16 workers · max 16.0×
workersidealAmdahl onlyrealisticlimited by
11.0×1.00×1.00×none
22.0×1.90×1.85×serial
44.0×3.48×3.19×serial
66.0×4.80×4.17×serial
88.0×5.93×4.17×bandwidth
1010.0×6.90×4.17×bandwidth
1212.0×7.74×4.17×bandwidth
1414.0×8.48×4.17×bandwidth
1616.0×9.14×4.17×bandwidth
speedup at 8 workers
4.17×
best point on the curve
4.17× @ 6
past best, adding workers
costs
distinct causes on curve
serial, bandwidth
At 8 workers this configuration reaches 4.17× and the dominant cause is "bandwidth". Past 6 workers the cores are fed by a memory system that is already saturated — they are stalled, not computing. More threads make the stall queue longer. The fix is fewer bytes per unit of work (better locality, smaller types), not more parallelism. The reason to name the cause is that each one has a different fix, and three of the four get worse if you respond by adding threads.
SIMULATEDcomposed from named effects, not fitted to a measurement

Eight threads, one lock

Eight threads, one lock
Every thread does some work, then takes the same mutex. Watch how much of each lane is spent waiting for a turn, and what the machine actually delivers.
8 cores
Thread 1
work
lock
work
wait
lock
work
Thread 2
work
wait
lock
work
wait
lock
work
Thread 3
work
wait
lock
work
wait
lock
Thread 4
work
wait
lock
work
wait
Thread 5
work
wait
lock
work
wait
Thread 6
work
wait
lock
work
wait
Thread 7
work
wait
lock
work
wait
Thread 8
work
wait
lock
work
wait
runningreadywaitingblockedidle24 ms of wall clock
throughput
500/s
effective parallelism
2.50 / 8
lock busy
90.0%
mean lock wait
18 ms
serialised share of each task0.4 · 2.0 ms locked of 5.0 ms total — 40.0%
The critical section is busy 90% of the time. It is now the ceiling: more cores and more workers change nothing. Effective parallelism is 2.5 on 8 cores — the definition of false parallelism. Shrink the critical section or shard the lock. The critical section is 40.0% of each task, so 2.5 of 8 cores' worth of work is really happening at once. Waiting is not evenly distributed either: mean lock wait is 18 ms, and the tail is far worse than the mean because queueing delay grows non-linearly as the lock approaches saturation. Contention is not caused by threads; it is caused by the fraction of the work that must be serialised. Adding threads to a contended lock adds queue, not capacity — and past that point each extra thread makes the tail latency worse while leaving throughput exactly where it was.
SIMULATEDLanes are a discrete simulation of one mutex granted in arrival order; throughput comes from the lab model. Neither is a measurement, and real locks add cache-line traffic this omits.

What people believe, and what is true

Claim

The threads do not share any data, so there cannot be contention.

Reality

The hardware's unit of ownership is the line, not the variable. Independent variables in one line contend exactly as if they were shared.

Claim

The profiler says the increment is hot, so incrementing is expensive.

Reality

The increment is where the stall is attributed, not where the cost comes from. The cost is fetching the line back from another core.

Claim

Adding a lock or making it atomic will help.

Reality

Neither addresses the layout. An atomic increment on a bouncing line is slower, not faster, and correctness was never in question.

Go deeper

Overview

Two threads updating two different variables that happen to sit in the same 64-byte block will fight over that block, and the program slows down as you add threads.

Practical

In any share-nothing workload that scales badly, test the stride first. If spacing the writes fixes it, either pad to a line or — better — accumulate in a local and write once at the end.

Advanced

Audit layout wherever independently written fields sit adjacent: ring buffer head and tail indices, a lock word next to the data it guards, sharded counters, per-connection statistics. Each is a standard instance with a standard fix.

Internals

The mechanism is the coherence protocol's exclusive-ownership requirement for any write, applied at line granularity. The protocol states and the interconnect messages belong to Computer Architecture; what belongs here is the layout discipline that follows from the granularity.

Apply it