MESI缓存一致性协议 伪共享 合并写 有序性 内存屏障
CPU Cache与Memory关系图
先看一张存储器的层次结构图
现在的处理器都是多核处理器,并且每个核都带有多个缓存,为什么需要缓存,是因为cpu的速度特别快,比内存快多个数量级,所以在cpu与内存之间加了个缓存用来提高访问速度。现在假如有一个数在内存里,这个数他会被load到 L3缓存上,L1和L2是在CPU的内部的,这时候会产生一个情况,L3或者主存里面这个数会被load不同的cpu的内部,这个时候如果把cpu1的x修改成1,cpu2的x修改成2,就会产生数据不一致问题,多核同时访问同一个变量时这些缓存是如何进行同步的,两种方案总线锁和MESI缓存一致性来解决。
总线锁
总线锁会锁住总线,使得其他cpu甚至不能访问内存中其他的地址,在L2和L3中间加把锁,独占共享内存,因而效率比较低,但在一些情况必须使用总线锁
- 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定
- 有些处理器不支持缓存锁定,对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁
现在的cpu的数据一致性实现=缓存锁+总线锁
MESI协议缓存状态
MESI中每个缓存行(cache line)都有4个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid)
缓存行(Cache line):缓存存储数据的单元 64bytes
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状态转换图
local read、local write 分别代表本地cache读和写本地cache数据,remote read、remote write分别代表其他cache读和写本地cache数据。
当前状态 | 事件 | 行为 | 下一个状态 |
---|---|---|---|
M(modified) | local read | 状态不变 | M |
local write | 状态不变 | M | |
remote read | 先把cache中数据写入内存中,其他cpu的cache在读取,状态都变成S | S | |
remote write | 先把cache中的数据写入到内存中,其他cpu的cache再读取并修改后,本地cache状态变成I,修改的那个cache状态变成M | I | |
E(exclusive) | local read | 状态不变 | E |
local write | 状态变成M | M | |
remote read | 数据和其他核共享,状态变成S | S | |
remote write | 数据被修改,状态变成I | I | |
S(shared) | local read | 状态不变 | S |
local write | 其他CPU的cache状态变成I,本地cache状态变成M | M | |
remote read | 状态不变 不影响 | S | |
remote write | 本地cach状态变成I,修改内容的CPU的cache状态变成M | I | |
I(invalid) | local read | 1.如果其他处理器中没有这份数据,本缓存从内存中取该数据,状态变成E 2.如果其他处理器中有这份数据,且缓存行状态为M,则将数据更新到内存,本地cache再从内存中读取数据,两个cache的缓存行状态都变成S 3.如果其他处理器中有这份数据,且缓存行状态为S或者E,本地cache从内存中读取数据。这些cache的缓存行状态都变成S | E/S |
local write | 1.先从内存中读取数据,如果其他缓存中有这份数据,且状态为M,则先将数据更新到内存再读取,这时状态都变成S,如果本地cache再修改的话,则其他CPU的状态变成I,自己变成M 2.如果其他缓存中有这份数据,其状态为E或S,那么其他缓存行的状态变成I | M | |
remote read | 状态不变 | I | |
remote write | 状态不变 | I |
伪共享
缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
缓存行通常是64字节,并且它有效地引用主内存中的一块地址,它不是只把这一个数据放进去,假如long类型有8个字节,但是我读到缓存时,它不是只把这8个字节读出来,还要把整个缓存行全部读出来。
下面来看两个例子:
第一种写法:定义只有8个字节的long类型变量,不断修改此变量的值,这样就会生产一个缓存行需要不断的更新
public class CacheLinePadding {
private static class T {
//创建一个类T 定义一个long类型变量x 占8个字节
public volatile long x = 0L;
}
//定义一个T类型数组
public static T[] arr = new T[2];
//静态代码块,初始化数组,指向new出来的对象,每个对象只有一个8个字节的long类型
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
//启动两个线程
//第一个线程,循环一百万次,让x的值不断产生变化,刚好两个值位于一个缓存行,又正好位于一个cpu,那就会发生第一个cpu和第二个cpu不断更新缓存行
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start )/ 1000000);//耗时 332 毫秒
}
}
第二种写法:保证类T自己占64个字节,独占一个缓存行
public class CacheLinePadding_02 {
//创建一个类,定义了7个long类型变量 8*7个字节
private static class Padding{
public volatile long var1,var2,var3,var4,var5,var6,var7;
}
//继承Padding类,这样T 自己占用个64个字节,独占一个缓存行
private static class T extends Padding {
//创建一个类T 定义一个long类型变量x 占8个字节
public volatile long x = 0L;
}
//定义一个T类型数组
public static T[] arr = new T[2];
//静态代码块,初始化数组,指向new出来的对象,每个对象只有一个8个字节的long类型
static {
arr[0] = new T();
arr[1] = new T();
}
public static void main(String[] args) throws InterruptedException {
//启动两个线程
//第一个线程,循环一百万次,让x的值不断产生变化,刚好两个值位于一个缓存行,又正好位于一个cpu,那就会发生第一个cpu和第二个cpu不断更新缓存行
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000_0000L; i++) {
arr[0].x = i;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000_0000L; i++) {
arr[1].x = i;
}
});
final long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println((System.nanoTime() - start));
System.out.println((System.nanoTime() - start) / 1000000);// 耗时 81 毫秒
}
}
位于同一个缓存行的两个不同数据x、y,被两个不同的cpu锁定,产生相互影响的伪共享问题。一个cpu去读数据x的时候会把整行都load到内存,y也会被读到,另一个cpu读取y时,也会把x load到内存
如何避免伪共享
通过上面的代码,我们可以得知那就是缓存行填充(Padding),在并发编程如果队列的head头节点与tail尾节点都不足64字节的话,处理器会将他们读到同一个缓存行,在多处理器下每个处理器都会缓存同样的头尾节点,当一个处理器修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存的尾节点,而队列的入队和出队操作则需要不停地修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率,所以需要追加64字节的方式填满高速缓存区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头尾节点在修改时不会互相锁定。
乱序
由于cpu的速度要比内存快很多,在费时长的指令执行时,排在后面的指令不必等前面的指令执行完毕就开始执行后面的指令,前提是这两条命令没有依赖关系。
合并写
写操作也有可能出现乱序的问题,cpu在L1和cpu中间还有一个缓存加WCBuffer合并写写操作也可以进行合并。
合并写的意思就是说,当cpu执行存储命令时,它会首先将数据写到离它最近的L1里面,如果此时出现L1未命中,则会写到L2里面,由于L2相对于L1和cpu较慢,在L1未命中以后,cpu就会使用一个另外的缓存区,就是合并写存储缓存区。假如在写的过程中这个数后续的一些指令修改了这个值,它就会把这些执行合并到一起,放到合并写缓存里,最终写到L2里。
看下面的代码示例
public final class WriteCombining {
private static final int ITERATIONS = Integer.MAX_VALUE;
private static final int ITEMS = 1 << 24;
private static final int MASK = ITEMS - 1;
private static final byte[] arrayA = new byte[ITEMS];
private static final byte[] arrayB = new byte[ITEMS];
private static final byte[] arrayC = new byte[ITEMS];
private static final byte[] arrayD = new byte[ITEMS];
private static final byte[] arrayE = new byte[ITEMS];
private static final byte[] arrayF = new byte[ITEMS];
public static void main(final String[] args) {
for (int i = 1; i <= 3; i++) {
System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne());
System.out.println(i + " SplitLoop duration (ns) = " + runCaseTwo());
}
}
public static long runCaseOne() {
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i; // 1Byte
arrayA[slot] = b; // 1Byte
arrayB[slot] = b; // 1Byte
arrayC[slot] = b; // 1Byte
arrayD[slot] = b; // 1Byte
arrayE[slot] = b; // 1Byte
arrayF[slot] = b; // 1Byte
}
return (System.nanoTime() - start)/100000;
}
public static long runCaseTwo() { // 一次正好写满一个四字节的 Buffer,比上面的循环效率更高
long start = System.nanoTime();
int i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i; // 1Byte
arrayA[slot] = b; // 1Byte
arrayB[slot] = b; // 1Byte
arrayC[slot] = b; // 1Byte
}
i = ITERATIONS;
while (--i != 0) {
int slot = i & MASK;
byte b = (byte) i;
arrayD[slot] = b;
arrayE[slot] = b;
arrayF[slot] = b;
}
return (System.nanoTime() - start)/100000;
}
}
测试结果:
单次执行 (ms) = 5010
拆分两次执行 (ms) = 4462
测试环境是4核的CPU,runCaseOne函数中连续写入6个不同位置的内存,那么当4个数据写满了合并写缓冲时,cpu就要等待合并写缓冲区更新到L2cache中,因此cpu就被强制暂停了。然而在runCaseTwo函数中是每次写入4个不同位置的内存,可以很好的利用合并写缓冲区,因合并写缓冲区满到引起的cpu暂停的次数会大大减少,当然如果每次写入的内存位置数目小于4,也是一样,所以性能上会有差距。
再看一个写操作乱序的例子
public class InstructionReOrder {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
//第一个线程a=1.x=b
//顺序执行的话会产生0,1 1,0 1,1 不会产生0,0
Thread one = new Thread(new Runnable() {
public void run() {
//由于线程one先启动,下面这句话让它等一等线程other.
shortWait(100000);
a = 1;
x = b;
}
});
//第二个线程先执行b=1,y=a
Thread other = new Thread(new Runnable() {
public void run() {
shortWait(100000);
b = 1;
y = a;
}
});
one.start();
other.start();
one.join();
other.join();
System.out.println("第 "+ i + "次,x="+x +", y="+y);
if(x == 0 && y == 0){
break;
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
只要出现0,0这种情况就说明一定发生可重排
第 9842711次,x=0, y=1
第 9842712次,x=0, y=1
第 9842713次,x=0, y=1
第 9842714次,x=0, y=1
第 9842715次,x=0, y=0
有序性
CPU内存屏障
使用cpu级别的内存屏障(不同于java的内存屏障),不同的cpu他的内存屏障指令是不同的。
-
sfence(存屏障):在sfence指令前的写操作当必须在sfence指令后的写操作前完成。
-
lfence(读屏障):在lfence指令前的读操作当必须在lfence指令后的读操作前完成。
-
mfence(读写屏障):在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。
原子指令
如x86上的"lock…"指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个cpu
software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序
JVM层级
- Store:将处理器缓存的数据刷新到内存中。
- Load:将内存存储的数据拷贝到处理器的缓存中。
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作 |
StoreStore Barriers | Store1;StoreStore;Store2 | 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作 |
LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令 |