为什么有了MESI还需要volatile关键字

4 篇文章 0 订阅
1 篇文章 0 订阅

MESI的概念此处不再累赘,有兴趣的可以搜索

store buffer

  1. 引入store buffer是为了将同步改为异步

  2. 引入store forwarding技术是为了让CPU可以直接从store buffer里加载数据

  3. 但是因此可能会发生乱序情况,譬如a在store buffer里,b在cache里,但是赋值操作虽然先设置了a,后设置了b,但是实际上却可能是b先被刷新到cache里,因为store buffer里的a在等待其他cpu返回invalid ack

  4. 引入写屏障技术,添加smp_wmb(),则执行的store操作,如果store buffer里有数据,那么更新value也全都放入到store buffer里,上述的b赋值之后也不直接刷新cache,而是放入到store buffer里,至此可以看出store buffer像是cache的缓存了。

     a. 写屏障技术是软件层面的,所以当你的变量没有使用volatile修饰的话,重排序还是会发生
     b. 写屏障指令可以通过smp_wmb()触发,但是默认是不触发的,所以默认没有写屏障;读屏障同理
     c. 为了加快invalid ack的速度,给每个CPU增加一个invalid Queue
    
  5. 因为引入了invalid Queue,可能会导致没有真的invalid某个cache line,这样导致读取的cache line里的值是invalid之前的,导致错误

  6. 引入读屏障技术,添加smp_rmb(),则执行后面的read操作,如果invalid Queue里还有没处理的ack,先处理invalid Queue

     smp_rmb(): 在invalid queue的数据被刷完之后再执行屏障后的读操作。
     smp_wmb(): 在store buffer的数据被刷完之后再执行屏障后的写操作。
     smp_mb(): 同时具有读屏障和写屏障功能。
    
  7. 因此也解释了,为什么有了MESI技术,我们还需要增加volatile关键字,因为不加的话,就没有屏障,也就没法使用store forwarding和处理 invalid Queue技术,还是可能发生重排序。

  8. 可以参考文章:https://blog.csdn.net/wll1228/article/details/107775825https://blog.csdn.net/wll1228/article/details/107775976

笔者自我总结

1.MESI的启用逻辑

使用volatile之前,MESI没有启用屏障功能,拥有store buffer、store forwarding、invalidate Queue;
store buffer:解决了同步等待其他CPU的invalid ack问题,带来了当前CPU中,该变量可能读取不一致问题;
store forwarding:解决了当前CPU中,同一个变量读取数据不一致的问题,但是没有解决不同变量之间可能乱序的问题
因此增加写屏障sfence,解决了同一个CPU中不同变量之间乱序问题;
invalidate Queue:解决了被其他CPU同步等待的问题,带来了当前CPU中,该变量的数据不一致问题
因此增加读屏障,解决了不同CPU中,同一个变量的数据不一致问题
全屏障mfence,拥有sfence和lfence的功能
但是jvm在volatile实现的时候:
https://github.com/openjdk-mirror/jdk7u-hotspot/blob/master/src/share/vm/interpreter/bytecodeInterpreter.cpp
1919行,
写操作,之后一定会调用该语句,

OrderAccess::storeload();

也就是咱们说的写读屏障,按理来说这就是咱们上面说的mfence,但是看看具体实现
https://github.com/openjdk-mirror/jdk7u-hotspot/blob/master/src/os_cpu/linux_x86/vm/orderAccess_linux_x86.inline.hpp

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
  }
}

也就是Jvm在实现volatile时,只是使用该变量的语义,具体实现并没有使用mfence,因为mfence的性能不佳,改为通过lock前缀的汇编指令能实现相同功能。

volatile读(只参考int类型变量,其他类型同理)

bytecodeInterpreter.cpp

if (cache->is_volatile()) {
   if (tos_type == atos) {
     //
   } else if (tos_type == itos) {
     SET_STACK_INT(obj->int_field_acquire(field_offset), -1);
   } else if (tos_type == ltos) {
     //
   }
 } else {
   if (tos_type == atos) {
     //
   } else if (tos_type == itos) {
     SET_STACK_INT(obj->int_field(field_offset), -1);
   } else if (tos_type == ltos) {
     //
   }
 }

 UPDATE_PC_AND_CONTINUE(3);
}

可以看到volatile调用的是obj->int_field_acquire(field_offset),而非volatile调用的是int_field(field_offset),查看oop.inline.hpp

