内存屏障和 volatile 语义(三)
上面的例子,看起来就好像第一个一样,仍然是b=1 先生效,a=1 后生效。导致了cpu1执行的错误。就好像内存操作”重排序”一样(个人不太喜欢内存操作重排序这个术语,因为实际上并不是重新排序的问题,而是是否可见的问题。但是用重排序这样的词语,反而不好理解。但是很多书都是用是了这个词语,大家可以有自己的理解。但是还是推荐不要理会这些作者的抽象概念,直接了解核心)。其实这个问题的触发,就是因为invalidate queue没有在需要被处理的时候处理完成,造成了原本早该失效的cacheline仍然被cpu认为是有效,出现了错误的结果。那么只要让内存屏障增加一个让invalidate queue全部处理完成的功能即可。
硬件的设计者也是这么考虑的,请看下面的代码
public void set(){
a=1;
smp_mb();
b=1;
}
public void print(){
while(b==0)
;
smp_mb();
assert a==1;
}
a同时存在于cpu0和cpu1之中,状态为s。b是cpu0独享,状态为E或者M。
序号 |
cpu0的步骤(执行set) |
cpu1的步骤(执行print) |
1 |
想写入a=1,但是由于a的状态是s,向cpu1发送invalidate消息 |
执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息 |
2 |
向store buffer中写入a=1 |
收到cpu0的invalidate消息,放入invalidate queue,响应invalidate ack消息。 |
3 |
遇到smp_mb(),等待直到可以将store buffer中的内容刷新到cacheline。立刻收到cpu0的invalidate ack,将store buffer中的a=1写入到cacheline,并且修改状态为M |
等待cpu0响应的read response消息 |
4 |
由于b就在自己的cacheline中,写入b=1,修改状态为M |
等待cpu0响应的read response消息 |
5 |
收到cpu1响应的read请求,将b=1作为响应回传,同时将cacheline的状态修改为s。 |
等待cpu0响应的read response消息 |
6 |
无 |
收到read response,将b=1写入cacheline,程序跳出循环 |
7 |
无 |
遇见smp_mb(),让cpu将invalidate queue中的消息全部处理完后,才能继续向下执行。此时将a所在的cacheline设置为invalidate |
8 |
无 |
由于a所在的cacheline已经无效,向cpu0发送read消息 |
9 |
收到read请求,以a=1发送响应 |
收到cpu0发送的响应,以a=1写入cacheline,执行assert a==1.判断成功 |
可以看到,由于内存屏障的加入,程序正确了。
内存屏障
通过上面的解释和例子,可以看出,内存屏障是是因为有了store buffer和invalidate queue之后,被用来解决可见性问题(也就是在cacheline上的操作重排序问题)。内存屏障具备两方面的作用
- 强制cpu将store buffer中的内容写入到cacheline中
- 强制cpu将invalidate queue中的请求处理完毕
但是有些时候,我们只需要其中一个功能即可,所以硬件设计者们就将功能细化,分别是
- 读屏障: 强制cpu将invalidate queue中的请求处理完毕。也被称之为
smp_rmb
- 写屏障: 强制cpu将store buffer中的内容写入到cacheline中或者将该指令之后的写操作写入store buffer直到之前的内容被写入cacheline.也被称之为
smp_wmb
- 读写屏障: 强制刷新store buffer中的内容到cacheline,强制cpu处理完invalidate queue中的内容。也被称之为
smp_mb
JMM内存模型
在上面描述中可以看到硬件为我们提供了很多的额外指令来保证程序的正确性。但是也带来了复杂性。JMM为了方便我们理解和使用,提供了一些抽象概念的内存屏障。注意,下文开始讨论的内存屏障都是指的是JMM的抽象内存屏障,它并不代表实际的cpu操作指令,而是代表一种效果。
- LoadLoad Barriers
该屏障保证了在屏障前的读取操作效果先于屏障后的读取操作效果发生。在各个不同平台上会插入的编译指令不相同,可能的一种做法是插入也被称之为smp_rmb 指令,强制处理完成当前的invalidate queue中的内容
- StoreStore Barriers
该屏障保证了在屏障前的写操作效果先于屏障后的写操作效果发生。可能的做法是使用smp_wmb 指令,而且是使用该指令中,将后续写入数据先写入到store buffer的那种处理方式。因为这种方式消耗比较小
- LoadStore Barriers
该屏障保证了屏障前的读操作效果先于屏障后的写操作效果发生。
- StoreLoad Barriers
该屏障保证了屏障前的写操作效果先于屏障后的读操作效果发生。该屏障兼具上面三者的功能,是开销最大的一种屏障。可能的做法就是插入一个smp_mb 指令来完成。
内存屏障在volatile关键中的使用
内存屏障在很多地方使用,这里主要说下对于volatile关键字,内存屏障的使用方式。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
上面的内存屏障方式主要是规定了在处理器级别的一些重排序要求。而JMM本身,对于volatile变量在编译器级别的重排序也制定了相关的规则。可以用下面的图来表示 volatile变量除了在编译器重排序方面的语义以外,还存在一条约束保证。如果cpu硬件上存在类似invalidate queue的东西,可以在进行变量读取操作之前,会先处理完毕queue上的内容。这样就能保证volatile变量始终是读取最新的最后写入的值。
Happen-before
JMM为了简化对编程复杂的理解,使用了HB来表达不同操作之间的可见性。HB关系在不同的书籍中有不同的表达。这里推荐一种比较好理解的。
A Happen before B,说明A操作的效果先于B操作的效果发生。这种偏序关系在单线程中是没有什么作用的,因为单线程中,执行效果要求和代码顺序一致。但是在多线程中,其可见性作用就非常明显了。举个例子,在线程1中进行进行a,b操作,操作存在hb关系。那么当线程2观察到b操作的效果时,必然也能观察到a操作的效果,因为a操作Happen before b操作。
在java中,存在HB关系的操作一共有8种,如下。
- 程序次序法则,如果A一定在B之前发生,则happen before
- 监视器法则,对一个监视器的解锁一定发生在后续对同一监视器加锁之前
- Volatie变量法则:写volatile变量一定发生在后
|