The mechanism
Cache coherence does not track variables. It tracks cache lines, which on common x86-64 hardware are 64 bytes. When a core writes to any byte in a line it must first obtain that line in an exclusive state, which means every other core holding a copy has its copy invalidated. The next time one of those cores reads its own, entirely separate variable that happens to live in the same line, it takes a miss and must fetch the line again - typically from the other core's cache rather than from memory, but a transfer either way.
So two threads incrementing two different counters, with no lock, no atomic contention on the same address, and no data race in the language's sense, can end up passing a single line back and forth on every operation. The work each thread does is independent. The hardware does not know that, because the granularity of its bookkeeping is coarser than the granularity of your data.
This is why the symptom is so confusing. Adding a second thread makes the program slower. There is no lock to point at, the profiler shows time inside a trivial increment, and the source reads as embarrassingly parallel. Candidates who have not met it before reach for lock contention or for memory bandwidth, and neither explains it.
What it looks like in code
struct Stats {
std::atomic<uint64_t> messages_in;
std::atomic<uint64_t> orders_out;
};
struct alignas(64) PaddedCounter {
std::atomic<uint64_t> value{0};
char pad[64 - sizeof(std::atomic<uint64_t>)];
};
struct Stats2 {
PaddedCounter messages_in;
PaddedCounter orders_out;
};
The commented line is the one people get wrong. Adding trailing padding to a struct changes its size but says nothing about where an instance starts, so an array of 64-byte-sized-but-unaligned objects can still put two hot fields in one line for half the elements. Alignment and size have to agree. The standard library exposes a hint for the interference size a platform expects, which is preferable to hard-coding 64 when you care about portability, though on the hardware in question 64 is the honest answer.
Confirming it rather than guessing
An interviewer will push here, because "I would pad it" is a guess until you have evidence. The evidence is hardware performance counters. Modern CPUs expose events for cache-line transfers between cores and for stores that hit a line held modified in another core's cache, and a profiler capable of reading those counters will attribute them to a source line. If the count is high and it points at your counter increment, you have your answer.
There is also a cheap experiment that costs nothing to run. Insert padding, or simply give each thread a local accumulator and merge at the end, and measure again. If throughput jumps and scales with thread count where it previously degraded, the diagnosis is confirmed. Doing this before reaching for the profiler is defensible when the code is small; asserting the cause without either step is not.
The distinction to hold onto is between false sharing and true contention. If both threads increment the same counter, padding changes nothing, because the serialisation is genuine - the hardware is doing exactly what you asked. The fix there is to stop sharing the counter at all, which is per-thread accumulation, not layout.
Padding is a trade, not a free win
The reason you cannot simply pad everything is that cache is a fixed resource. Expanding a counter from 8 bytes to 64 multiplies the footprint of an array of them eightfold, and a per-thread statistics table that fitted in L1 may now spill to L2 or beyond. You have traded a coherence problem for a capacity problem, and on a workload that reads those counters more than it writes them, that is a net loss.
The same argument applies to the objects you touch on the hot path. A cache-line-aware layout is as much about packing the fields you read together as about separating the fields different threads write. If a strategy reads four fields of a market-data snapshot on every tick, having those four fields in one line is a real win, and interleaving padding between them to be safe would be actively harmful. Layout is a per-structure decision informed by which threads touch which fields at which rates, and the general rule is: group by access pattern, separate by writer.
Why a fixed layout comes unfixed
The failure mode that catches teams months later is not the original bug, it is its return. Someone adds a field to a struct above the padded region, or changes an allocator so instances no longer start at a line boundary, or a compiler upgrade reorders something, and the layout silently regresses. Nothing fails a test. Throughput drops a few percent and the tail widens, which is exactly the kind of change that gets attributed to "load".
The defence is to make the layout an asserted property rather than a comment. A static assertion on the size and offsets of the hot fields, checked at compile time, turns a silent regression into a build failure. This is the answer that reads as production experience, because it says the candidate has watched a layout fix decay and has decided not to let it happen twice.
Where it hides beyond your own structs
Two more places worth naming. First, adjacent elements of an array handed to different threads: splitting a range by index gives thread boundaries that fall in the middle of a line, so the two threads at each boundary false-share until you chunk by line-sized blocks. Second, the internals of things you did not write. A queue's head and tail indices, written by producer and consumer respectively, are the textbook case, which is why well-engineered single-producer single-consumer ring buffers put them on separate lines and cache each side's view of the other index locally. If you are using a container from a library and seeing this symptom, the layout that needs fixing may not be in your code.
Coherence is bookkeeping over 64-byte lines, so two variables that your program treats as unrelated are related in hardware if they share one - and the fix costs footprint, which is why it belongs where you measured it and nowhere else.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).