volatile 靠的是MESI协议解决可见性问题?(下)

  • Post author:
  • Post category:其他

volatile 靠的是MESI协议解决可见性问题?(上)

处理器解决MESI协议带来写请求阻塞问题的方案:

(1)引入store buffer

将同步等待变成异步的,单独为写操作划分一个store buffer,这样读的数据完全由cache 获取。而store buffer只负责写数据。 CPU可以先将要写入的数据写到Store Buffer,然后继续做其它事情。等到收到其它CPU发过来的Cache Line(Read Response),再将数据从Store Buffer移到Cache Line。结构如下所示:

在这里插入图片描述
然后加了Store Buffer之后,会引入另一个问题:cache 的数据是不准的,因为store buffer数据还没同步到cache,store buffer 只负责写,取数从cache 里面取出来

a = 2;
b = a + 1 ;

初始状态下,假设a,b值都为0,并且a存在CPU1的Cache Line中(Shared状态),可能出现如下操作序列:

  • CPU0 要写入A,发出Read Invalidate消息,并将a=1写入Store Buffer
  • CPU1 收到Read Invalid,返回Read Response(包含a=0的Cache Line)和Invalid Ack
  • CPU0 收到Read Response,更新Cache Line(a=0)
  • CPU0 开始执行 b = a + 1,从Cache Line中加载a,得到a=0 ,然后此时 b的值是1,跟我们预测的 b =2+1 是违背。
  • CPU0 收到所有的invalid ack,将Store Buffer中的a=1应用到Cache Line

造成原因: 同一个CPU存在对a的两份拷贝,一份在Cache,一份在Store Buffer,前者用于读,后者用于写,因而出现单线程情况下CPU执行顺序与程序顺序(Program Order)不一致(看起来是先执行了b=a+1,再执行a=1)。

科普一下:

