Two threads write to adjacent counters and throughput collapses. What is happening and how do you fix it?
They are false sharing: both counters sit in one cache line, so each write invalidates the other core's copy and the line ping-pongs between caches. You fix it by aligning each hot field to its own line, at the cost of footprint, and only where a hardware counter says so. It also connects cache lines to the point an interviewer is testing.
What the interviewer is scoring
- Whether you know coherence operates on whole cache lines rather than on individual variables
- Does the candidate explain why the code looks correct and lock-free yet still serialises
- That they name a concrete way to confirm the diagnosis rather than asserting it from the source
- Whether you treat padding as a trade against footprint and cache capacity, not a free win
- Does the candidate separate false sharing from true contention on the same variable
Answer
Short answer
They are false sharing: both counters sit in one cache line, so each write invalidates the other core's copy and the line ping-pongs between caches. You fix it by aligning each hot field to its own line, at the cost of footprint, and only where a hardware counter says so.
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
// Both counters land in one 64-byte line if the struct is allocated once.
struct Stats {
std::atomic<uint64_t> messages_in; // written by the feed thread
std::atomic<uint64_t> orders_out; // written by the strategy thread
};
// Each counter now owns a line. alignas is the part that matters: padding
// the struct without aligning it can still straddle a line boundary.
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.
Likely follow-ups
- How would you lay out a per-thread statistics array so that padding does not blow out your L2 working set?
- What does a read-mostly field sharing a line with a frequently written one do to readers, and is that also false sharing?
- Why can a struct that was laid out correctly start false sharing again after someone adds a field above it?
- How do you decide between padding a field and moving it into thread-local storage that is aggregated later?
Related questions
- What actually happens during a context switch, and why should an application developer care?mediumAlso on cpu-cache and concurrency5 min
- An asyncio call times out and you handle the TimeoutError, but the background task keeps running and mutates shared state a few seconds later. What happened, and how do you make the timeout actually stop the work?hardAlso on concurrency4 min
- A list of a million small objects uses far more memory than the data inside them. Where is it going?mediumAlso on memory-layout5 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on concurrency7 min