Guide

Caches stay consistent across cores at the level of cache lines. That is useful when cores really share data. It becomes a problem when two cores write different variables that happen to sit in the same line. Nothing is logically shared, yet the line bounces between cores on every write. Two more hardware effects follow the same pattern. Memory can sit on another CPU socket, and a thread can run far from the data it needs.

Concept

Before you start: cache coherence is the rule that all cores see one consistent value for each cache line. When a core writes a line, other cores' copies are invalidated. Their next read must fetch the line again.

The big idea: false sharing

When core A writes to a line, every other core's copy is invalidated. When core B next touches the line, it must move over from A's cache. That costs tens of nanoseconds. The line can ping-pong between cores on every write:

Walking through it: two counters, two layouts

Two threads each increment their own counter, 200 million times. In the packed layout, a and b share a cache line. In the padded layout, a 120-byte pad pushes b onto its own line:

c
struct { volatile long a; volatile long b; } packed;                   // a and b share a line
struct { volatile long a; char pad[120]; volatile long b; } padded;    // a and b on separate lines

On an Apple M-series laptop (128-byte lines), the packed version took about 0.20 to 0.23 s and the padded version 0.06 to 0.09 s. That is 2 to 4× slower, purely from where two variables sat. The effect grows with core count, and it is worse across sockets.

The symptom to recognize: a multi-threaded workload gets slower as you add threads, and the profile shows no lock contention.

The fix: keep hot per-thread data on separate lines

Pad or align so that fields written by different threads never share a line:

  • Java: @jdk.internal.vm.annotation.Contended (used inside LongAdder and ConcurrentHashMap), or manual padding as in the LMAX Disruptor.
  • Go: CacheLinePad from golang.org/x/sys/cpu, placed between fields.
  • C, C++ and Rust: alignas(64), #[repr(align(64))], or std::hardware_destructive_interference_size in C++17.

Many libraries already do this. LongAdder, sharded counters and per-CPU data in the Linux kernel all avoid the problem.

NUMA: memory has a distance

On a server with two or more CPU sockets, each socket has its own memory controllers and its own DRAM. A core can read the other socket's memory, but the request crosses an interconnect. That is typically about 1.5 to 2× the latency, with less bandwidth. This is NUMA, for non-uniform memory access.

Linux tries to allocate memory on the node where the thread runs. That works until one process uses most of the machine. Then a big database or JVM heap ends up mostly on one node, threads on the other node pay remote latency on every access, and one node can swap while the other has free memory.

The usual tools:

  • numactl --hardware shows the topology.
  • numactl --interleave=all spreads a database's memory evenly across nodes.
  • The JVM's -XX:+UseNUMA makes the heap NUMA-aware.
  • Pinning one process per socket keeps threads and their memory together.

On a single-socket cloud VM you can mostly ignore NUMA. On large bare-metal database hosts, you cannot.

Warning

Pinning and interleaving change behavior under load. Test with production-shaped traffic, not a single benchmark run.

Common mistakes

  • Packing per-thread counters into one struct. This is the classic false-sharing bug.
  • Ignoring socket layout on a big host. Remote memory adds latency to every miss.
  • Padding everything. Padding costs memory. Pad only the fields that are written by different threads.

Check yourself

  • Two threads each write their own counter, and the program is slower with two threads than with one. What is the likely cause? The counters share a cache line, so each write invalidates the other core's copy.
  • Why might a large database slow down on a two-socket server even with spare memory? Its memory sits on one socket, and threads on the other socket pay remote latency on each access.

Key takeaways

  • False sharing happens when different threads write different variables in one cache line. Each write forces the line to move between cores.
  • Pad or align hot per-thread fields onto separate 64-byte (or 128-byte) lines.
  • On multi-socket servers, remote NUMA memory costs about 1.5 to 2× more. Large databases and JVM heaps need NUMA-aware setup.

Further learning

For how a process gets its memory and threads, see processes and threads. For a bigger picture of where these costs land in production, see Kubernetes in production.