上期文章说到 Go 语言中,单变量的原子的读或写操作,CPU 的多核心使用了 mesi 协议来确保正确性,也就足够了。但是 mesi 协议还存在一些缺陷:如果对某个变量进行先读,然后修改,再写,这样的多步操作,mesi 协议是无法解决的。因此我们需要除了读和写之外的指令,能够保证这种一系列操作的原子性。

内存重排

mesi 能够保证单变量全局顺序的正确性,但其他多变量的全局顺序其实是保证不了的。在 Go 语言的并发编程中,有个叫做内存重排,指的是内存的读/写指令重排。为什么会用到内存重排呢?一些硬件或者编译器会对程序进行一些指令优化,优化后的结果可能会导致程序编码时的顺序与代码编译后的先后顺序不一致。因此,内存重排就专门为其适配,提升程序执行效率,减少一些IO操作。

一般我们可以使用工具 litmus 来验证内存重排,具体暂时不演示了,网上有很多文章。它会模拟本机上一些多线程的读写指令,来对其结果做一些判断。这部分内容不难,刚毕业的学生在面试的时候也会遇到面试官问其中的一些汇编指令是什么意思。其中涉及到的汇编源码,其实我们可以简化为以下的伪代码

image-20211215232733958

关键来了,我们在写并发代码的时候,如何考虑内存重排呢?对大多数程序员来说,不用考虑这个,既然知道在并发的时候诸如以上的操作都可能有内存重排,那么需要保证其顺序性的时候,直接在两遍加上互斥锁就 OK 了。而如果还要考虑更高的性能,就需要使用一些工具。

Memory barrier

如果我们要阻止内存重排的发生,只能使用 Memory barrier。首先,它和内存回收 GC 中的 write barrier 和 read barrier 不是一回事。网上一些文章中会把它们混淆起来,因为它们看起来都有个 barrier。虽然看起来都有 barrier,但其实完全不是一回事。

image-20211215233343872

在并发编程中 ,Memory barrier 是为了防止各种类型的读写重排而专门设计出的工具。比如说 atomic load,在其他语言中都需要加条件,诸如 require、flag 线性一致。

在 GC 中的 write/read barrier 其实是指在堆上的指针修改之前,插入的一小段的代码。

以上的 barrier 其实对于做 Go 应用开发的程序员来说可以不用详细了解,只要知道程序在加锁的时候能够保证正确性就 OK 了。

False sharing

上期文章我们说到了 CPU 采取的是分层存储结构,L1 cache 是 64 个字节。而实际上我们共享变量还不到 64 字节。比如我自定义了一个 struct,它只有 16 字节。但对 CPU 来说,它从内存去在加载这个 struct 的时候,它会把这个 struct 临近的内存也一起加载到 L1 cache 中。

但这会导致一个问题:因为 CPU 处理读写是以 cache line 为单位,所以并发修改变量时,会一次性将其他 CPU core 中的 cache line invalidate 掉,导致未修改的内存上相邻的变量也需要同步,带来额外的性能负担。

现在我在 struct 中修改了一个字段,它会把整个包括 struct 和临近总和为 64 字节都 invalidate 使之无效,最终导致临近的变量没有被修改,但在其他的核心中也变成了非法访问的状态。并且,如果这些变量都修改非常频繁,就会出现一个 cache line 在多个核心之间反复地进行同步,最终导致一定的性能低下。也正因如此,在 Go 的 Runtime 中会有 pad 结构,在修改 empty 的时候一定不会影响 work 旁边的一些全局变量。

image-20211215234754940

True sharing

今天最后个概念就很好理解了,与 False sharing 是相对的。如果说 False sharing 是我们没有真正地共享某个全局变量,不小心地修改了没有被共享的全局变量。而 True sharing 就是我们真正地去共享一个全局变量。比如多线程中,确实在共享并更新同一个变量/内存区域。

OK,下期文章还会讲解关于 Happen-before,网上也有很多文章在分享它,但存在着一些误区,我会通过一些例子进行讲解的。