Skip to content
← Back to explainers

Rust explainer

False sharing: when separate counters compete for a cache line

About 5 min to read. Allow extra time to work through examples.

Understand how nearby writes can cause cache contention, when padding helps, and when local accumulation is a better fit.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Two threads can update different variables and still interfere with each other's performance. If those variables occupy the same cache line, writes can create false sharing: the program treats the data as separate, but the hardware tracks ownership at a larger granularity.

Both versions of such a program can produce the correct result. Output tests alone do not establish whether false sharing occurs or how much it costs. This article explains the mechanism and gives examples you can investigate on your own machine; it does not promise a particular speedup.

Separate values, shared cache line

On typical cache-coherent processors, caches move and track data in fixed-size lines. Before a core modifies data in a line, the coherence protocol must give it the necessary write permission, which can invalidate copies in other cores. If different cores keep writing to the same line, obtaining that permission can create substantial traffic and delays.

Consider a vector of atomic counters with one writer per element. The elements are contiguous, so neighboring counters may share a cache line. They need not all occupy the same line: allocation alignment and the line boundary matter. Nor does every increment necessarily transfer a line. A core can perform several updates while retaining ownership; the interleaving determines when ownership changes are needed.

False sharing can also involve a reader: repeatedly writing one field can invalidate a different, unchanged field that another core keeps reading.

This differs from true sharing, where threads update the same counter. Padding separate counters can address false sharing. Padding one shared counter does not stop all writers from contending on that same value.

Atomics ensure the required atomic access semantics. Ordering::Relaxed does not remove cache coherence or make concurrent updates free. A mutex and an atomic have different costs and progress properties; neither is automatically faster under every workload, and their performance need not converge under load.

Padding is a layout choice

For an experiment on a machine with 64-byte coherence lines, this wrapper separates adjacent counters by at least that alignment:

use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

#[repr(align(64))]
struct Counter(AtomicU64);

fn main() {
    let counters: Vec<Counter> = (0..4)
        .map(|_| Counter(AtomicU64::new(0)))
        .collect();

    thread::scope(|scope| {
        for counter in &counters {
            scope.spawn(move || {
                for _ in 0..10_000 {
                    counter.0.fetch_add(1, Ordering::Relaxed);
                }
            });
        }
    });

    let total: u64 = counters.iter()
        .map(|counter| counter.0.load(Ordering::Relaxed))
        .sum();
    assert_eq!(total, 40_000);
}

The scoped threads are joined before the final loads. Relaxed operations are sufficient for these independent counters; they are not being used to publish other data to concurrent readers. The example requires a target supporting AtomicU64.

repr(align(64)) raises alignment to at least 64 bytes. A type's size is a multiple of its alignment, so vector elements have an appropriately aligned stride. On a typical target where this wrapper has size 64, four counters use 256 bytes of element storage instead of 32 bytes for four unpadded 8-byte counters.

The number 64 is an assumption for that experiment, not a portable cache-line query. It can be insufficient on a machine with larger coherence lines. Crossbeam's CachePadded uses architecture-dependent padding estimates; even those are not a guarantee about every processor. Check the target and inspect the actual layout.

Padding trades space for separation. It can increase cache footprint and memory bandwidth use, and it does not eliminate traffic from readers of each counter or unrelated shared data. Benchmark before applying it broadly.

Accumulate locally when intermediate values are unnecessary

If callers only need a final total, workers can compute independent results and return them for reduction. This avoids an atomic operation per input item:

use std::thread;

fn main() {
    let values: Vec<u64> = (1..=1000).collect();
    let total = thread::scope(|scope| {
        let handles: Vec<_> = values.chunks(250)
            .map(|chunk| scope.spawn(move || chunk.iter().sum::<u64>()))
            .collect();
        handles.into_iter()
            .map(|handle| handle.join().expect("worker panicked"))
            .sum::<u64>()
    });
    assert_eq!(total, 500_500);
}

This is a correctness example, not a useful benchmark: thread startup may dominate such a small amount of work. A local variable is not guaranteed to stay in a register, and workers still read their input and use memory. The benefit is removing repeated updates to shared counter storage.

Local reduction also changes when results become visible. It suits a final summary, but may not suit live progress reporting. Periodic batching is another option when some delay is acceptable. For floating-point sums, changing the reduction order can also change rounding; do not assume identical results.

Investigate the suspected bottleneck

Start with a hypothesis from the data layout and access pattern: multiple writers, nearby locations and frequent updates. Adding threads making a program slower is a clue, but not proof. Thread overhead, memory bandwidth, lock contention, scheduling and other factors can cause the same symptom.

Compare the original layout with padded counters or local accumulation while keeping the amount of work and final result comparable. Use optimized builds, repeat measurements, and record the processor, thread count and workload. Separate thread startup from steady-state work when that distinction matters. A faster alternative supports further investigation; it does not identify the hardware mechanism by itself.

Hardware profiling can provide stronger evidence. On supported Linux systems, perf c2c can help identify contended cache lines and the accesses involved. Availability and detail depend on processor support, kernel configuration and permissions. Combine profiling with layout inspection and controlled experiments.

Choose the design around the required behavior: local totals for end-of-job results, batching for delayed updates, or separated counters when independent state must stay observable. Then measure the implementation on its target.

step 7 of 12

Atomics, Send/Sync and the Memory Model

Up next OnceLock: initialize shared state once