as-if-serial语义:不管怎么重排(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变,不会对存在数据依赖关系的操作进行重排序,编译器,runtime和处理器必须遵守as-if-serial 语义。

  • 真·重排序:编译器,底层硬件cpu等(指令级别),出于“优化”的目的,按照某种规则将指令重新排序。
  • 假·重排序:由于store buffer 缓存同步cache
    的顺序问题,看起来指令被重排序了。但是在语义上面是不允许被重排序的,因为存在关联关系。

Store Forwarding 技术:

CPU可以直接从Store Buffer中加载数据,即支持将CPU存入Store Buffer的数据传递(forwarding)给后续的加载操作,而不经由Cache。(为了解决同一cpu里面cache 和store buffer 数值不一致)
在这里插入图片描述

(2)Invalid Queue:将同步响应Invalid ack 变成异步

Invalid Ack耗时的主要原因是CPU要先将对应的Cache Line置为Invalid后再返回Invalid Ack,一个很忙的CPU可能会导致其它CPU都在等它回Invalid Ack。

解决思路还是化同步为异步 : CPU不必要处理了Cache Line之后才回Invalid Ack,而是可以先将Invalid消息放到某个请求队列Invalid Queue,然后就返回Invalid Ack。CPU可以后续再处理Invalid Queue中的消息,大幅度降低Invalid Ack响应时间。

此时的CPU Cache结构图如下:
在这里插入图片描述

  • 对于所有的收到的 Invalidate 请求,Invalidate Acknowlege 消息必须立刻发送 Invalidate
  • 并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。

加了lnvalid queue之后,会引入另一个问题:cache 的数据是不准的,因为lnvalid queue数据还没同步到cache,从cache 读出来还是不准的数据。

在这里插入图片描述

回到我们一开始的问题,在改良后的cpu 缓存模型下,为什么thread还看不到flag 变量的修改?

是因为有延迟,store buffer 异步等待 其他cpu 的valid ack,才能将cache变成exclusive 并且修改变成modify。

  • main 线程发出invalid 信号,等待 thread 信号响应 invalid ack 信号,thread 收到invalid
    信号,把cache设置为Invalid。 然后返回Invalid ack 信号
    在这里插入图片描述
  • 但其实我们main程序还没等到invalid ack 就结束了,根本就没有修改到memory 的值,thread 因为cache Invalid 只能从memory 获取到旧的值
    在这里插入图片描述
public class NoVolatile {
    private boolean flag = true;

    public void test() {
        System.out.println("start");
        while (flag) {

        }
        System.out.println("end");
    }

    public static void main(String[] args) {
        NoVolatile noVolatile = new NoVolatile();
        new Thread(noVolatile::test).start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {

        }
        noVolatile.flag = false;
    }
}

//执行结果:
start 

那么怎样才能看到thread 线程在不通过validate的情况下能获取到值呢? 将 main 放久一些,就能得到这个效果。

public class NoVolatile {
    private boolean flag = true;

    public void test() {
        System.out.println("start");
        System.out.println(flag);
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {

        }
        System.out.println(flag);
        System.out.println("end");
    }

    public static void main(String[] args) {
        NoVolatile noVolatile = new NoVolatile();
        new Thread(noVolatile::test).start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {

        }
        noVolatile.flag = false;
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {

        }
    }
}

在这里插入图片描述

为了解决处理器等待(写请求阻塞)问题,引入了写缓冲区和无效队列,又导致了重排序和可见性问题

存在问题:

a、可见性:

  • 比如处理器1写入到自己的写缓冲器中,处理器2通过总线去read数据读取到的还是旧数据;
  • 比如处理器1接受到了 a的invalid 消息,将消息写入了Invalid queue ,但是没有处理。会导致处理器1读取a的消息,会读到本该无效的值

b、有序性:

  • store load重排:处理器1的写操作写入到了写缓存区对处理器2不可见,处理器2读了一份数据则觉得load在前store在后;
  • store store重排:处理器1的第一个写操作发现数据是s状态,写入到写缓冲区;第二个写操作为m状态直接修改,即两个写操作顺序反过来;

本质问题:cp1 store buffer 到 cp2 cache 存在比较大的延迟问题,Invalid queue 不及时同步数据存在延迟问题,跨线程依赖的情况下无法容忍延迟问题。

解决方案:对于上面的内存不一致,很难从硬件层面优化,因为CPU不可能知道哪些值是相关联的,因此硬件工程师提供了一个叫内存屏障的东西。设置屏障方法是禁止重排续,变成同步效果,这样的就解决了,可见性和重排续问题。

写屏障:

写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。

cpu提供了写屏障(write memory barrier)指令,Linux操作系统将写屏障指令封装成了smp_wmb()函数,cpu执行smp_mb()的思路是,会先把当前store buffer中的数据刷到cache之后,再执行屏障后的“写入操作”,该思路有两种实现方式: 一是简单地刷store buffer,但如果此时远程cache line没有返回,则需要等待,二是将当前store buffer中的条目打标,然后将屏障后的“写入操作”也写到store buffer中,cpu继续干其他的事,当被打标的条目全部刷到cache line,之后再刷后面的条目。

读屏障:

读屏障 Load Memory Barrier (a.k.a. LD, RMB, smp_rmb) 是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列 Invalid queue 中的失效操作的指令。

可以通过读屏障让CPU标记当前Invalid Queue中所有的条目,所有的后续加载操作必须先等Invalid Queue中标记的条目处理完成再执行

因为cpu可能只关注,写情况或者读情况。大多数CPU架构将内存屏障分为了读屏障(Read Memory Barrier)和写屏障(WriteMemory Barrier)

  • 读屏障 lfence : 任何读屏障前的读操作都会先于读屏障后的读操作完成
  • 写屏障 sfence : 任何写屏障前的写操作都会先于写屏障后的写操作完成
  • 全屏障 mfence : 同时包含读屏障和写屏障的作用

内存屏障能够解决重排续和可见性问题。那么volatile是怎么解决的?在哪里实现内存屏障的?

volatile static int a = 0;
//javap 反编译后得到 ACC_VOLATILE 关键字
 static volatile int a;
    descriptor: I
    flags: ACC_STATIC, ACC_VOLATILE

在accessFlags.hpp 文件中

 //ACC_VOLATILE 关键字
 bool is_volatile () const { return (_flags & JVM_ACC_VOLATILE    ) != 0; }

在bytecodeInterpreter.cpp 文件中

  • Volatile 写操作的源码
		if (cache->is_volatile()) {
            if (tos_type == itos) {
              obj->release_int_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == atos) {
              VERIFY_OOP(STACK_OBJECT(-1));
              obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
              OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
            } else if (tos_type == btos) {
              obj->release_byte_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ltos) {
              obj->release_long_field_put(field_offset, STACK_LONG(-1));
            } else if (tos_type == ctos) {
              obj->release_char_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == stos) {
              obj->release_short_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ftos) {
              obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
            } else {
              obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
            }
            OrderAccess::storeload();
          }

两个关键动作:

  1. 调用release_int_field_put 函数,执行赋值动作
  2. 执行OrderAccess::storeload()
inline void oopDesc::release_int_field_put(int offset, jint contents)  { 		OrderAccess::release_store(int_field_addr(offset), contents);
}

inline void OrderAccess::release_store(volatile jint* p, jint v) { 
	*p = v; 
}
  • Volatile 读操作
		if (cache->is_volatile()) {
            if (tos_type == atos) {
              VERIFY_OOP(obj->obj_field_acquire(field_offset));
              SET_STACK_OBJECT(obj->obj_field_acquire(field_offset), -1);
            } else if (tos_type == itos) {
              SET_STACK_INT(obj->int_field_acquire(field_offset), -1);
            } else if (tos_type == ltos) {
              SET_STACK_LONG(obj->long_field_acquire(field_offset), 0);
              MORE_STACK(1);
            } else if (tos_type == btos) {
              SET_STACK_INT(obj->byte_field_acquire(field_offset), -1);
            } else if (tos_type == ctos) {
              SET_STACK_INT(obj->char_field_acquire(field_offset), -1);
            } else if (tos_type == stos) {
              SET_STACK_INT(obj->short_field_acquire(field_offset), -1);
            } else if (tos_type == ftos) {
              SET_STACK_FLOAT(obj->float_field_acquire(field_offset), -1);
            } else {
              SET_STACK_DOUBLE(obj->double_field_acquire(field_offset), 0);
              MORE_STACK(1);
            }
          }

关键动作:

JVM定义的内存屏障有这些:

在这里插入图片描述

inline void OrderAccess::loadload()   { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore()  { acquire(); }
inline void OrderAccess::storeload()  { fence(); }

inline void OrderAccess::acquire() {
  volatile intptr_t local_dummy;
#ifdef AMD64
  __asm__ volatile ("movq 0(%%rsp), %0" : "=r" (local_dummy) : : "memory");
#else
  __asm__ volatile ("movl 0(%%esp),%0" : "=r" (local_dummy) : : "memory");
#endif // AMD64
}

inline void OrderAccess::release() {
  // Avoid hitting the same cache-line from
  // different threads.
  volatile jint local_dummy = 0;
}

inline void OrderAccess::fence() {
  //判断是不是多核
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

GCC内嵌汇编语言

  __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
  __asm__ (汇编语句模板: 输出部分: 输入部分: 破坏描述部分)   

破坏描述符: 用于通知编译器我们使用了哪些寄存器或内存,由逗号格开的字符串组成,每个字符串描述一种情况,一般是寄存器名;,此外还有内存“memory”和“cc”。我们称形如:“%eax”、“%ebx”、“%ecx”等为寄存器破坏描述符;称“memory”为内存描述符,表示修改了memory;“cc” 表示汇编程序代码修改了标志寄存器’

memory: 破坏描述符,告诉GCC 内存已经被修改,GCC会保证在此内联汇编之前,如果某个内存的内容被装入了寄存器,那么在这个内联汇编之后,如果需要使用这个内存处的内容;就会在这段指令之前,插入必要的指令将寄存器中的变量值先写回主存,指令之后读取时也会直接到这个内存处重新读取,而不是使用被存放在寄存器中的拷贝。(CPP 转化成汇编语言还会再优化的)

volatile 是C++的一个关键字: 遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,而可以提供对特殊地址的稳定访问。

声明时语法:int volatile vInt; 
当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存

在x86处理器当中,会忽略掉前三种,只关心storeload(读写屏障)
在这里插入图片描述

对于storeload(读写屏障),走到的是 mfence方法,os::is_MP()用于判断是否是多核架构,#ifdef AMD64用于判断是否是64位处理器,然后会添加一句汇编码:lock; addl $0,0(%%rsp),这就是使用了lock来达到内存屏障的效果

那为什么Lock 关键字能够达到内存屏障的效果?

Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令

  • 总线锁

LOCK#信号就是我们经常说到的总线锁,处理器使用LOCK#信号达到锁定总线,来解决原子性问题,当一个处理器往总线上输出LOCK#信号时,其它处理器的请求将被阻塞,此时该处理器此时独占共享内存

总线锁这种做法锁定的范围太大了,导致CPU利用率急剧下降,因为使用LOCK#是把CPU和内存之间的通信锁住了,这使得锁定时期间,其它处理器不能操作其内存地址的数据 ,所以总线锁的开销比较大

  • 缓存锁(锁缓存行,其他CPU不能缓存改行)

如果访问的内存区域已经缓存在处理器的缓存行中,P6系统和之后系列的处理器则不会声明LOCK#信号,它会对CPU的缓存中的缓存行进行锁定,在锁定期间,其它CPU 不能同时缓存此数据,在修改之后,通过缓存一致性协议来保证修改的原子性,这个操作被称为“缓存锁”

  • 什么情况下使用总线锁(LOCK#)

当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,也会使用总线锁
因为从P6系列处理器开始才有缓存锁,所以对于早些处理器是不支持缓存锁定的,也会使用总线锁

  • LOCK#作用总结
  1. 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,因为锁总线的开销比较大,后来的处理器都采用锁缓存替代锁总线,在无法使用缓存锁的时候会降级使用总线锁
  2. lock期间的写操作会回写已修改的数据到主内存,同时通过缓存一致性协议让其它CPU相关缓存行失效
  3. 总线锁、缓存锁可以保证原子性,缓存一致性协议可以保证可见性

X86版本的Storeload 是用于volatile的写操作,那如何做到volatile写完之后的值,被观察到?

		if (cache->is_volatile()) {
            if (tos_type == itos) {
              obj->release_int_field_put(field_offset, STACK_INT(-1));
            } 
            ......
            OrderAccess::storeload();
          }
	
		inline void OrderAccess::storeload()  { fence(); }

		inline void OrderAccess::fence() {
 		 //判断是不是多核
	    if (os::is_MP()) {
		 // always use locked addl since mfence is sometimes expensive
		#ifdef AMD64
		    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
		#else
		    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
		#endif
		  }
		}

addl $0,0(%%esp): 表示将数值0加到esp寄存器中,而该寄存器指向栈顶的内存单元。加上一个0,esp寄存器的数值依然不变。本身没有太大的意义。

memory: 破坏描述符,告诉GCC 内存已经被修改,GCC会保证在此内联汇编之前,如果某个内存的内容被装入了寄存器,那么在这个内联汇编之后,如果需要使用这个内存处的内容;就会在这段指令之前,插入必要的指令将寄存器中的变量值先写回主存,指令之后读取时也会直接到这个内存处重新读取,而不是使用被存放在寄存器中的拷贝。(会强制刷回内存,和读内存的值)

在这里插入图片描述
保证可见性步骤如下:

  1. release_int_field_put 修改cache,使得cache变成modified状态
  2. lock# 关键字 锁住总线
  3. memory 关键字使得被修改的Cache得刷回memory,刚好占领了总线,所以是先刷回去的
  4. memory 关键字使得其他cpu 的Cache 无效 重新从memory 获取,但是总线被锁住了,只能等存进去,才能获取

volatile 保证原子性吗?

public class AtomicVolatile {
    public static volatile int a = 0;

    public static void main(String[] args) throws Exception {

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                a++;
            }
            System.out.println("t1 执行完了");
        });


        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                a++;
            }
            System.out.println("t2 执行完了");
        });

        t1.start();t2.start();
        t1.join(); t2.join();;

        System.out.println("a的值:" + a);
    }
}

