Golang和假共享(false sharing)

多核处理器(SMP)系统中, 每一个处理器都有一个本地高速缓存。内存系统必须保证高速缓存的一致性。当不同处理器上的线程修改驻留再同一高速缓存中的变量时就会发生假共享(false sharing),结果导致高速缓存无效,并强制更新,进而影响系统性能。

什么是假共享(false sharing)

假共享是 SMP 系统上的一种常见性能问题。在SMP系统中,每个处理器均有一个高速缓存。 当不同处理器上的线程修改驻留在同一高速缓存行(Cache Block,或Cache Line)中的变量时就会发生假共享。 这种现象之所以被称为假共享,是因为每个线程并非真正共享相同变量的访问权。 访问同一变量或真正共享要求编程式同步结构,以确保有序的数据访问。

img

线程 0 和线程 1 会用到不同变量,它们在内存中彼此相邻,并驻留在同一高速缓存块(Cache Block,或Cache Line)。 高速缓存行被加载到 CPU 0 和 CPU 1 的高速缓存中(灰色箭头)。 尽管这些线程修改的是不同变量(红色和蓝色箭头),高速缓存行(Cache Block,或Cache Line)仍会无效,并强制内存更新以维持高速缓存的一致性,这会降低应用性能。

避免假共享

避免假共享的主要方式是进行代码检查。 潜在的假共享主要出现在线程访问全局或动态分配共享数据结构。 注意,在线程访问内存中碰巧相近的几个完全不同的全局变量时,也会出现假共享。 线程本地存储或本地变量不会导致假共享。

可以通过内存填充(padding)的方式予以更正, 目的是确保引起假共享的变量在内存中存放的位置相隔足够远,从而不会驻留在同一高速缓存块中。

来自intel开发社区的避免共享的建议

避免假共享,但要谨慎使用。 过度使用会影响处理器可用高速缓存的有效使用。 即便对于多处理器共享高速缓存设计,仍然建议避免假共享。 尝试最大限度提高多处理器共享高速缓存的利用率,可能会带来一些好处,但一般不会超过支持不同高速缓存架构的多代码路径,所需的软件维护成本。

现在基本上所有CPU的缓存行大小都是64字节,如下面的Go代码, 数据可能会驻留在同一高速缓存中,进而出现假共享。

代码来自colobu

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type NoPad struct {
	a uint64
	b uint64
	c uint64
}

func (np *NoPad) Increase() {
	atomic.AddUint64(&np.a, 1)
	atomic.AddUint64(&np.b, 1)
	atomic.AddUint64(&np.c, 1)
}

可以通过填充64字节,使abc不会驻留同一高速缓存中,进而避免伪共享:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type Pad struct {
	a   uint64
	_p1 [8]uint64
	b   uint64
	_p2 [8]uint64
	c   uint64
	_p3 [8]uint64
}

func (np *Pad) Increase() {
	atomic.AddUint64(&np.a, 1)
	atomic.AddUint64(&np.b, 1)
	atomic.AddUint64(&np.c, 1)
}

性能测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func BenchmarkPad_Increase(b *testing.B) {
	pad := &Pad{}

	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			pad.Increase()
		}
	})
}

func BenchmarkNoPad_Increase(b *testing.B) {
	nopad := &NoPad{}
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			nopad.Increase()
		}
	})
}

运行结果:

1
2
3
4
goos: darwin
goarch: amd64
BenchmarkPad_Increase-12      	50000000	        26.2 ns/op	       0 B/op	       0 allocs/op
BenchmarkNoPad_Increase-12    	30000000	        47.5 ns/op	       0 B/op	       0 allocs/op

通过填充后的性能相比于没有填充之前要好很多。

假共享检测

运行时检测方法是使用英特尔® VTune 性能分析器或 英特尔® 性能调优实用程序(英特尔 PTU,请见 /zh-cn/articles/intel-performance-tuning-utility/)。 此方法通过基于事件取样(可发现哪些位置存在高速缓存行共享)来揭示性能影响。 但是,这种影响不区分真共享与假共享。

