浅谈 CPU Cacheline 和 False Sharing

900 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第7天,点击查看活动详情

CPU 缓存

2004 年在计算机发展史上是有着独特影响力的一年,此前人们一直在追求的主频提升终于到了瓶颈,摩尔定律虽然还在发挥功效,但本质是因为多核时代的到来。这里引用 MIT Performance Engineering 课程上一个经典的图片:(感兴趣的同学可以来这里看原文

image.png

你会发现 Clock speed 在这之后就不怎么变了。很有意思。详细的原因涉及硬件解释,大家可以看一下这篇文章,这里我们不再赘述。

从软件开发工程师的角度,我们需要记住的是,计算机存储数据的物理器件是以 CPU 为核心,由内而外地组建了一整套的存储体系结构。在 CPU 和内存之间,其实还包含了 L1, L2, L3 三层CPU缓存。

缓存的速度介于处理器和内存之间,CPU 访问一次内存通常需要 100 个时钟周期以上,而访问一级缓存只需要 4~5 个时钟周期,二级缓存大约 12 个时钟周期,三级缓存大约 30 个时钟周期(对于 2GHZ 主频的 CPU 来说,一个时钟周期是 0.5 纳秒)。

image.png

CPU 缓存的材质 SRAM 比内存使用的 DRAM 贵许多,它的大小是以 MB 来计算的。比如,在 Linux 系统上,离 CPU 最近的一级缓存是 32KB,二级缓存是 256KB,最大的三级缓存则是 20MB。

image.png

从上图可以看到,L1 缓存其实包含数据缓存和指令缓存两种,L2缓存和L3缓存不分指令和数据。L1 和 L2 在一个核中。L3 是所有处理器核共享的,L1 和 L2 是每个处理器核特有的。

Cache Line

Cache Line 是缓存进行管理的一个最小存储单元,也叫缓存块。从内存向缓存加载数据也是按缓存块进行加载的。 其实可以简单类比一下 MySQL 的 page,可能我们的 SQL 查询语句最终返回只有几行数据,但是底层 DB 在加载数据时都是一个 page,一个page从磁盘读取数据的,这样效率最高。这里也是一样的道理,你可以理解为 cache line 就是 CPU 加载数据的最小单位。主流的CPU的Cache Line 是 64 Bytes(也有的CPU用32Bytes和128Bytes)。

那么如果 CPU 为 32 KB,Cache Line 单位为 64 Bytes,也就是说一共有 32*1024/64 = 512 个 Cache Line。

对于数据缓存,我们应在循环体中尽量操作同一块内存上的数据,由于缓存是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时也有性能提升。对于指令缓存,有规律的条件分支能够让 CPU 的分支预测发挥作用,进一步提升执行效率。

False Sharing

在多核CPU时代,CPU有“缓存一致性”原则,也就是说每个处理器(核)都会通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了。如果过期了,则失效。比如声明volitate,当变量被修改,则会立即要求写入系统内存。

所谓 False Sharing 的意思是:

当两个线程同时各自修改两个相邻的变量,由于缓存是按缓存块来组织的,当一个线程对一个缓存块执行写操作时,必须使其他线程含有对应数据的缓存块无效。这样两个线程都会同时使对方的缓存块无效,导致性能下降。

比如我声明了一个结构体,里面包含两个变量,各自占用 8 字节,在一个 cache line 中,当启动两个线程各自修改两个变量值时就会出现问题。

image.png

上图中thread0位于core0,而thread1位于core1,二者均想更新彼此独立的两个变量,但是由于两个变量位于同一个cache line中,此时可知的是两个cache line的状态应该都是Shared,而对于cache line的操作core间必须争夺主导权,如果core0抢到了,thread0因此去更新cache line,会导致core1中的cache line状态变为Invalid,随后thread1去更新时必须通知core0cache line刷回主存,然后它再从主从中loadcache line进高速缓存之后再进行修改,但令人抓狂的是,该修改又会使得core0cache line失效,重复上演历史,从而高速缓存并未起到应有的作用,反而影响了性能。

解决办法也很简单,将可能并发修改的变量拆开(比如通过内存 padding)不要放在同一个 cache line,这样两个线程分别操作不同的 cache line 不会相互影响。

按照 cpu cache line(比如 64 字节)来访问内存时,不会出现多核 CPU 下的伪共享问题,可以尽量减少访问内存的次数。比如,若桶大小为 64 字节,那么根据地址获取字符串时只需要访问一次内存,而桶大小为 50 字节,会导致最坏 2 次访问内存,而 70 字节最坏会有 3 次访问内存。

type poolLocal struct {
	poolLocalInternal

	// Prevents false sharing on widespread platforms with
	// 128 mod (cache line size) = 0 .
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

Golang 的 sync.Pool 里面提供了经典的用法,取模后用128减去余数来做 padding,提高性能,这里我们也可以学到,非常热的数据最好cache line对齐。

参考资料