在多线程的环境中,多个线程或协程同时操作内存中的共享变量,如果不加限制的话,就会出现出乎意料的结果。想保证结果正确,就需要在时序上让来自不同线程的访问串行化,彼此之间不出现重叠。
线程对变量的操作一般就是Load和Store两种,就是我们俗称的读和写。Happens Before也可以认为是对一种串行化描述或要求,目的是保证某个线程对变量的写操作,能够被其他的线程正确的读到。
按照字面含义,你可能会认为,如果事件e2在时序上于事件e1结束后发生,那么就可以说事件e1 happens before e2了。按照一般常识应该是这样的,在我们介绍内存乱序之前你暂时可以这样理解,事实上这对于多核环境下的内存读写操作来讲是不够的。
如果e1 happens before e2,那么也可以说成e2 happens after e1。若要保证对变量v的某个读操作r,能够读取到某个写操作w写入到v的值,必须同时满足以下条件:
1)w happens before r;
2)没有其他针对v的写操作happens after w且before r。
如果e1既不满足happens before e2,又不满足happens after e2,那么就认为e1与e2之间存在并发。
单个线程或协程内部访问某个变量是不存在并发的,默认就能满足happens before条件,因此某个读操作总是能读到之前最近一次写操作写入的值。但是在多个线程或协程之间就不一样了,因为存在并发的原因,必须通过一些同步机制来实现串行化,以确立happens before条件。
在单核CPU上分时交替运行的多个线程,可以认为是最经典的并发场景。宏观上看起来同时在运行的多个线程,微观上是以极短时间片交替运行在同一个CPU上。在多核CPU出现以前,并发指的就是这么一种情况。但是在多核CPU出现以后,并发就不像以前那么简单了,不仅仅是微观上的分时运行,还包含了并行的情况。
抽象的解释并发的话,指的是多个事件在宏观上是同时发生的,但是并不一定要在同一时刻发生。而并行就不一样了,从微观角度来看,并行的两个事件至少有某一时刻是同时发生的。所以在单核CPU上的多线程只存在并发,不存在并行。只有在多核CPU上,线程才有可能并行执行。
一般来讲,我们认为代码会按照编写的顺序来执行,也就是逐语句、逐行的按顺序执行。然而事实并非如此,编译器有可能对指令顺序进行调整,处理器普遍具有乱序执行的特性,目的都是为了更优的性能。
操作内存的指令可以分成Load和Store两类,也就是按读和写划分。编译器和CPU都会考虑指令间的依赖关系,在不会改变当前线程行为的前提下进行顺序调整,因此在单个线程内依然是逻辑有序的。但这种有序性只是在单个线程内,并不会保证线程间的有序性。
程序中的共享变量都是位于内存中的,指令顺序的变化会让多个线程同时读写内存中的共享变量产生意想不到的结果。这种因指令乱序造成的内存操作顺序与预期不一致的问题,就是我们所谓的内存乱序。
所谓的编译期乱序,指的就是编译器对最终生成的机器指令进行了顺序调整,一般是出于性能优化的目的。造成的影响就是,机器指令的顺序与源代码中语句的顺序并不是严格匹配。这种乱序在C++中很常见,尤其是在编译器的优化级别比较高的时候。这一问题最常用的解决方法就是使用compiler barrier,俗称“编译屏障”。
编译屏障会阻止编译器跨屏障移动指令,但是仍然可以在屏障的两侧分别移动。在GCC中,常用的编译屏障就是在两条语句之间嵌入一个空的汇编语句块:
data = someValue;
asm volatile("" :::"memory"); // compiler barrier
ok = true;
但是加上编译屏障后,依然无法保证程序能够在各个平台上如预期的运行,原因就是CPU在执行期间也可能会对指令的顺序进行调整,也就是所谓的“执行期乱序”。
先用一段代码来让大家亲自见证执行期乱序,这样更有助于后续内容的理解。下面这段代码使用Go语言来实现,平台是amd64:
func main() {
s := [2]chan struct{}{
make(chan struct{}, 1),
make(chan struct{}, 1),
}
f := make(chan struct{}, 2)
var x, y, a, b int64
go func() {
for i := 0; i < 1000000; i++ {
<-s[0]
x = 1
b = y
f <- struct{}{}
}
}()
go func() {
for i := 0; i < 1000000; i++ {
<-s[1]
y = 1
a = x
f <- struct{}{}
}
}()
for i := 0; i < 1000000; i++ {
x = 0
y = 0
s[i%2] <- struct{}{}
s[(i+1)%2] <- struct{}{}
<-f
<-f
if a == 0 && b == 0 {
println(i)
}
}}
代码中一共有3个协程,4个int类型的共享变量,3个协程都会循环100万次,3个channel用来同步每次循环。循环开始时先由主协程将x、y清零,然后通过切片s中的两个channel让其他两个协程开始运行。协程1在每轮循环中先把1赋值给x,再把y赋值给b。协程2在每轮循环中先把1赋值给y,再把x赋值给a。f用来保证在每轮循环中都等到两个协程完成赋值操作后,主协程才去检测a和b的值,两者同时为0时会打印出当前循环的次数。
a和b的取值会有几种可能?
从源码角度来看,无论如何a和b都不应该同时等于0。如果协程1完成赋值后协程2才开始执行,结果就是a等于1而b等于0,反过来就是a等于0而b等于1。如果两个协程的赋值语句并行执行,那么结果就是a和b都等于1。
然而实际运行的话,会发现大量打印输出,根本原因就是出现了执行期乱序。注意,执行期乱序要在并行环境下才能体现出来,单个CPU核心自己是不会体现出乱序的。Go程序可以使用GOMAXPROCS环境变量来控制并发度,针对上述示例代码,GOMAXPROCS设置为1的话即使在多核心CPU上也不会出现乱序。
为什么a和b会同时等于0?
协程一、二中的两条赋值语句形式相似,对应到x86汇编就是三条内存操作指令,按顺序分别是Store、Load、Store。
出现的乱序问题就是由前两条指令造成的,称为Store-Load乱序,这也是当前的x86架构CPU上能够观察到的唯一一种乱序。Store和Load分别操作的是不同的内存地址,从现象来看就像是先执行的Load而后执行的Store。
为什么会出现Store-Load乱序呢?
我们知道现在的CPU普遍带有多级指令和数据缓存,指令执行系统也是流水线式的,可以让多条指令同时在流水线上执行。一般的内存都属于write-back cacheable内存,简称WB内存。对于WB内存而言,Store和Load指令并不是直接操作内存中的数据,而是先把指定的内存单元填充到高速缓存中,然后读写高速缓存中的数据。
Load指令的大致流程:先尝试从高速缓存中读取,如果缓存命中,读操作就完成了。
如果缓存未命中,那么先填充对应的cache line,然后从cache line中读取:
Store指令的大致流程类似,先尝试写高速缓存,如果缓存命中,写操作就完成了。如果缓存未命中,那么先填充对应的cache line,然后写到cache line中:
可能有些读者会对Store操作写之前要先填充cache line感到疑惑,那是因为高速缓存和内存之间的数据传输不是以字节为单位的,最小单位就是一个cache line。cache line大小因处理器的架构而异,常见的大小有32、64及128字节等。
在多核心的CPU上,Store操作会变的更复杂一些。每个CPU核心都拥有自己的高速缓存,例如x86的L1 Cache。写操作会修改当前核心的高速缓存,被修改的数据可能存在于多个核心的高速缓存中,CPU需要保证各个核心间的缓存一致性。
目前主流的缓存一致性协议是MESI协议,MESI这个名字取自缓存单元可能的4种状态,分别是已修改Modified,独占的Exclusive,共享的Shared,和无效的Invalid。
当一个CPU核心要对自身高速缓存的某个单元进行修改时,它需要先通知其他CPU核心把各自高速缓存中对应的单元置为Invalid,再把自己的这个单元置为Exclusive,然后就可以进行修改了。
这个过程涉及多核间内部通信,是一个相对较慢的过程,为了避免当前核心因为等待而阻塞,CPU在设计上又引入了Store Buffer。当前核心向其他核心发出通知以后,可以先把要写的值放在Store Buffer中,然后继续执行后面的指令,等到其他核心完成响应以后,当前核心再把Store Buffer中的值合并到高速缓存中。
虽然高速缓存会保证多核一致性,但是Store Buffer却是各个核心私有的,对其他核心不可见。在Store-Load乱序中,从微观时序上,Load指令可能是在另一个线程的Store之后执行,但此时多核间通信尚未完成,对应的缓存单元还没有被置为Invalid,Store Buffer也没有被合并到高速缓存中,所以Load读到的是修改前的值。
如上图所示,如果协程1执行的Store命令,x的新值只是写入CPU1的Store Buffer,尚未合并到高速缓存,此时协程2执行Load指令拿到的x就是修改前的旧值0,而不是1。同样的,协程2修改y的值也可能只写入的CPU2的Store Buffer,所以协程1执行Load指令加载的y的值就是旧值0。
而当协程1执行最后一条Store指令时,b就被附值为0;同样的,协程2会将a附值为0。即使Store Buffer合并到高速缓存,x和y都被修改为新值,也已经晚了。
我们通过代码示例见证了x86的Store-Load乱序,Intel开发者手册上说x86只会出现这一种乱序。抛开固定的平台架构,理论上可能出现的乱序有4种:
(1)Load-Load,相邻的两条Load指令,后面的比前面的先读到数据;
(2)Load-Store,Load指令在前Store指令在后,但是Store操作先变成全局可见,Load指令在此之后才读到数据;
(3)Store-Load,Store指令在前Load指令在后,但是在Load指令先读到了数据,Store操作在此之后才变成全局可见。这个我们已经在x86平台见证过了;
(4)Store-Store,相邻的两条Store指令,后面的比前面的先变成全局可见。
所谓的全局可见,指的就是在多核CPU上对所有核心可见。因为笔者手边只有amd64架构的工作电脑,暂时无法见证其他几种乱序,有条件的读者可以在其他的架构上尝试一下。比如通过以下代码应该可以发现Store-Store乱序:
func main() {
var wgsync.WaitGroup
var x, yint64
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 1000000000; i++ {
if x == 0 {
if y != 0 {
println("1:", i)
}
x = 1
y = 1
}
}
}()
go func() {
deferwg.Done()
for i := 0;i < 1000000000; i++ {
if y == 1 {
if x != 1 {
println("2:",i)
}
y= 0
x= 0
}
}
}()
wg.Wait()
}
执行期乱序会给结果带来很大的不确定性,这对于应用程序来说是不能接受的,完全按照指令顺序执行又会使性能变差。为了解决这一问题,CPU提供了内存排序指令,应用程序在必要的时候能够通过这些指令来避免发生乱序。
以目前的Intel x86处理器为例,就提供了LFENCE、SFENCE和MFENCE这3条内存排序指令,接下来我们就逐一分析一下它们的作用。
LFENCE是LoadFence的缩写,Fence翻译成中文是栅栏,可以认为起到分隔的作用,它会对当前核心上LFENCE之前的所有Load类指令进行序列化操作。具体来说,针对当前CPU核心,LFENCE会在之前的所有指令都执行完后才开始执行,并且在LFENCE执行完之前,不会有后续的指令开始执行。特别是,LFENCE之前的Load指令,一定会在LFENCE执行完成之前从内存接收到数据。LFENCE不会针对Store指令,Store指令之后的LFENCE可能会在Store写入的数据变成全局可见前执行完成。LFENCE之后的指令可以提前被从内存中加载,但是在LFENCE执行完之前它们不会被执行,即使是推测性的。
以上主要是Intel开发者手册对LFENCE的解释,它原本被设计用来阻止Load-Load乱序。让所有后续的指令在之前的指令执行完后才开始执行,这是Intel对它功能的一个扩展,因此理论上它应该也能阻止Load-Store乱序。考虑到目前的x86 CPU不会出现这两种乱序,所以编程语言中暂时没有用到LFENCE指令来进行多核同步,未来也许会用到。Go的runtime中用到了LFENCE的扩展功能来对RDTSC进行序列化,但是这并不属于同步的范畴。
SFENCE是StoreFence的缩写,它能够分隔两侧的Store指令,保证之前的Store操作一定会在之后的Store操作变成全局可见前先变成全局可见。结合前一小节的高速缓存和Store Buffer,笔者猜测SFENCE影响到Store Buffer合并到高速缓存的顺序。
根据上述解释,SFENCE应该主要是用来应对Store-Store乱序,由于现阶段的x86 CPU也不会出现这种乱序,所以编程语言暂时也未用到它来进行多核同步。
MFENCE是MemoryFence的缩写,它会对之前所有的Load和Store指令进行序列化操作,这个序列化会保证MFENCE之前的所有Load和Store操作会在之后的任何Load和Store操作前先变成全局可见。所以上述3条指令中,只有MFENCE能够阻止Store-Load乱序。
我们对之前的示例代码稍作修改,尝试使用MFENCE指令来阻止Store-Load乱序,新的示例中用到了汇编语言,所以需要两个源码文件。首先是汇编代码文件fence_amd64.s:
#include "textflag.h"
// func mfence()
TEXT ·mfence(SB), NOSPLIT, $0-0
MFENCE
RET
接下来是修改过的Go代码,被放置在fence.go文件中,跟之前会发生乱序的代码只有一点不同,就是在Store和Load之间插入了MFENCE指令。如下所示:
package main
func main() {
s :=[2]chan struct{}{
make(chanstruct{}, 1),
make(chan struct{}, 1),
}
f := make(chan struct{}, 2)
var x, y, a, b int64
go func() {
for i := 0; i <1000000; i++ {
<-s[0]
x = 1
mfence()
b = y
f <-struct{}{}
}
}()
go func() {
for i := 0;i < 1000000; i++ {
<-s[1]
y = 1
mfence()
a = x
f <-struct{}{}
}
}()
for i := 0;i < 1000000; i++ {
x = 0
y = 0
s[i%2] <- struct{}{}
s[(i+1)%2] <- struct{}{}
<-f
<-f
if a == 0 && b == 0 {
println(i)
}
}
}
func mfence()
编译执行上述代码,就会发现之前的Store-Load乱序不见了,程序不会有任何打印输出。如果将MFENCE指令换成LFENCE或SFENCE,就无法达到同样的目的了,感兴趣的读者可以自己尝试一下。
通过内存排序指令解决了执行期乱序造成的问题,但是这并不足以解决并发场景下的同步问题。要想结合代码逻辑轻松的实现多线程同步,就要用到专门的工具——锁。