其他更多关于假共享

  1. C++ 和 false sharing
  2. Wiki False sharing
  3. Analysis of False Cache Line Sharing Effects on Multicore CPUs

附:CPU缓存

摘抄自维基百科的CPU缓存介绍:

计算机系统中,CPU高速缓存(英语:CPU Cache,在本文中简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。

当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。

缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。

在处理器看来,缓存是一个透明部件。因此,程序员通常无法直接干预对缓存的操作。但是,确实可以根据缓存的特点对程序代码实施特定优化,从而更好地利用缓存。

缓存的存储结构

结构上,一个直接映射(Direct Mapped)缓存由若干缓存块(Cache Block,或Cache Line)构成。每个缓存块存储具有连续内存地址的若干个存储单元。在32位计算机上这通常是一个双(dword),即四个字节。因此,每个双字具有唯一的块内偏移量。

每个缓存块有一个索引(Index),它一般是内存地址的低端部分,但不含块内偏移和字节偏移所占的最低若干位。一个数据总量为4KB、缓存块大小为16B的直接映射缓存一共有256个缓存块,其索引范围为0到255。使用一个简单的移位函数,就可以求得任意内存地址对应的缓存块的索引。由于这是一种多对一映射,必须在存储一段数据的同时标示出这些数据在内存中的确切位置。所以每个缓存块都配有一个标签(Tag)。拼接标签值和此缓存块的索引,即可求得缓存块的内存地址。如果再加上块内偏移,就能得出任意一块数据的对应内存地址。

CPU缓存_00缓存段结构

此外,每个缓存块还可对应若干标志位,包括有效位(valid bit)、脏位(dirty bit)、使用位(use bit)等。这些位在保证正确性、排除冲突、优化性能等方面起着重要作用。

缓存的发展史

在科研领域,C. J. Conti等人于1968年在描述360/85和360/91系统性能差异时最早引入了高速缓存(cache)一词[8]。Alan Jay Smith于1982年的一篇论文中引入了空间局部性和时间局部性的概念。Mark Hill在1987年发明了3C(Compulsory, Capacity, Conflict)冲突分类。[9]

最早介绍非阻塞缓存的论文之一来自David Kroft(1981年)。1990年Norman Paul Jouppi在一篇论文中介绍了受害者缓存并研究了使用流缓冲器进行预取的性能。[10]

在工业领域,最早的有记载的缓存出现在IBM36085系统上[11]

Intelx86架构CPU从386开始引入使用SRAM技术的主板缓存,大小从16KB到64KB不等。486引入两级缓存。其中8KBL1缓存和CPU同片,而L2缓存仍然位于主板上,大小可达268KB。将二级缓存置于主板上在此后十余年间一直设计主流。但是由于SDRAM技术的引入,以及CPU主频和主板总线频率的差异不断拉大,主板缓存在速度上的对内存优势不断缩水。因此,从Pentium Pro起,二级缓存开始和处理器一起封装,频率亦与CPU相同(称为全速二级缓存)或为CPU主频的一半(称为半速二级缓存)。

AMD则从K6-III开始引入三级缓存。基于Socket 7接口的K6-III拥有64KB和256KB的同片封装两级缓存,以及可达2MB的三级主板缓存。

今天的CPU将三级缓存全部集成到CPU芯片上。多核CPU通常为每个核配有独享的一级和二级缓存,以及各核之间共享的三级缓存。

参考资料

  1. https://medium.com/@genchilu/whats-false-sharing-and-how-to-solve-it-using-golang-as-example-ef978a305e10
  2. https://software.intel.com/zh-cn/articles/avoiding-and-identifying-false-sharing-among-threads
  3. https://colobu.com/2019/01/24/cacheline-affects-performance-in-go/
  4. https://zh.wikipedia.org/wiki/CPU%E7%BC%93%E5%AD%98
  5. https://github.com/genchilu/concurrencyPractice/tree/master/golang/pad
  6. https://stackoverflow.com/questions/14707803/line-size-of-l1-and-l2-caches
  7. https://www.scss.tcd.ie/Jeremy.Jones/CS3021/5%20caches.pdf
  8. https://www.7-cpu.com