False sharing & coherence¶
Scope: the module overview already named false sharing and its padding fix in brief. This page goes one layer deeper on the mechanism underneath it — the MESI cache-coherence protocol that keeps every core's view of memory consistent. It traces exactly how that protocol turns two logically-unrelated variables into a real bottleneck when they share a cache line, and how padding/alignment fixes it.
TL;DR — in 30 seconds:
- MESI keeps every core's cached view of memory consistent via four states (Modified / Exclusive / Shared / Invalid); reads scale for free, but a write forces an invalidate-and-re-fetch.
- False sharing is two independent variables landing on the same 64-byte line — MESI can't tell them apart, so one core's write invalidates the whole line for the other.
- It's a performance bug, not a correctness bug — a race detector finds nothing wrong; the fix is padding/alignment so hot fields land on separate lines.
1. Why multicore needs a coherence protocol at all¶
Each core has its own private L1 (and often L2) cache — a local copy of whatever memory it has recently touched. That's exactly what makes caching fast, and exactly what creates a problem the moment two cores cache the same cache line: if Core 0 writes its private copy, Core 1's private copy is now stale, and nothing about "private cache" on its own stops Core 1 from reading that stale value.
Cache coherence is the hardware guarantee that this can't happen — every core's view of shared memory stays consistent no matter which core last wrote it. The mechanism mainstream CPUs use to provide that guarantee is MESI.
2. MESI: the four states a cache line can be in¶
MESI names every cache line, in every core's cache, one of four states:
| State | Meaning |
|---|---|
| Modified (M) | This core has written the line; its copy is the only correct one; memory itself is stale |
| Exclusive (E) | Only this core holds the line, and it matches memory — not yet written |
| Shared (S) | One or more cores hold a read-only copy, all matching memory |
| Invalid (I) | This core's copy is stale and unusable — must re-fetch before reading or writing |
A core wanting to write a line it only holds as Shared (or doesn't hold at all) must first tell every other core holding that line to drop it — invalidate its copy — before the write can proceed. That invalidate-then-write handshake is called snoop traffic: every core watches (snoops) the shared bus or interconnect for other cores' cache activity so it knows when to invalidate its own copy.
stateDiagram-v2
[*] --> Invalid
Invalid --> Exclusive: this core reads,<br/>no one else has it
Invalid --> Shared: this core reads,<br/>others already hold it
Exclusive --> Modified: this core writes
Shared --> Modified: this core writes<br/>(invalidates other copies)
Modified --> Shared: another core reads<br/>(this core's copy written back)
Shared --> Invalid: another core writes<br/>(snoop invalidates this copy)
Modified --> Invalid: another core writes<br/>(snoop invalidates this copy)
Reads scale well under this protocol — many cores can hold the same line as Shared simultaneously, no coordination needed. Writes are what's expensive: a write forces every other holder to invalidate, and if another core wants to read or write right after, the line has to be re-fetched across the interconnect rather than served from a local cache. Coherence is what makes shared memory correct across cores — it does not make shared writes free.
3. False sharing: when "independent" is a lie the cache falls for¶
Coherence tracks state per cache line, not per variable — because, as the memory-hierarchy page covered, the cache line (64 bytes on mainstream CPUs) is the unit memory actually moves in. That single fact is the entire cause of false sharing.
False sharing is two cores writing to two logically independent variables that happen to physically land on the same 64-byte cache line. Neither core cares about the other's variable — there is no data race, no shared state in the program's logic — but MESI has no way to know that two different offsets within one line belong to two different, uncontended pieces of state. From the protocol's point of view, any write to any part of the line invalidates the whole line everywhere else it's cached.
flowchart TB
LINE["One 64-byte cache line<br/>bytes 0-7: var A · bytes 8-15: var B<br/>logically independent, physically adjacent"]
C0["Core 0<br/>writes var A only"] -->|invalidate + re-fetch<br/>whole line| LINE
C1["Core 1<br/>writes var B only"] -->|invalidate + re-fetch<br/>whole line| LINE
LINE -.->|ping-pongs between<br/>the two cores| LINE
The result: two threads that never touch each other's data still throttle each other, because the line underneath them keeps bouncing Modified → Invalid → Modified between the two cores' caches. Each bounce pays the same invalidate-and-re-fetch cost a real contended write would pay — the throughput hit is real, but it is a performance bug, not a correctness bug: there is no data race in the sense the concurrency page defined, because the two threads never actually share state. That distinction matters when diagnosing it — a race detector will find nothing wrong, because nothing is wrong at the correctness level; only a profiler that shows per-line cache-miss/invalidation counts will surface it.
4. The fix: pad and align so contended fields don't share a line¶
Once two frequently-written, logically-independent fields are identified as sharing a line, the fix is mechanical: pad or align the struct so each hot field gets its own cache line.
- Padding — insert unused filler bytes between the two fields so they land on different 64-byte lines instead of adjacent offsets within one.
- Alignment — force a field (or a whole struct) to start at a cache-line boundary, so it never straddles or crowds a line another hot field also occupies.
flowchart TB
subgraph BEFORE["Before: var A and var B share one line"]
L1["Line: [A][B]"]
end
subgraph AFTER["After: padded to separate lines"]
L2["Line N: [A][padding......]"]
L3["Line N+1: [B][padding......]"]
end
The trade-off is memory footprint: padding spends extra bytes — often an entire cache line's worth per field — to buy back the throughput lost to ping-ponging. That's a good trade for a handful of hot, independently-written counters or flags; it is not something to apply blindly to every struct field, since most fields are never write-contended and padding them would only waste space for no benefit.
5. How this connects¶
- This is the direct payoff of the memory-hierarchy page's cache-line mechanic (§2 there): false sharing is only possible because the cache moves data a line at a time rather than a variable at a time.
- It is the multicore counterpart to the concurrency page's data race: same symptom category (two threads, shared memory, contention), but false sharing has no unordered read/write on the same logical variable — it's the coherence protocol's line granularity creating contention the program never asked for.
- The single-writer principle from the concurrency page still helps here indirectly: state owned by one thread is never write-contended by another core in the first place, so there's no adjacent hot field for it to collide with.
- The module's exercise — draw a two-
int64-field struct, mark the 64-byte boundary, and show the padding fix — is the concrete, checkable version of §3–§4 here.
Common gotchas
- Running a race detector to find a false-sharing bottleneck. Fix: it won't show up — there's no data race here; only a profiler surfacing per-line cache-miss/invalidation counts will.
- Padding every struct field "just in case." Fix: only pad the handful of hot, frequently-written independent fields you've actually identified as sharing a line — padding everything wastes memory on fields that are never contended.
- Assuming two threads writing different variables can never interact. Fix: check whether those variables happen to share a cache line — if they do, MESI still ping-pongs the whole line even though there's no logical race between them.
Check yourself — two cores each hold a line as Shared, and one wants to write it. What has to happen first, and why?
The writing core must invalidate every other core's copy first — coherence requires only one core's copy be authoritative at a time, so no core can write while another still holds the line as Shared.
Check yourself — why doesn't a race detector catch false sharing?
Because there's no data race in the correctness sense — the two threads never touch the same logical variable. The line only bounces because MESI tracks state per cache line, not per variable; only a per-line cache-miss profiler surfaces it.
You can defend this when you can name all four MESI states and say which transition is triggered by a read versus a write from another core; explain why false sharing throttles throughput without being a data race in the correctness sense; and, given a struct with two independently-written fields, say whether they share a cache line and show the padding or alignment fix if they do.