线程安全性深层原因
这里我们将会从计算机硬件和编辑器等方面来详细了解线程安全产生的深层原因。
缓存一致性问题
CPU内存架构
随着CPU的发展,而因为CPU的速度和内存速度不匹配的问题(CPU寄存器的访问速度非常快,而内存访问速度相对偏慢),所有在CPU和内存之间出现了多级高速缓存。下图是现代CPU和内存的一般架构图:
我们可以看到高速缓存也分为三级缓存,越靠近寄存器的级别缓存访问速度越快。其中L3 Cache为多核共享的,L1和L2 Cache为单核独享,而L1又有数据缓存(L1 d)和指令缓存(L1 i)。
正因为高速缓存的出现,各CPU内核从主内存获取相同的数据将会存在于缓存中,当多核都对此数据进行操作并修改值,此时另外的核心并不知道此值已被其他核心修改,从而出现缓存不一致的问题。
如何解决缓存一致性问题
解决缓存一致性问题一般有两个方法:
- 第一个是采用总线锁,在总线级别加锁,这样从内存种访问到的数据将被当个CPU核心独占,在多核的情况下对单个资源将是串行化的。这种方式性能上将大打折扣。
- 第二个是采用缓存锁,在缓存的级别上进行加锁。此种方式需要某种协议对缓存行数据进行同步,后面所说的缓存一致行协议便是一种实现。
缓存一致性协议(MESI)
为了解决缓存一致性的问题,一些CPU系列(比如Intel奔腾系列)采用了MESI协议来解决缓存一致性问题。此协议将每个缓存行(Cache Line)使用4种状态进行标记。
- M: 被修改(Modified)
该缓存行只被缓存在该CPU核心的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它CPU读取请主存中相应内存之前)写回(write back)主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
- E: 独享的(Exclusive)
该缓存行只被缓存在该CPU核心缓存中,它是未被修改过的(clean),与主存中数据一致。该状态可以在任何时刻当有其它CPU核心读取该内存时变成共享状态(shared)。同样地,当CPU核心修改该缓存行中内容时,该状态可以变成Modified状态。
- S: 共享的(Shared)
该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(clean),当有一个CPU修改该缓存行中,其它CPU中该缓存行可以被作废(变成无效状态(Invalid))。
- I: 无效的(Invalid)
该缓存是无效的(可能有其它CPU核心修改了该缓存行)
在MESI协议中,每个CPU核心的缓存控制器不仅知道自己的操作(local read和local write),每个核心的缓存控制器通过监听也知道其他CPU中cache的操作(remote read和remote write),再确定自己cache中共享数据的状态是否需要调整。
- local read(LR):读本地cache中的数据;
- local write(LW):将数据写到本地cache;
- remote read(RR):其他核心发生read;
- remote write(RW):其他核心发生write;
针对操作,缓存行的状态迁移图如下:
指令重排序问题
在我们编程过程中,习惯性程序思维认为程序是按我们写的代码顺序执行的,举个例子来说,某个程序中有三行代码:
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
从程序员角度执行顺序应该是1 -> 2 -> 3,实际经过编译器和CPU的优化很有可能执行顺序会变成 2 -> 1 -> 3(注意这样的优化重排并没有改变最终的结果)。类似这种不影响单线程语义的乱序执行我们称为指令重排。(后面讲Java内存模型也会讲到这部分。)
编译器指令重排
举个例子,我们先看可以看一段代码:
class ReorderExample {
int a = 0;
boolean flag = false;
public void write() {
a = 1; // 1
flag = true; // 2
}
public void read() {
if (flag) { // 3
int i = a * a; // 4
}
}
}
在单线程的情况下如果先write再read的话,i的结果应该是1。但是在多线程的情况下,编译器很可能对指令进行重排,有可能出现的执行顺序是2 -> 3 -> 4 -> 1。这个时候的i的结果就是0了。(1和2之间以及3和4之间不存在数据依赖,有关数据依赖在后面的Java内存模型中会讲到。)
CPU指令重排
在CPU层面,一条指令被分为多个步骤来执行,每个步骤会使用不同的硬件(比如寄存器、存储器、算术逻辑单元等)。执行多个指令时采用流水线技术进行执行,如下示意图:
注意这里出现的”停顿“,出现这个原因是因为步骤22需要步骤13得到结果后才能进行。CPU为了进一般优化:消除一些停顿,这时会将指令3(指令3对指令2和1都没有数据依赖)移到指令2之前进行运行。这样就出现了指令重排,根本原因是为了优化指令的执行。
内存系统重排
CPU经过长时间的优化,在寄存器和L1缓存之间添加了LoadBuffer、StoreBuffer来降低阻塞时间。LoadBuffer、StoreBuffer,合称排序缓冲(Memoryordering Buffers (MOB)),Load缓冲64长度,store缓冲36长度,Buffer与L1进行数据传输时,CPU无须等待。
- CPU执行load读数据时,把读请求放到LoadBuffer,这样就不用等待其它CPU响应,先进行下面操作,稍后再处理这个读请求的结果。
- CPU执行store写数据时,把数据写到StoreBuffer中,待到某个适合的时间点,把StoreBuffer的数据刷到主存中。
因为StoreBuffer的存在,CPU在写数据时,真实数据并不会立即表现到内存中,所以对于其它CPU是不可见的;同样的道理,LoadBuffer中的请求也无法拿到其它CPU设置的最新数据;由于StoreBuffer和LoadBuffer是异步执行的,所以在外面看来,先写后读,还是先读后写,没有严格的固定顺序。
由于引入StoreBuffer和LoadBuffer导致异步模式,从而导致内存数据的读写可能是乱序的(也就是内存系统的重排序)。
内存屏障
为了解决CPU优化带来的不可见、重排序的问题,可以使用内存屏障(memory barrier)来阻止一定的优化(在后面介绍Java内存模型也会详细结合讲内存屏障)。不同的CPU架构对内存屏障的实现方式与实现程度非常不一样,下面我们看下X86架构中内存屏障的实现。
Store Barrier
使所有Store Barrier之前发生的内存更新都是可见的。
Load Barrier
使所有Store Barrier之前发生的内存更新,对Load Barrier之后的load操作都是可见的。
Full Barrier
所有Full Barrier之前发生的操作,对所有Full Barrier之后的操作都是可见的。
延伸
在程序我们常说的三大性质:可见性、原子性、有序性。通过线程安全性深层原因我们能更好的理解这三大性质的根本性原因。(可见性、原子性、有序性会在后面文章中进行详细讲解。)