inline jint oopDesc::int_field_acquire(int offset) const                    { return OrderAccess::load_acquire(int_field_addr(offset));      }

inline jint oopDesc::int_field(int offset) const                    { return *int_field_addr(offset);        }

inline jint*     oopDesc::int_field_addr(int offset)    const { return (jint*)    field_base(offset); }

inline void*     oopDesc::field_base(int offset)        const { return (void*)&((char*)this)[offset]; }

可以看到根据offset获取了内存地址,volatile:调用了OrderAccess::load_acquire(int_field_addr(offset));
非volatile是直接把地址里的值返回了
咱们查看下windows的x86是如何实现这个的

inline jint     OrderAccess::load_acquire(volatile jint*    p) { return *p; }

可以看到该方法入参是volatile类型,此时的volatile是C语言提供的,C语言的volatile语义是指所有的变量读写直接基于内存,也就是直接从内存地址里返回了最新变量值。

volatile写(只参考int类型变量)

if (cache->is_volatile()) {
  if (tos_type == itos) {
    obj->release_int_field_put(field_offset, STACK_INT(-1));
  } else if (tos_type == atos) {
  }
  OrderAccess::storeload();
} else {
	if (tos_type == itos) {
    obj->int_field_put(field_offset, STACK_INT(-1));
  } else if (tos_type == atos) {
    //
  }
}

再查看查看oop.inline.hpp

inline void oopDesc::release_int_field_put(int offset, jint contents)       { OrderAccess::release_store(int_field_addr(offset), contents);  }

inline void oopDesc::int_field_put(int offset, jint contents)       { *int_field_addr(offset) = contents;    }

inline jint*     oopDesc::int_field_addr(int offset)    const { return (jint*)    field_base(offset); }

inline void*     oopDesc::field_base(int offset)        const { return (void*)&((char*)this)[offset]; }

和读类似, 非volatile变量直接把地址的指针指向了设置的值,可以理解为该指针指向地址缓存的变量的值被更改。

inline void     OrderAccess::release_store(volatile jint*    p, jint    v) { *p = v; }

volatile变量,C语言的volatile关键字修饰,代表直接更新的是内存里存储的值,而不是该地址缓存在寄存器或者cache里的值,需要传进地址的最新指针,让指针指向设置的值。
写比读还多了一行,必须执行的代码:OrderAccess::storeload();

inline void OrderAccess::storeload()  { fence(); }

inline void OrderAccess::fence() {
#ifdef AMD64 //如果是64位的x86架构
  StubRoutines_fence();
#else
  // 如果是多核的
  if (os::is_MP()) {
    __asm {
      lock add dword ptr [esp], 0;
    }
  }
#endif // AMD64
}

可以看到其实对于volatile的变量来说,
读比普通变量的读多了一层封装,OrderAccess::load_acquire(volatile jint* p),入参是volatile类型,此时的volatile就是C++里面的volatile了,C++里的volatile其实含义相同,也是为了保证获取该变量的最新值(不再使用寄存器或者cache里存的该地址对应的数据,但是C++里的volatile并不保证顺序性,应该就是MESI的基本功能),因为此时入参是个地址,就是要获取地址里的最新值(当前内存里的值);
对于写也一样,让该地址指向的最新值指向我们设置的变量,然后多了一步fence()调用,而该汇编指令其实执行的操作也是无意义的操作,把内存地址ds:[esp]中的元素+0,主要是执行了lock指定。根据其他博客所说该汇编指令的作用是使得其他CPU中该缓存行的数据失效,其实也就是我们在上面讲的mfence的作用,也就是读写屏障的功能:使得在当前CPU中该缓存行之后的写操作都排到了storeBuffer里,在其他CPU中该缓存行的读操作,都必须先进行invalidate Queue里的处理再进行。
突然也就理解了为什么jvm里的volatile写不是线程安全的了,因为写操作被分割成了 更新缓存行+lock指令,两个操作,不是原子性的操作。所以多线程并发的时候,是非线程安全的。

Java的volatile读 = C语言的volatile读
Java的volatile写 = C语言的volatile写 + 设置mfence

内存模型

最近一段时间又看了内存模型相关文章,内存模型一般有TSO,PSO,RSO。

TSO:只存在store-load乱序情况
PSO:存在store-load、store-store乱序
RMO:四种乱序情况都可能产生

