False Sharing and Cache Lines in Concurrent Go Programs
Measure false sharing in Go: 10 goroutines on their own counters took 5876 ms side by side and 181 ms padded to cache lines, a 32 times difference

Two goroutines write to two different variables. There is no lock, no shared data, no race, and yet adding the second goroutine makes the program slower than one goroutine alone. This is false sharing, and it is one of the few places where the layout of memory shows up as a first order performance effect in Go. It does not appear in a CPU profile as anything but "the atomic add is slow". This article measures it with ten goroutines on a 10 core machine, shows the 32 times slowdown, and shows the fix, padding.
Tested with Go 1.25.6, golang.org/x/sys v0.36.0, Apple M1 Max (10 cores, 128 byte cache lines), macOS arm64, median of 5 runs
It builds on struct layout and padding, where padding wastes memory, and here it is added on purpose. It belongs with the concurrency measurements in what a mutex and an atomic cost and channels. The cornerstone is the Go performance guide.
What is happening
A processor does not read or write single bytes from memory. It moves cache lines, 64 bytes on most x86 machines and 128 bytes on Apple's performance cores (the lab asks the OS: hw.cachelinesize is 128). Each core has its own cache. To keep them consistent, a coherence protocol allows many cores to hold a line for reading, but only one to hold it for writing. When core A writes to a line, every other copy is invalidated. If core B then writes to any byte of the same line, it must fetch the line from core A (a transfer that costs tens to hundreds of cycles), write, and invalidate A's copy again.
If A's variable and B's variable are 8 bytes apart, they share a line. The two goroutines never touch each other's data and still pass the line back and forth on every write. That is false sharing: sharing of the cache line, not of the variable. The cost is the coherence traffic.
The experiment
Ten goroutines (GOMAXPROCS on this machine), each doing 20 million atomic.AddInt64 calls. Four setups:
- One goroutine on its own counter (the baseline).
- Ten goroutines on one shared counter (real contention).
- Ten goroutines, each on its own counter, in a
[]packedwhere each element is 8 bytes (false sharing). - Ten goroutines, each on its own counter in a
[]padded, where each element is 136 bytes: the counter followed bycpu.CacheLinePad.
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"time"
"unsafe"
"golang.org/x/sys/cpu"
)
// packed: one counter per goroutine, next to each other.
type packed struct{ n int64 }
// padded: one counter per goroutine, each alone on a cache line.
type padded struct {
n int64
_ cpu.CacheLinePad
}
const perGoroutine = 20_000_000
func run(workers int, work func(id int)) time.Duration {
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
work(i)
}(i)
}
wg.Wait()
return time.Since(start)
}
func median(f func() time.Duration) time.Duration {
var ds [5]time.Duration
for i := range ds {
ds[i] = f()
}
for i := range ds {
for j := i + 1; j < len(ds); j++ {
if ds[j] < ds[i] {
ds[i], ds[j] = ds[j], ds[i]
}
}
}
return ds[2]
}
func main() {
workers := runtime.GOMAXPROCS(0)
fmt.Printf("%d goroutines, %d atomic increments each; sizeof(packed) = %d, sizeof(padded) = %d, CacheLinePad = %d bytes\n\n",
workers, perGoroutine, unsafe.Sizeof(packed{}), unsafe.Sizeof(padded{}), unsafe.Sizeof(cpu.CacheLinePad{}))
a := make([]packed, workers)
p := make([]padded, workers)
var shared int64
dShared := median(func() time.Duration {
return run(workers, func(int) {
for i := 0; i < perGoroutine; i++ {
atomic.AddInt64(&shared, 1)
}
})
})
dPacked := median(func() time.Duration {
return run(workers, func(id int) {
for i := 0; i < perGoroutine; i++ {
atomic.AddInt64(&a[id].n, 1)
}
})
})
dPadded := median(func() time.Duration {
return run(workers, func(id int) {
for i := 0; i < perGoroutine; i++ {
atomic.AddInt64(&p[id].n, 1)
}
})
})
dOne := median(func() time.Duration {
return run(1, func(id int) {
for i := 0; i < perGoroutine; i++ {
atomic.AddInt64(&p[0].n, 1)
}
})
})
fmt.Printf("one goroutine, its own counter: %6d ms (%.1f ns per increment)\n", dOne.Milliseconds(), float64(dOne)/perGoroutine)
fmt.Printf("%d goroutines, ONE shared counter: %6d ms (%.1f ns per increment per goroutine)\n", workers, dShared.Milliseconds(), float64(dShared)/perGoroutine)
fmt.Printf("%d goroutines, counters side by side: %6d ms (%.1f ns per increment per goroutine)\n", workers, dPacked.Milliseconds(), float64(dPacked)/perGoroutine)
fmt.Printf("%d goroutines, counters on their own lines: %4d ms (%.1f ns per increment per goroutine)\n", workers, dPadded.Milliseconds(), float64(dPadded)/perGoroutine)
fmt.Printf("\nside by side is %.1fx slower than padded; one shared counter is %.1fx slower than padded\n", float64(dPacked)/float64(dPadded), float64(dShared)/float64(dPadded))
}set -e
echo "cache line size reported by the OS: $(sysctl -n hw.cachelinesize) bytes"
go mod tidy > /dev/null 2>&1
go run .cache line size reported by the OS: 128 bytes
10 goroutines, 20000000 atomic increments each; sizeof(packed) = 8, sizeof(padded) = 136, CacheLinePad = 128 bytes
one goroutine, its own counter: 138 ms (6.9 ns per increment)
10 goroutines, ONE shared counter: 11908 ms (595.4 ns per increment per goroutine)
10 goroutines, counters side by side: 5876 ms (293.8 ns per increment per goroutine)
10 goroutines, counters on their own lines: 181 ms (9.1 ns per increment per goroutine)
side by side is 32.4x slower than padded; one shared counter is 65.7x slower than paddedcpu.CacheLinePad is a [N]byte array in golang.org/x/sys/cpu whose size is the cache line size for the architecture (the lab printed 128). Using it in a struct after the hot field means the next element starts on a fresh line. Go does not guarantee that the slice itself starts on a line boundary, so with a []padded the padding guarantees at least one line between counters, which is what matters.
Results
| Setup | Time | Per increment |
|---|---|---|
| 1 goroutine, own counter | 138 ms | 6.9 ns |
| 10 goroutines, one shared counter | 11,908 ms | 595.4 ns |
| 10 goroutines, counters side by side | 5,876 ms | 293.8 ns |
| 10 goroutines, counters on their own lines | 181 ms | 9.1 ns |
The program summarised it: side by side is 32.4x slower than padded; one shared counter is 65.7x slower than padded.
The padded version scales. Ten goroutines did ten times the work in 181 ms, against 138 ms for one goroutine. The extra 43 ms is startup and scheduling. This is what parallelism is supposed to look like.
The packed version is 32 times slower though each goroutine touches only its own variable. 5,876 ms for the same work means each increment took 294 ns, hundreds of times the cost of the atomic instruction itself, because every increment found the line owned by another core. The result is worse than using one goroutine at 138 ms. Adding cores made it 42 times slower.
The shared counter is the worst case, and is a different problem. Ten goroutines contending for the same variable need the line exclusively and serialise: 595 ns per increment. Fixing it requires a different design (sharding the counter, as the padded setup does, then summing at the end), not just padding.
Where this bites in real programs
- Per goroutine or per worker counters and statistics stored in a slice or array:
stats[workerID].processed++. - Per shard locks in an array: ten
sync.Mutexvalues side by side (8 bytes each) share lines, and locking one invalidates its neighbours. Shard locks should be padded. - Ring buffers and queues where the producer's index and the consumer's index sit next to each other: each side's write invalidates the other's line. High performance queues pad the head and tail apart.
- Atomic flags and sequence numbers in a struct that several goroutines read and write.
sync.Pooland runtime internals pad their per processor structures for this reason; the runtime does it for you in its own code.
The symptoms are consistent: a parallel program that scales badly, with the CPU profile dominated by an atomic operation or by the function that increments, and with cores busy. Neither the race detector nor -race reports anything; the code is correct.
Fixing it
- Pad. Add
_ cpu.CacheLinePad(or a[64]byteor[128]bytearray) after the field that is written often. Use the constant for the architecture you target, since Apple silicon is 128 and most x86 is 64. Padding to 128 everywhere is safe and wastes a little memory on x86. - Separate by allocation. Allocate each worker's state with
newand keep a slice of pointers. Separate allocations are usually not adjacent, though the allocator offers no guarantee for small sizes. - Shard by CPU, not by index. Keep per CPU data where possible (
sync.Pooldoes), and sum at read time. - Batch. Accumulate in a local variable and add to the shared counter every N items. A goroutine that updates a local counter 1000 times and publishes once avoids the traffic entirely, and is the right fix when exact timing of the shared value does not matter.
- Group hot and cold fields. Fields written by one goroutine together, read mostly fields apart.
The padding costs memory: padded is 17 times the size of packed. Use it for a handful of hot objects, never for millions of them. If you have millions, batch instead.
Measuring and confirming
False sharing is easy to diagnose by experiment and hard to see in a profile. Run the code with 1, 2, 4 and 8 goroutines and plot throughput. A flat or falling curve on code with no shared data is the signal. Then pad the suspect fields and rerun; the lab's change from 5,876 ms to 181 ms shows it. On Linux, perf c2c reports cache line contention directly, and hardware counters for "cache line transfers" show it on any platform that exposes them.
Checklist
- Writes by different cores to the same 64 or 128 byte line cause coherence traffic even when the variables differ.
- Check the line size of your hardware and use
cpu.CacheLinePad. - Pad hot per goroutine counters, per shard locks and queue indexes, a few objects only.
- Batch updates in a local variable when padding is too expensive.
- Test scaling with 1, 2, 4 and 8 goroutines to expose it.
The package is documented in golang.org/x/sys/cpu, and the coherence behaviour is explained in the Linux kernel's perf c2c notes.


