每日一Go-46、什么是Go语言的伪共享(False Sharing)? 如何避免伪共享?

0 阅读5分钟

    在并发编程中,死锁、资源竞争都是显而易见的性能杀手,但有一些是看不见“幽灵”。伪共享(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小程序):

微信搜索“加班计”.png *源码地址*

1、公众号“Codee君”回复“每日一Go”获取源码

2、pan.baidu.com/s/1B6pgLWfS…


如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!