基本定义
最开始接触这三个定义,还是大学里写 java 的时候,先简单看一下概念。这三个特性是用来保证并发安全的。
-
原子性(Atomicity)
一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
由于程序的执行机制,只有底层的指令才天然的有原子性,比如 i ++ 转换为汇编以后发现其实是有三条语句,并不满足原子性。
如果需要对更大的操作序列保证原子性,就需要使用锁的机制。
-
可见性(Visibility)
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
-
有序性(Ordering)
一个线程中的所有操作必须按照程序的顺序来执行。
最近在看 goroutine 的时候也涉及到了这个概念,其实这个是和计算机底层相关的。
为什么会有这么些个复杂的概念
一切都是为了效率。为了更高的效率, 计算机引入了多核,每个CPU又添加了三级缓存。
三级缓存
在CPU和内存的资源交换中,CPU常常需要等待内存,而浪费了大量的计算能力。
三级缓存的出现正是为了弥补内存的慢和CPU的快而诞生的产物。
CPU将不再直接与内存进行数据的交换而是在二者之间加入一个缓存以解决这种不协调而导致的资源浪费情况。
三级缓存分为三种:L1 Cache,L2 Cache,L3 Cache 。级别越低,速度越快,越贵也就容量越小。
为什么缓存能加速
程序在执行的时候,满足两个特性
-
时间局限性
如果某个数据被访问,那么它在不久的将来有可能被在次访问
-
空间局限性
如果某个缓存行的数据被访问,那么与之相邻的缓存行很快可能被访问到
CPU缓存一致性问题
CPU一二级缓存是核心独占的,而三级缓存和主内存是共享的。
这样不同的CPU在访问同一块主内存的时候,由于各自都会先从自己的缓存里拿,就可能导致读取到的值不同。
因此,提出了 MESI 协议。
MESI协议
定义
在缓存中数据的存储单元是缓存行(Cache line),主流的CPU缓存行都是64个字节。
MESI协议是一种采用写–无效方式的监听协议。它要求每个cache行有两个状态位,用于描述该行当前是处于修改态(M)、专有态(E)、共享态(S)或者无效态(I)中的哪种状态,从而决定它的读/写操作行为。
状态 | 含义 |
---|---|
M(Modified) | 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 |
E(Exclusive) | 这行数据有效,数据和内存中的数据一致,数据只存在于本 Cache 中。 |
S(Shared) | 这行数据有效,数据和内存中的数据一致,数据存在于很多 Cache 中。 |
I(Invalid) | 这行数据无效。 |
当一个CPU缓存行的状态发生改变时,需要同步的改变其他CPU对应的缓存行的状态,维护缓存一致性。
带来的问题
多个core的缓存状态置换是需要消耗时间的,导致内核在此期间将无事可做。甚至一旦某一个内核发生阻塞,将会导致其他内核也处于阻塞,从而带来性能和稳定性的极大消耗。
这种等待有时是没有必要的,因为在这个等待时间内内核完全可以去干一些其他事情。即当内核处于等待状态时,不等待当前指令结束接着去处理下一个指令。
指令重排
定义
简单来说,就是指你在程序中写的代码,在执行时并不一定按照写的顺序。而是一个指令还未结束便去执行其它指令。异步的思想。
实现
-
Store Buffere—存储缓存
store buffer即存储缓存。位于内核和缓存之间。当处理器需要处理将计算结果写入在缓存中处于shared状态的数据时,需要通知其他内核将该缓存置为 Invalid(无效),引入store buffer后将不再需要处理器去等待其他内核的响应结果,只需要把修改的数据写到store buffer,通知其他内核,然后当前内核即可去执行其它指令。当收到其他内核的响应结果后,再把store buffer中的数据写回缓存,并修改状态为M。
-
Invalidate Queue—失效队列
简单说处理器修改数据时,需要通知其它内核将该缓存中的数据置为Invalid(失效),我们将该数据放到了Store Buffere处理。那收到失效指令的这些内核会立即处理这种失效消息吗?答案是不会的,因为就算是一个内核缓存了该数据并不意味着马上要用,这些内核会将失效通知放到Invalidate Queue,然后快速返回Invalidate Acknowledge消息。后续收到失效通知的内核将会从该queue中逐个处理该命令。
数据一致性问题
可以保证缓存的一致性,但是无法保证实时性,可能会有极短时间的脏读问题。
Happens-Before原则
重排序和CPU高速缓存有利于计算机性能的提高,但却对多CPU处理的一致性带来了影响。
为了解决这个矛盾,我们可以采取一种折中的办法。我们用分割线把整个程序划分成几个程序块,在每个程序块内部的指令是可以重排序的,但是分割线上的指令与程序块的其它指令之间是不可以重排序的。在一个程序块内部,CPU不用每次都与主内存进行交互,只需要在CPU缓存中执行读写操作即可,但是当程序执行到分割线处,CPU必须将执行结果同步到主内存或从主内存读取最新的变量值。
Happens-Before 规则就是定义了这些程序块的分割线。Happens-Before是一种偏序关系。如果存在 happen-before(a,b)
,那么操作a及a之前在内存上面所做的操作(如赋值操作等)都对操作b可见,即操作 a 影响了操作 b 。
内存屏障
定义
CPU就给我们提供了指令,控制什么指令不能重排,什么指令能重排的机制。
- load:将内存中的数据拷贝到内核的缓存中。
- store:将内核缓存的数据刷新到内存中。
将这两条指令结合起来,就得到了内存屏障:Memory Barrier。
分类
-
LoadLoad Barriers(读屏障)
告诉处理器在执行任何的加载前,执行所有已经在失效队列中的失效(I)指令。所有load barrier之前的store指令对之后(本核心和其他核心)的指令都是可见的。
-
StoreStore Barriers(写屏障)
告诉处理器在执行这之后的指令之前,执行所有已经在存储缓存中的修改(M)指令。所有store barrier之前的修改(M)指令都是对之后的指令可见。
-
LoadStore Barriers
-
StoreLoad Barriers
缺点
CPU不知道什么时候需要加入内存屏障,CPU将这个加入内存屏障的时机交给了程序员。
如java 提供了 volatile 关键字, go 提供了 atomic 包。
自旋锁和互斥
除了用原子操作来处理原子数据并发问题以外,对于比较复杂的操作,我们往往需要需要使用锁来实现。对于操作系统来说,只有两种形式的锁。
-
互斥 (mutex)
mutex为睡眠等待类型的锁,当线程抢锁失败后,线程会陷入休眠。可以节省CPU资源但会消耗一定的时间用于唤醒。
互斥锁加锁失败后,线程会释放 CPU,给其他线程。
-
自旋锁(spinlock)
自旋锁通俗的说就是忙等待。通过CPU提供的CAS(Compare and Swap)实现。
自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
golang 里锁的实现可以参考同步原语与锁
总结
- CPU 比 内存 快太多了,所以引入了 三级缓存
- 多个 CPU 的 L1 缓存和 L2 缓存是独占的,L3 缓存和主存是共享的,所以导致了数据不一致
- 为了解决数据不一致,所以提出来MESI协议,通过同步缓存行的状态,来保证一致性,可是这样在同步的时候 CPU 会被阻塞,又变慢了。
- 通过异步的思想,在执行时并不一定按照写的顺序,而是一个指令还未结束便去执行其它指令,这叫做指令重排。指令重排需要遵守 **Happens-Before **原则,把整个程序划分成几个程序块来执行。
- 指令重排可以保证缓存的一致性,但是无法保证实时性,可能会有极短时间的脏读问题。所以CPU又提供了内存屏障的机制,控制指令能否重排。
- 但是 CPU 不清楚什么时候可以重排,所以语言层面上就提供了关键字和包来交给程序员控制。
参考资料
volatile关键字?MESI协议?指令重排?内存屏障?这都是啥玩意