False Sharing in Go: How 64-Byte Cache Lines Slow Down Race-Free Code

Your CPU does not read single bytes. It moves memory between caches in fixed-size blocks called cache lines — 64 bytes on most modern x86 and ARM processors. Every variable you read pulls in its whole 64-byte neighborhood, and every variable you write takes the entire line with it. This design detail quietly shapes the performance of concurrent code, and it produces one of the strangest failure modes in systems programming: false sharing, where two threads write to two completely different variables and still slow each other to a crawl.

False sharing is worth understanding even if you never write a lock-free data structure, because it hides inside ordinary code: a metrics struct with a few counters, a slice of per-worker statistics, a sharded map. The data races are all correct and race-free. The performance loss can still be dramatic.

Cohesion first: true sharing versus false sharing

When two cores both want a cache line, the hardware cache coherence protocol (MESI and its descendants) keeps them consistent: only one core may hold a line in Modified state, and any write by one core invalidates the copies cached by the others. If two threads genuinely update the same variable — true sharing — this invalidation traffic is the unavoidable price of correctness. Synchronization, atomics, or locks are required no matter what.

False sharing is different. Two threads update two different variables that happen to live on the same cache line. No data race exists and no synchronization is logically needed — yet the hardware cannot transfer less than a full line. Each write invalidates the other core’s copy, the line ping-pongs between the two cores’ L1 caches, and every access after an invalidation is a coherence miss. The two threads are fighting over memory neither of them actually shares.

Go makes this easy to hit. Struct fields are laid out contiguously, so a struct like this puts four independent counters on one or two cache lines:

type Stats struct {
	x, y, a, b atomic.Int64
}

x is at offset 0, y at offset 8, a at 16, b at 24 — all four fit within the first 64 bytes. Four goroutines each incrementing one field are coherent-locking the same line on every write.

Measuring the damage

Here is a small benchmark — eight goroutines, each incrementing its own counter 20 million times, either packed or padded — run on an AMD Ryzen 9 9900X with Go 1.26:

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

const N = 8

type Stats struct {
	c [N]atomic.Int64
}

type paddedCounter struct {
	n atomic.Int64
	_ [56]byte // pad 8 + 56 = 64 bytes
}

type PaddedStats struct {
	c [N]paddedCounter
}

func runShared(iter int64) time.Duration {
	var s Stats
	var wg sync.WaitGroup
	wg.Add(N)
	start := time.Now()
	for k := 0; k < N; k++ {
		go func(k int) {
			defer wg.Done()
			for i := int64(0); i < iter; i++ {
				s.c[k].Add(1)
			}
		}(k)
	}
	wg.Wait()
	return time.Since(start)
}

func runPadded(iter int64) time.Duration {
	var s PaddedStats
	var wg sync.WaitGroup
	wg.Add(N)
	start := time.Now()
	for k := 0; k < N; k++ {
		go func(k int) {
			defer wg.Done()
			for i := int64(0); i < iter; i++ {
				s.c[k].n.Add(1)
			}
		}(k)
	}
	wg.Wait()
	return time.Since(start)
}

func main() {
	iter := int64(20_000_000)
	runShared(100_000)  // warmup
	runPadded(100_000)

	var sharedTotal, paddedTotal time.Duration
	runs := 5
	for r := 0; r < runs; r++ {
		sharedTotal += runShared(iter)
		paddedTotal += runPadded(iter)
	}
	avgS := sharedTotal / time.Duration(runs)
	avgP := paddedTotal / time.Duration(runs)
	fmt.Printf("shared: %v  padded: %v  ratio: %.2fx\n", avgS, avgP, float64(avgS)/float64(avgP))
	fmt.Printf("per-op shared: %.2f ns  per-op padded: %.2f ns\n",
		float64(avgS.Nanoseconds())/float64(iter*N), float64(avgP.Nanoseconds())/float64(iter*N))
}

On this machine, the padded version finished in about 459ms versus about 672ms for the packed struct — roughly 1.46x faster overall, with per-operation cost dropping from about 4.2ns to about 2.9ns. The gap varies with core topology: when pinned goroutines land on cores that share an L3 slice, the penalty shrinks; when they spread across CCDs, it grows. Run the benchmark on your own hardware and treat any specific number as an order of magnitude, not a law. The pattern — contention collapsing when fields stop sharing a line — reproduces everywhere.

The fix: padding and alignment

The standard fix is to force each hot field onto its own cache line. Because the field is 8 bytes and the line is 64, you add 56 bytes of padding. Go’s standard library does exactly this internally — sync/atomic alignment guarantees aside, the runtime’s CPU feature flags live in a struct padded with CacheLinePad fields (the internal/cpu package defines CacheLinePadSize = 64 on x86) specifically so that frequently written flag bits do not false-share:

type paddedCounter struct {
	n atomic.Int64
	_ [56]byte // 8 + 56 = 64: one full cache line
}

Two refinements matter in production code. First, padding only helps if the field actually starts a line — a padded struct dropped in the middle of another struct can still share its line with neighbors. The strongest form is allocating each per-worker item separately, e.g. a slice of padded structs where each element occupies its own line, which also plays well with Go’s size-class allocator (16-byte size classes mean adjacent elements can start on line boundaries). Second, do not pad blindly: padding multiplies memory use, blows cache locality for readers that legitimately want the fields together, and hurts if the “contended” fields are rarely written. Pad only fields with a measured, hot, multi-core write path.

For collections, the padding applies per element:

type WorkerStats struct {
	n atomic.Int64
	_ [56]byte
}

// One cache line per worker; goroutine k writes workers[k].n.
var workers = make([]WorkerStats, 16)

Diagnosis before optimization

False sharing never shows up in a profiler as “false sharing.” What you will see is high CPU with low useful work, or in perf, elevated counts for cache-coherence events such as cache-misses and LLC-related counters. The practical workflow: benchmark the suspect struct with and without padding (the A/B above takes five minutes to write), and only commit the padded layout if the delta is real on your target hardware. Go’s benchmark harness with -benchtime and multiple -count runs is enough — the effect, when present, is large and repeatable rather than subtle.

The deeper lesson goes beyond the fix. The Go memory model guarantees correctness of race-free code, but it says nothing about where your variables physically live. That is a hardware contract, and the hardware only negotiates in 64-byte blocks. Structure your hot, concurrently-written data so that each writer owns a whole block, and the cache coherence machinery that was fighting you disappears back into the silicon where it belongs.

Leave a Reply

Your email address will not be published. Required fields are marked *