背景
在阅读java中volatile的关键词语义时,发现很多书中都使用了重排序这个词来描述,同时又讲到了线程工作内存和主存等等相关知识。但是只用那些书的抽象定义进行理解时总是感觉什么地方说不通,最后发现,是那些书中使用的抽象屏蔽了一些对读者的知识点,反而导致了理解上的困难。因此有了这篇文章。没有任何虚构的理解抽象,从硬件的角度来理解什么是内存屏障,以及内存屏障如何让volatile工作。最后说明了在多线程中,如何使用volatile来提升性能。
存储结构
在计算机之中,存在着多级的存储结构。这是为了适应不同硬件速度带来的差异。底层是主存(也就是内存),容量最大,速度最慢;中间是cpu的缓存(现代cpu都有多级缓存结构,级别越高速度越慢,但是可以将多级缓存看成是一个整体),容量较小,但是速度很快;最上层是cpu自身的store buffer和Invalidate Queues,速度最快,容量非常少。其中主存和cpu缓存的数据视图对于每一个cpu都是相同的,也就是说在这个级别上,每个cpu都看到了相同的数据,而store buffer和Invalidate Queues是每一个cpu私有的。这就导致了一系列的编程问题,下文会详细展开。
CPU缓存
Cpu为了平衡自身处理速度过快和主存读写速度过慢这个问题,使用了缓存来存储处理中的热点数据。cpu需要处理数据的时候(包含读取和写出),都是直接向缓存发出读写指令。如果数据不在缓存中,则会从主存中读取数据到缓存中,再做对应的处理。需要注意的是,cpu读取数据到缓存中,是固定长度的读取。也就是说cpu缓存是一行一行的载入数据进来。因为也称之为cpu缓存行,即cacheline。而缓存中的数据,也会在合适的时候回写到主存当中(这个时机可以抽象的认为是由cpu自行决定的)。
现在的cpu都是多核cpu,为了在处理数据的时候保持缓存有效性,因此一个cpu需要数据而且该数据不在自身的cache中的时候,会同时向其他的cpu缓存和主存求取。如果其他的cpu缓存中有数据,则使用该数据,这样就保证了不会使用到主存中的错误的尚未更新的旧数据。
而各个cpu的内部缓存依靠MESI缓存一致性协议来进行协调。以此保证各个Cpu看到的内容是一致的。
Store buffer
如果一个Cpu要写出一个数据,但是此时这个数据不在自己的cacheline中,因为cpu要向其他的cpu缓存发出read invalidate消息。等待其他的cpu返回read response和invalidate ack消息后,将数据写入这个cacheline。这里就存在着时间的浪费,因为不管其他的cpu返回的是什么数据,本cpu都是要将它覆盖的。而在等待的这段时间,cpu无事可干,只能空转。为了让cpu不至于空闲,因为设计了store buffer组件。store buffer是每个cpu独享的写入缓存空间,用于存储对cacheline的写入,而且速度比cacheline高一个数量级,但是容量非常少。
但是store buffer会产生在单核上的读写不一致问题。下面是模拟
a = 1; b = a+1; assert b==2;
假设a不在本cpu的cacheline中。在其他cpu的cacheline中,值为0.会有如下的步骤
序号 | 操作内容 |
---|---|
1 | 发现a的地址不在本cpu的cacheline中,向其他的cpu发送read invalidate消息 |
2 | 将数据写入store buffer中 |
3 | 收到其他cpu响应的read response和invalidate ack消息 |
4 | 执行b=a+1,因为a这个时候已经在cacheline中,读取到值为0,加1后为1,写入到b中 |
5 | 执行assert b==2 失败,因为b是1 |
6 | store buffer中的值刷新到a的cacheline中,修改a的值为1,但是已经太晚了 |
为了避免这个问题,所以对于store buffer的设计中增加一个策略叫做store forwarding。就是说cpu在读取数据的时候会先查看store buffer,如果store buffer中有数据,直接用store buffer中的。这样,也就避免了使用错误数据的问题了。
store forwarding可以解决在单线程中的数据不一致问题,但是store buffer所带来的复杂性远不止如此。在多线程环境下,会有其他的问题。下面是模拟代码
public void set(){ a=1; b=1; } public void print(){ while(b==0) ; assert a==1; }
假设a和b的值都是0,其中b在cpu0中,a在cpu1中。cpu0执行set方法,cpu1执行print方法。
序号 | cpu0的步骤(执行set) | cpu1的步骤(执行print) |
---|---|---|
1 | 想写入a=1,但是由于a不在自身的cacheline中,向cpu1发送read invalidate消息 | 执行while(b==0),由于b不在自身的cacheline中,向cpu0发送read消息 |
2 | 向store buffer中写入a=1 | 等待cpu0响应的read response消息 |
3 | b在自身的cacheline中,并且此时状态为M或者E,写入b=1 | 等待cpu0响应的read response消息 |
4 | 收到cpu1的read请求,将b=1的值用read response消息传递,同时将b所在的cacheline修改状态为s | 等待cpu0响应的read response消息 |
5 | 等待cpu1的read response和invalidate ack消息 | 收到cpu0的read response消息,将b置为1,因此程序跳出循环 |
6 | 等待cpu1的read response和invalidate ack消息 | 因为a在自身的cacheline中,所以读取后进行比对。assert a==1失败。因为此时a在自身cacheline中的值还是0,而且该cacheline尚未失效 |
7 | 等待cpu1的read response和invalidate ack消息 | 收到cpu0发送的read invalidate消息,将a所在的cacheline设置为无效,但是 为时已晚,错误的判断结果已经产生了 |
8 | 收到cpu1响应的read response和invalidate ack消息,将store buffer中的值写入cacheline中 | 无 |
通过上面的例子可以看到,在多核系统中,store buffer的存在让程序的结果与我们的预期不相符合。上面的程序中,由于store buffer的存在,所以在cacheline中的操作顺序实际上先b=1
然后a=1
。就好像操作被重排序一样(重排序这个词在很多文章中都有,但是定义不详,不好理解。实际上直接理解store buffer会简单很多)。为了解决这样的问题,cpu提供了一些操作指令,来帮助我们避免这样的问题。这样的指令就是内存屏障(英文fence,也翻译叫做栅栏)。来看下面的代码
public void set(){ a=1; smp_mb()