//执行结果
t2 执行完了
t1 执行完了
a的值:13585

为什么volatile 修饰的变量没有办法保证原子性?最后相加的结果不是20000

原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

JVM 8种原子性操作:

  • read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中
  • load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
  • use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎
  • assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量
  • store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中
  • write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
  • unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定
  • lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;

I++ 其实 是三个动作

  1. 获取I的值
  2. 将I的值进行+1 得到x
  3. 将x赋值于I

因为I++ 这个语句可以被拆成3个动作来执行,I++是非原子性动作,无法保证一起完成。所以存在下面这种情况发生
在这里插入图片描述

使用synchronized关键字来保证原子性

public class AtomicVolatile {
    public static volatile Integer a = 0;
    public static Integer b = 0;

    public static void main(String[] args) throws Exception {

        Thread t1 = new Thread(() -> {
        	//不要拿a来做锁对象,Integer 大于127就变了新的对象了,踩坑,切记切记
            synchronized (b) {
                for (int i = 0; i < 10000; i++) {
                    a++;
                }
            }
            System.out.println("t1 执行完了a:" + a);
        });


        Thread t2 = new Thread(() -> {
            synchronized (b) {
                for (int i = 0; i < 10000; i++) {
                    a++;
                }
            }
            System.out.println("t2 执行完了a:" + a);
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("a的值:" + a);
    }
}

//执行结果
t1 执行完了a:20000
t2 执行完了a:20000
a的值:20000

在这里插入图片描述

总结:

volatile 可以保证可见性和顺序性(通过内存屏障的方式)
volatile 不保证原子性

课外拓展

为什么 多线程创建 new 实例的时候需要加上Volatile关键字?

public class SingletonFactory {

    private volatile static SingletonFactory myInstance;
    public static SingletonFactory getMyInstance() {
        if (myInstance == null) {
            synchronized (SingletonFactory.class) {
                if (myInstance == null) {
                    myInstance = new SingletonFactory();
                }
            }
        }
        return myInstance;
    }

    public static void main(String[] args) {
        SingletonFactory.getMyInstance();
    }
}

synchronized已经保证线程可见性了,为什么还需要volatile来修饰SingletonFactory myInstance呢?

myInstance = new SingletonFactory(); 反编译得到 初始化一个对象分为三步

  1. new 分配一片内存空间(在类加载时就已经确定需要分配多少空间了)
  2. invokespecial 对象的初始化 (构造方法)
  3. astore_2 myInstance引用指向对象的内存地址

在单线程情况下,不一定是按照123顺序执行的,可能是132,因为有CPU的指令重排序

在多线程情况下,线程1执行时,如果发生了指令重排序,1,3执行完后,myInstance指向的对象非空,此时线程2执行时,正好执行到 if (myInstance == null),那么就不会为 当前this对象初始化变量SingletonFactory myInstance了,不符合程序运行的预期

加上volatile后,禁止指令重排序,不会有以上问题


版权声明:本文为Prior_SX原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。