缓存行伪共享(Cache Line False Sharing)
前言
Cache Line False Sharing(缓存行伪共享) 是多线程编程中一个非常经典且隐蔽的性能问题。
很多程序:
- 没有锁竞争
- 没有 CAS 重试
- CPU 使用率很高
- 线程之间看起来互不影响
但是性能却很差。最后排查发现,原因竟然是 False Sharing(伪共享)。
一、先理解 Cache Line 是什么
CPU 不会一字节一字节地读取内存。CPU 与内存之间存在多级缓存:
CPU Core
│
├── L1 Cache (最快)
│
├── L2 Cache
│
├── L3 Cache(多个核心共享)
│
└── Memory(RAM)
CPU 每次加载数据时,不是加载一个变量,而是加载一个 Cache Line。通常大小是 64 Bytes,现代 Intel/AMD CPU 基本都是 1 Cache Line = 64B。
例如:
long a; // 8B
long b; // 8B
long c; // 8B
内存布局:
┌─────────────────────────────┐
│ a │ b │ c │ ... │
└─────────────────────────────┘
由于总共不到 64B,a、b、c 可能落在同一个 Cache Line。
二、什么是 False Sharing
假设:
struct Data {
long a;
long b;
};
两个线程:Thread1 不停修改 a,Thread2 不停修改 b。看起来 a != b,互不影响。但实际 a 和 b 在同一个 Cache Line:
┌──────── Cache Line 0 ────────┐
│ a │ b │ ... │
└──────────────────────────────┘
Thread1 执行 a++ 时,CPU 会获得 Cache Line 独占权限;随后 Thread2 执行 b++,CPU 会获得同一个 Cache Line 独占权限。结果:
Core1 ←→ Core2 不断争夺同一个 Cache Line
缓存行不停失效:
Invalidate
Invalidate
Invalidate
...
虽然线程修改的是不同变量,但因为在同一个 Cache Line,导致缓存一致性协议不停同步,这就是 False Sharing(伪共享)。
三、为什么叫"伪"共享
真正共享(True Sharing):多个线程修改同一个变量,例如 counter++:
Thread1 -> counter
Thread2 -> counter
Thread3 -> counter
大家确实共享同一份数据。
而伪共享:
Thread1 -> a
Thread2 -> b
逻辑上 a 与 b 无关,但物理上在同一个 Cache Line,CPU 认为它们共享,因此叫 False Sharing。
四、CPU 为什么会这样
原因来自缓存一致性协议,最典型的是 MESI Protocol,状态:
M Modified(已修改)
E Exclusive(独占)
S Shared(共享)
I Invalid(失效)
例如:Core1 和 Core2 的 Line0 都是 Shared。Core1 修改 a:
Line0 -> Modified,同时 Core2 的 Line0: Shared → Invalid
然后 Core2 修改 b,需要重新加载 Line0,于是 Core1 的 Line0 被失效。形成:
Core1 ↔ Core2 不停 Ping-Pong
这叫 Cache Line Ping Pong,也是 False Sharing 的本质。
五、一个经典例子
long counters[2];
Thread1:
while(true)
counters[0]++;
Thread2:
while(true)
counters[1]++;
数组布局中 counters[0] 在 0x1000、counters[1] 在 0x1008,两者间隔只有 8B,而 Cache Line 是 64B,因此它们在同一个 Cache Line:
┌─────────────────────────────┐
│ counter0 counter1 ... │
└─────────────────────────────┘
结果:性能极差,有时下降几十倍。
六、如何解决
方法 1:Padding(填充)
最常见的方法:
struct Counter {
long value;
char padding[56];
};
long = 8B + padding = 56B,总计 64B,正好一个 Cache Line:
┌──── Cache Line 0 ────┐
│ value0 │
└─────────────────────┘
┌──── Cache Line 1 ────┐
│ value1 │
└─────────────────────┘
这样 Thread1 只修改 Line0,Thread2 只修改 Line1,不再互相影响。
方法 2:Cache Line Alignment
C++ 使用 alignas:
alignas(64)
struct Counter {
long value;
};
或者:
struct alignas(64) Counter {
long value;
};
保证按 64B 对齐。
方法 3:Java 的 @Contended
JDK 提供注解,JVM 自动填充 Padding 避免伪共享:
@Contended
class Counter {
volatile long value;
}
七、Go 是怎么处理的
Go runtime 到处在避免 False Sharing。例如 runtime/pool.go 中的:
type poolLocal struct {
poolLocalInternal
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
为什么是 128 而不是 64?因为兼容未来 CPU、避免跨 Cache Line,让每个 P(Local Pool)独占缓存行。
八、性能差距有多大
经典测试:
var counters [2]int64
两个 goroutine:
atomic.AddInt64(&counters[0], 1)
atomic.AddInt64(&counters[1], 1)
可能只有 100M ops/s。
加 padding:
type Counter struct {
value int64
_ [56]byte
}
变成 500M~1000M ops/s。不同 CPU 上差异很大,但提升通常非常明显。
九、用图理解 False Sharing
Cache Line 0 (64B)
┌──────────────────────────────────────┐
│ counterA │ counterB │ ... │
└──────────────────────────────────────┘
▲ ▲
│ │
Thread1 Thread2
不断修改
Core1 ------------------> Invalid Core2
Core2 ------------------> Invalid Core1
Core1 ------------------> Invalid Core2
Core2 ------------------> Invalid Core1
Cache Line Ping-Pong
而优化后:
Cache Line 0 Cache Line 1
┌─────────────┐ ┌─────────────┐
│ counterA │ │ counterB │
└─────────────┘ └─────────────┘
▲ ▲
│ │
Thread1 Thread2
不再互相失效
一句话总结
False Sharing(伪共享)本质上是:多个线程虽然访问不同变量,但这些变量恰好位于同一个 Cache Line 中,导致 MESI 等缓存一致性协议不断使缓存行失效(Cache Line Ping-Pong),从而产生严重的性能损耗。
它是高性能并发程序(Go Runtime、Linux Kernel、Redis、Netty、Disruptor 等)中最常见的底层性能陷阱之一。