MESI缓存一致性协议 伪共享 合并写 有序性 内存屏障

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在读取,状态都变成SS
remote write先把cache中的数据写入到内存中,其他cpu的cache再读取并修改后,本地cache状态变成I,修改的那个cache状态变成MI
E(exclusive)local read状态不变E
local write状态变成MM
remote read数据和其他核共享,状态变成SS
remote write数据被修改,状态变成II
S(shared)local read状态不变S
local write其他CPU的cache状态变成I,本地cache状态变成MM
remote read状态不变 不影响S
remote write本地cach状态变成I,修改内容的CPU的cache状态变成MI
I(invalid)local read1.如果其他处理器中没有这份数据,本缓存从内存中取该数据,状态变成E
2.如果其他处理器中有这份数据,且缓存行状态为M,则将数据更新到内存,本地cache再从内存中读取数据,两个cache的缓存行状态都变成S
3.如果其他处理器中有这份数据,且缓存行状态为S或者E,本地cache从内存中读取数据。这些cache的缓存行状态都变成S
E/S
local write1.先从内存中读取数据,如果其他缓存中有这份数据,且状态为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 BarriersLoad1;LoadLoad;Load2该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore BarriersStore1;StoreStore;Store2该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad BarriersStore1;StoreLoad;Load2该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值