缓存行伪共享(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,abc 可能落在同一个 Cache Line。

二、什么是 False Sharing

假设:

struct Data {
    long a;
    long b;
};

两个线程:Thread1 不停修改 a,Thread2 不停修改 b。看起来 a != b,互不影响。但实际 ab 在同一个 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

逻辑上 ab 无关,但物理上在同一个 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]0x1000counters[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 等)中最常见的底层性能陷阱之一。