对应着下面四种强弱内存模型:

1. Weak Memory Model: 如DEC Alpha是弱内存模型,它可能经历所有的四种内存乱序(LoadLoad, LoadStore, StoreLoad, StoreStore),任何Load和Store操作都能与任何其它的Load或Store操作乱序,只要其不改变单线程的行为。
2. Weak With Date Dependency Ordering: 如ARM, PowerPC, Itanium,在Aplpha的基础上,支持数据依赖排序,如C/C++中的A->B,它能保证加载B时,必定已经加载最新的A
3. Strong Memory Model: 如X86/64,强内存模型能够保证每条指令acquire and release语义,换句话说,它使用了LoadLoad/LoadStore/StoreStore三种内存屏障,即避免了四种乱序中的三种,仍然保留StoreLoad的重排,对于代码片段7来说,它仍然可能出现r1=r2=42的情况
4. Sequential Consistency: 最强的一致性,理想中的模型,在这种内存模型中,没有乱序的存在。如今很难找到一个硬件体系结构支持顺序一致性,因为它会严重限制硬件对CPU执行效率的优化(对寄存器/Cache/流水线的使用)

可以看到RMO、PSO、TSO分别对应上面的1,2,3;intel的x86架构其实是TSO,也就是只会发生store-load乱序,所以再想想我们看的文件名orderAccess_linux_x86.inline.hpp

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

所以我们看的其实是x86架构(TSO内存模型)下volatile的实现,此时只需要保证store-load不乱序即可,所以我们的写操作后面调用了fence()方法,相当于加了storeload屏障,不让之后的读load乱序到此时的写store之前。

利用jit的方式看看汇编代码

不会的同学可以参考我的这篇博客mac下配置PrintAssembly

  1. 变量没有使用volatile进行修饰
public class TestVolatile {

    public static int value;
    public static void main(String[] args) {
        int a = 10;
        value = 9;
        value += a;
    }

}

对应的汇编指令(AT&T标准的汇编指令)如下,我们只择取重要的部分

0x000000011482ead5: je     0x000000011482eb01  ;*bipush
                                                ; - com.wang.demo.test.TestVolatile::main@0 (line 11)

  0x000000011482eadb: movabs $0x76ab7aed0,%rsi  ;   {oop(a 'java/lang/Class' = 'com/wang/demo/test/TestVolatile')}
  // value = 9,可以看到此时value被保存在0x68(%rsi)中
  0x000000011482eae5: movl   $0x9,0x68(%rsi)    ;*putstatic value
                                                ; - com.wang.demo.test.TestVolatile::main@5 (line 12)

// 下面这3行,执行的就是value += a;可以看到整个写操作被拆分为3步,RMW
  0x000000011482eaec: mov    0x68(%rsi),%edi    ;*getstatic value
                                                ; - com.wang.demo.test.TestVolatile::main@8 (line 13)

  0x000000011482eaef: add    $0xa,%edi
  0x000000011482eaf2: mov    %edi,0x68(%rsi)    ;*putstatic value
                                                ; - com.wang.demo.test.TestVolatile::main@13 (line 13)
  1. 变量利用volatile修饰
public class TestVolatile1 {

    public static volatile int value;
    public static void main(String[] args) {
        int a = 10;
        value = 9;
        value += a;
    }

}

对应的汇编(intelx86_64)指令如下:

0x000000011bfbcb15: je     0x000000011bfbcb4c  ;*bipush
                                                ; - com.wang.demo.test.TestVolatile1::main@0 (line 11)

  0x000000011bfbcb1b: movabs $0x76ab7aee8,%rsi  ;   {oop(a 'java/lang/Class' = 'com/wang/demo/test/TestVolatile1')}
  0x000000011bfbcb25: mov    $0x9,%edi
  0x000000011bfbcb2a: mov    %edi,0x68(%rsi)
  0x000000011bfbcb2d: lock addl $0x0,(%rsp)     ;*putstatic value
                                                ; - com.wang.demo.test.TestVolatile1::main@5 (line 12)

  0x000000011bfbcb32: mov    0x68(%rsi),%edi    ;*getstatic value
                                                ; - com.wang.demo.test.TestVolatile1::main@8 (line 13)

  0x000000011bfbcb35: add    $0xa,%edi
  0x000000011bfbcb38: mov    %edi,0x68(%rsi)
  // 多出来了这一行
  0x000000011bfbcb3b: lock addl $0x0,(%rsp)     ;*putstatic value
                                                ; - com.wang.demo.test.TestVolatile1::main@13 (line 13)

