在并发编程中,死锁、资源竞争都是显而易见的性能杀手,但有一些是看不见“幽灵”。伪共享(False Sharing)就是这样一种由于现代CPU缓存架构特性而引发的性能退化现象。
一、缓存行(Cache Line)
要理解伪共享,首先要了解现代CPU架构中是如何读取内存的。CPU并不是按字节为单位从内存中读取数据的。为了提高效率,CPU缓存(L1,L2,L3)是以缓存行(Cache Line)为单位进行存储和传输的。在主流的x86和ARM架构中,一个缓存行的大小通常是64字节。
为什么需要缓存行?这基于计算机科学中的局部性原理(Principle of Locality):如果你访问了一个变量,那么你很可能会在不久之后访问它相邻的变量。将相邻数据一次性加载到缓存中,可以极大减少内存访问次数。
二、什么是伪共享?
伪共享发生在以下场景中:
1. 多个变量恰好位于同一个缓存行内。
2. 多个CPU核心并行地访问这些变量。
3. 其中至少有一个核心在执行写操作。
冲突地本质是MESI(缓存一致性协议),当CPU核心1修改了某个缓存行中地变量A时,它会将该缓存行地状态标记为“已修改”。根据CPU的缓存一致性协议(MESI),核心2中包含相同缓存行的副本会被强制标记为“无效”。即时核心2只是想读取或修改同一个缓存行里的变量B(A与B在逻辑上毫无关系),它也不得不重新从内存或更高级别的缓存中加载整个缓存行。
MESI 是以四个状态的首字母命名的缓存一致性协议:Modified(修改)、Exclusive(独占)、Shared(共享)和 Invalid(无效)。它是所有核心之间的一场实时“谍战”,确保每个人看到的内存数据都是准确的。
这种由于“空间上相邻”导致的不必要的一致性维护,就像两个学生虽然在写不同的作业,但因为共用同一张草稿纸,只要一个人动了笔,另一个人要使用就必须先把草稿纸全部擦干净。这种反复的缓存失效过程被称为 Cache Line Ping-pong,会导致系统性能急剧下跌。
三、Go语言中的伪共享示例
在Go中,结构体中的字段在内存中是连续分布的,这非常容易触发伪共享。
type Day46 struct {
a uint64 // 8字节
b uint64 // 8字节
}
//核心1频繁增加a,核心2频繁增加b
由于a和b总共只占了16字节,他们大概率会落在同一个缓存行中。
基准测试对比:我们写一个Benchmark来作为“伪共享”和“规避伪共享”的性能对比。
type Day46 struct {
a uint64 // 8字节
b uint64 // 8字节
}
type Day46WithPadding struct {
a uint64
_ [64]byte
b uint64
_ [64]byte
}
func BenchmarkModel(b *testing.B) {
d := Day46{}
b.Run("NoPadding", func(b *testing.B) {
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func(idx int) {
defer wg.Done()
for j := 0; j < b.N; j++ {
if idx == 0 {
d.a++
} else {
d.b++
}
}
}(i)
}
wg.Wait()
})
dp :=Day46WithPadding{}
b.Run("WithPadding", func(b *testing.B) {
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func(idx int) {
defer wg.Done()
for j := 0; j < b.N; j++ {
if idx == 0 {
dp.a++
} else {
dp.b++
}
}
}(i)
}
wg.Wait()
})
}
运行测试命令( go test -bench=.)后,发现有padding的比无padding的性能快了10倍,这是因为WithPadding彻底消除了缓存行的竞争。如下图:
如有看不懂测试命令的,请移步每日一Go-23、Go语言实战-质量保证:编写单元测试
四、如何解决伪共享?
如上面的代码所示,在变量之间插入足够多的空字节就能解决伪共享,俗称内存填充(Padding)。
方法1:手动填充
type Day46WithPadding struct {
a uint64
_ [56]byte // 填充56字节,保证b在64字节边界上
b uint64
_ [64]byte // 填充64字节,足以撑开任何缓存行
}
方法2:使用cpu.CacheLinePad,在go的源码里很常见
package cpu
import _ "unsafe" // for linkname
// CacheLinePad is used to pad structs to avoid false sharing.
type CacheLinePad struct{ _ [CacheLinePadSize]byte }
// traceMap is a map of a variable-sized array of bytes to a unique ID.
//
// Because traceMap just operates on raw bytes, this type is used as the
// backing store for both the trace string table and trace stack table,
// the latter of which is just an array of PCs.
//
// ID 0 is reserved for arrays of bytes of size zero.
type traceMap struct {
root atomic.UnsafePointer // *traceMapNode (can't use generics because it's notinheap)
_ cpu.CacheLinePad
seq atomic.Uint64
_ cpu.CacheLinePad
mem traceRegionAlloc
}
方法3: 重新设计数据结构
数组/切片:如果多个Goroutine频繁修改切片中相邻的元素,也会触发伪共享。解决办法是让每个Goroutine处理的数据间距加大。
独立对象:将频繁修改的变量拆分成独立的对象,而不是作为同一个结构体的字段。
五、什么时候需要关注伪共享?
1. 极高频的并发写入:如果你的变量每秒被修改百万次
2. 基准测试发现瓶颈:通过go test -bench 发现多核扩展性极差
3. 核心组件开发:底层并发库、高性能中间件或网关
人生比喻:就像 Go 语言通过浪费一点内存(Padding)来换取极大的速度提升,人生中有时也要学会“浪费”一些资源来换取专注和清净。
加班费计算器(vx小程序):
*源码地址*
1、公众号“Codee君”回复“每日一Go”获取源码
如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!