The question this answers
Two threads never touch the same variable — why did adding the second thread make it slower?
Each of N worker threads increments its own counter in a shared array: counts[threadId]++, millions of times.
Nothing at the program level — each thread owns its own array slot exclusively. At the hardware level, several slots share one cache line.
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.
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.
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.
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.
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 misleads8 }9}1uint64_t counts[8];2 3void worker(int id) {4 uint64_t local = 0; // lives in a register5 for (uint64_t i = 0; i < 100'000'000; ++i) {6 local++; // no memory traffic, no coherence, no sharing7 }8 counts[id] = local; // one write, once9}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 counterNeither 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.
- • 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.
- • 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.
- • 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.
- • 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.
- • 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.
- • 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 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.
- • The stride test: change
counts[id]tocounts[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 c2con 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.
- • 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.
- • 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_sizein 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
struct Counters {
long a; // byte 0..7
long b; // byte 8..15 <- same 64-byte line as a
}; // sizeof == 16Why is 8 cores only 4.5×?
| workers | ideal | Amdahl only | realistic | limited by |
|---|---|---|---|---|
| 1 | 1.0× | 1.00× | 1.00× | none |
| 2 | 2.0× | 1.90× | 1.85× | serial |
| 4 | 4.0× | 3.48× | 3.19× | serial |
| 6 | 6.0× | 4.80× | 4.17× | serial |
| 8 | 8.0× | 5.93× | 4.17× | bandwidth |
| 10 | 10.0× | 6.90× | 4.17× | bandwidth |
| 12 | 12.0× | 7.74× | 4.17× | bandwidth |
| 14 | 14.0× | 8.48× | 4.17× | bandwidth |
| 16 | 16.0× | 9.14× | 4.17× | bandwidth |
Eight threads, one lock
What people believe, and what is true
The threads do not share any data, so there cannot be contention.
The hardware's unit of ownership is the line, not the variable. Independent variables in one line contend exactly as if they were shared.
The profiler says the increment is hot, so incrementing is expensive.
The increment is where the stall is attributed, not where the cost comes from. The cost is fetching the line back from another core.
Adding a lock or making it atomic will help.
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.