看到汇编指令,我们就明白了,正好多出来了 lock addl $0x0,(%rsp),而这句指令根据上文描写,正是intelx86架构下fence()方法的执行,也就是对应storeLoad内存屏障。正好也说明了,实际上volatile的内存屏障是一种抽象的概念,在不同的芯片架构下执行是不同的,intelx86的架构只会产生storeLoad的乱序,所以最后只需要进行这一行的添加。

lock# 指令的作用

根据Hotspot源码上的注释来说,lock addl $0x0,(%rsp) 能达到mfence的语义功能;但是汇编语言,其实这种lock前缀指令的最大作用是为了原子性执行指令,例如我们熟悉的CAS,在底层是通过 lock# cmpxchg达到的,所以cmpxchg操作本身并不是原子操作,是因为lock前缀达到的效果。
其他博客找到一个说法

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

下面一段摘自博客
LOCK instruction
LOCK 指令可以用于 x86 架构下的 CPU barrier

附录:Intel软件开发手册下载地址

LOCK 指令最初用于实现 atomic RMW (Read-Modify-Write) 操作,但是 LOCK 指令执行的时候也会 flush store buffer 操作

Locking operations typically operate like I/O operations in that they wait for all previous instructions to complete and for all buffered writes to drain to memory.

Intel Architecture Software Developer Manual, volume 3A, chapter 8, section 8.2 "Memory Ordering"

因而 load/store 操作都不能与 LOCK 指令发生重排,也就是说 LOCK 指令相当于一个 full barrier,因而可以用于消除 StoreLoad reorder

Reads or writes cannot be reordered with I/O instructions, locked instructions, or serializing instructions.

Intel Architecture Software Developer Manual, volume 3A, chapter 8, section 8.2 "Memory Ordering"

也就是说咱们的fence()其实相当于阻止了store-store和store-load乱序

这篇博客也可以用来借鉴

那MESI是线程安全的么?

答案是否!!
因为我们想要的数据写操作的线程安全其实是内存一致性问题,也就是针对同一个内存地址的操作的线程安全,最终达到一致性;而MESI解决的是cache的一致性问题。再加上我们一般意义上的写操作是RMW(read-modify-write)或者MW(modify-write),写操作是非原子性操作,可能会被中断,而写操作,一般是通过运算器操作寄存器的,也就是把数据从cache加载进寄存器再操作,而不是直接基于cache进行操作的。

普通的MESI:Unlocked increment:

Acquire cache line, shareable is fine. Read the value.
Add one to the read value.
Acquire cache line exclusive (if not already E or M) and lock it.
Write the new value to the cache line.
Change the cache line to modified and unlock it.
===========================================================
增加了汇编指令lock前缀的MESI:Locked increment:

Acquire cache line exclusive (if not already E or M) and lock it.
Read value.
Add one to it.
Write the new value to the cache line.
Change the cache line to modified and unlock it.

通过上面更新的汇编指令,其实应该已经有了答案了,像value += a这种指令,在汇编级别是要分3步执行的,并发情况下,当然无法做到线程安全

最新理解

为什么MESI会线程不安全?
譬如有线程T1和T2都要进行一次i++操作,且T1和T2在不同的CPU上操作

i++

对于变量的写操作其实是应该分为三个步骤:load、modify、write

t1: T1: load-Shared  T2: load-Shared
t2: T1: Exclusive    T2: Invalid
t3: T1: Modified     T2: Invalid
t4: T1: Exclusive    T2: Invalid
t5: T1: Shared       T2: Shared

可以看到如果T1和T2想同时进行i++操作,T1把load、modify、write都执行了,但是T2只执行了load操作,后续因为无法执行被忽略了,如果T2不忽略其Invalid的缓存行,还是继续进行操作,那么最终也会用错误的值覆盖掉主内存的值。但是如果加了锁,就不可能发生T1和T2同时执行load操作,因为load、modify、write三个操作被打包成了一个原子操作,load、modify、write操作作为临界区,只能被获得锁的变量进行原子处理。

参考博客&地址:

https://www.cnblogs.com/rsapaper/p/8004231.html
https://zhuanlan.zhihu.com/p/115355303
https://wudaijun.com/2019/04/cpu-cache-and-memory-model/

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值